Massive refactor.
This commit is contained in:
parent
2561127221
commit
ea1f43dcd3
12 changed files with 2171 additions and 2088 deletions
File diff suppressed because it is too large
Load diff
438
inventory/static/js/components/grid/encode-decode.js
Normal file
438
inventory/static/js/components/grid/encode-decode.js
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
import { SHAPE_DEFAULTS } from "./widget-core.js";
|
||||
|
||||
function shortenKeys(shapes) {
|
||||
const keyMap = {
|
||||
type: 't',
|
||||
points: 'p',
|
||||
color: 'cl', // avoid collision with x2
|
||||
strokeWidth: 'sw',
|
||||
strokeOpacity: 'so',
|
||||
fillOpacity: 'fo',
|
||||
fill: 'f',
|
||||
|
||||
x: 'x',
|
||||
y: 'y',
|
||||
w: 'w',
|
||||
h: 'h',
|
||||
|
||||
x1: 'a',
|
||||
y1: 'b',
|
||||
x2: 'c',
|
||||
y2: 'd'
|
||||
};
|
||||
|
||||
return shapes.map((shape) => {
|
||||
const out = {};
|
||||
for (const key of Object.keys(shape)) {
|
||||
const newKey = keyMap[key] || key;
|
||||
out[newKey] = shape[key];
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
function shortenShapes(shapes) {
|
||||
const shapeMap = { path: 'p', line: 'l', rect: 'r', ellipse: 'e', stateChange: 's' };
|
||||
return shapes.map(shape => ({
|
||||
...shape,
|
||||
type: shapeMap[shape.type] || shape.type
|
||||
}));
|
||||
}
|
||||
|
||||
function collapseStateChanges(shapes) {
|
||||
const out = [];
|
||||
let pending = null;
|
||||
|
||||
const flush = () => {
|
||||
if (pending) out.push(pending);
|
||||
pending = null;
|
||||
};
|
||||
|
||||
for (const shape of shapes) {
|
||||
if (shape.type === "stateChange") {
|
||||
if (!pending) pending = { ...shape };
|
||||
else {
|
||||
for (const [k, v] of Object.entries(shape)) {
|
||||
if (k !== "type") pending[k] = v;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
flush();
|
||||
out.push(shape);
|
||||
}
|
||||
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
function stateCode(shapes, SHAPE_DEFAULTS) {
|
||||
const state = {
|
||||
...SHAPE_DEFAULTS,
|
||||
color: "#000000",
|
||||
fill: false,
|
||||
fillOpacity: 1
|
||||
};
|
||||
|
||||
const styleKeys = Object.keys(state);
|
||||
const out = [];
|
||||
|
||||
for (const shape of shapes) {
|
||||
const s = { ...shape };
|
||||
const stateChange = {};
|
||||
|
||||
for (const key of styleKeys) {
|
||||
if (!(key in s)) continue;
|
||||
|
||||
if (s[key] !== state[key]) {
|
||||
stateChange[key] = s[key];
|
||||
state[key] = s[key];
|
||||
}
|
||||
|
||||
delete s[key];
|
||||
}
|
||||
|
||||
if (Object.keys(stateChange).length > 0) {
|
||||
out.push({ type: "stateChange", ...stateChange });
|
||||
}
|
||||
|
||||
out.push(s);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function computeDeltas(shapes) {
|
||||
const q = 100;
|
||||
|
||||
const out = [];
|
||||
let prevKind = null;
|
||||
|
||||
let prevBR = null;
|
||||
|
||||
let prevLineEnd = null;
|
||||
|
||||
const MAX_DOC_COORD = 1_000_000;
|
||||
const MAX_INT = MAX_DOC_COORD * q;
|
||||
|
||||
const clampInt = (v) => {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
if (v > MAX_INT) return MAX_INT;
|
||||
if (v < -MAX_INT) return -MAX_INT;
|
||||
return v;
|
||||
};
|
||||
|
||||
const toInt = (n) => clampInt(Math.round(Number(n) * q));
|
||||
|
||||
const resetRun = () => {
|
||||
prevKind = null;
|
||||
prevBR = null;
|
||||
prevLineEnd = null;
|
||||
};
|
||||
|
||||
for (const shape of shapes) {
|
||||
if (shape.type === "stateChange") {
|
||||
out.push(shape);
|
||||
resetRun();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shape.type === "path") {
|
||||
const s = { ...shape };
|
||||
if (!Array.isArray(s.points) || s.points.length === 0) {
|
||||
out.push(s);
|
||||
resetRun();
|
||||
continue;
|
||||
}
|
||||
|
||||
const pts = [toInt(s.points[0].x), toInt(s.points[0].y)];
|
||||
let prev = s.points[0];
|
||||
for (let i = 1; i < s.points.length; i++) {
|
||||
const cur = s.points[i];
|
||||
pts.push(toInt(cur.x - prev.x), toInt(cur.y - prev.y));
|
||||
prev = cur;
|
||||
}
|
||||
s.points = pts;
|
||||
out.push(s);
|
||||
resetRun();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shape.type === "line") {
|
||||
const s = { ...shape };
|
||||
|
||||
const x1 = toInt(s.x1), y1 = toInt(s.y1);
|
||||
const x2 = toInt(s.x2), y2 = toInt(s.y2);
|
||||
|
||||
let arr;
|
||||
if (prevKind !== "line" || !prevLineEnd) {
|
||||
arr = [x1, y1, x2 - x1, y2 - y1];
|
||||
} else {
|
||||
arr = [x1 - prevLineEnd.x2, y1 - prevLineEnd.y2, x2 - x1, y2 - y1];
|
||||
}
|
||||
|
||||
prevKind = "line";
|
||||
prevLineEnd = { x2, y2 };
|
||||
|
||||
delete s.x1; delete s.y1; delete s.x2; delete s.y2;
|
||||
s.points = arr;
|
||||
out.push(s);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shape.type === "rect" || shape.type === "ellipse") {
|
||||
const s = { ...shape };
|
||||
|
||||
const x = toInt(s.x), y = toInt(s.y);
|
||||
const w = toInt(s.w), h = toInt(s.h);
|
||||
|
||||
let arr;
|
||||
if (prevKind !== s.type || !prevBR) {
|
||||
arr = [x, y, w, h];
|
||||
} else {
|
||||
arr = [x - prevBR.x, y - prevBR.y, w, h];
|
||||
}
|
||||
|
||||
prevKind = s.type;
|
||||
prevBR = { x: x + w, y: y + h };
|
||||
|
||||
delete s.x; delete s.y; delete s.w; delete s.h;
|
||||
s.points = arr;
|
||||
out.push(s);
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(shape);
|
||||
resetRun();
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeRuns(shapes) {
|
||||
const out = [];
|
||||
let run = null;
|
||||
|
||||
const flush = () => {
|
||||
if (!run) return;
|
||||
out.push(run);
|
||||
run = null;
|
||||
};
|
||||
|
||||
for (const shape of shapes) {
|
||||
if (shape.type === 'path' || shape.type === 'stateChange') {
|
||||
flush();
|
||||
out.push(shape);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!run) {
|
||||
run = { ...shape, points: [...shape.points] };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shape.type === run.type) {
|
||||
run.points.push(...shape.points);
|
||||
} else {
|
||||
flush();
|
||||
run = { ...shape, points: [...shape.points] };
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeStates(shapes) {
|
||||
return shapes.map(shape => {
|
||||
if (shape.type !== 'stateChange') return shape;
|
||||
const re = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||
|
||||
let newShape = {};
|
||||
Object.keys(shape).forEach(key => {
|
||||
if (key === 'strokeOpacity' || key === 'strokeWidth' || key === 'fillOpacity') {
|
||||
const v = Number(shape[key]);
|
||||
if (Number.isFinite(v))
|
||||
newShape[key] = Math.round(v * 100);
|
||||
} else if (key === 'color') {
|
||||
newShape[key] = re.test(shape[key]) ? shape[key] : '#000000';
|
||||
} else if (key === 'fill') {
|
||||
newShape[key] = !!shape[key];
|
||||
}
|
||||
});
|
||||
return { ...shape, ...newShape };
|
||||
});
|
||||
}
|
||||
|
||||
export function encode({ cellSize, shapes, stripCaches, SHAPE_DEFAULTS }) {
|
||||
if (!SHAPE_DEFAULTS) SHAPE_DEFAULTS = { strokeWidth: 0.12, strokeOpacity: 1, fillOpacity: 1 };
|
||||
|
||||
const cs = Number(cellSize);
|
||||
const safeCellSize = Number.isFinite(cs) && cs >= 1 ? cs : 25;
|
||||
|
||||
const safeShapes = Array.isArray(shapes) ? shapes : [];
|
||||
const stripped = (typeof stripCaches === "function") ? stripCaches(safeShapes) : safeShapes;
|
||||
|
||||
const payload = {
|
||||
v: 1,
|
||||
cs: safeCellSize,
|
||||
q: 100,
|
||||
d: {
|
||||
cl: "#000000",
|
||||
f: false,
|
||||
sw: 12,
|
||||
so: 100,
|
||||
fo: 100
|
||||
},
|
||||
s: shortenKeys(
|
||||
shortenShapes(
|
||||
encodeStates(
|
||||
encodeRuns(
|
||||
computeDeltas(
|
||||
collapseStateChanges(
|
||||
stateCode(stripped, SHAPE_DEFAULTS)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
function decodePath(arr, q) {
|
||||
let x = arr[0], y = arr[1];
|
||||
const pts = [{ x: x / q, y: y / q }];
|
||||
for (let i = 2; i < arr.length; i += 2) {
|
||||
x += arr[i];
|
||||
y += arr[i + 1];
|
||||
pts.push({ x: x / q, y: y / q });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
export function decode(doc) {
|
||||
const q = Number(doc?.q) || 100;
|
||||
const cs = Number(doc?.cs) || 25;
|
||||
|
||||
const defaults = doc?.d || {};
|
||||
const state = {
|
||||
color: defaults.cl ?? "#000000",
|
||||
fill: !!defaults.f,
|
||||
strokeWidth: (Number(defaults.sw) ?? 12) / 100,
|
||||
strokeOpacity: (Number(defaults.so) ?? 100) / 100,
|
||||
fillOpacity: (Number(defaults.fo) ?? 100) / 100
|
||||
};
|
||||
|
||||
const outShapes = [];
|
||||
|
||||
const num01 = (v, fallback) => {};
|
||||
|
||||
const applyStateChange = (op) => {
|
||||
if ("cl" in op) state.color = op.cl;
|
||||
if ("f" in op) state.fill = !!op.f;
|
||||
if ("sw" in op) state.strokeWidth = num01(op.sw, state.strokeWidth * 100) / 100;
|
||||
if ("so" in op) state.strokeOpacity = num01(op.so, state.strokeOpacity * 100) / 100;
|
||||
if ("fo" in op) state.fillOpacity = num01(op.fo, state.fillOpacity * 100) / 100;
|
||||
};
|
||||
|
||||
const ops = Array.isArray(doc?.s) ? doc.s : [];
|
||||
for (const op of ops) {
|
||||
if (!op || typeof op !== "object") continue;
|
||||
|
||||
const t = op.t;
|
||||
if (t === "s") {
|
||||
applyStateChange(op);
|
||||
continue;
|
||||
}
|
||||
|
||||
const arr = op.p;
|
||||
if (!Array.isArray(arr) || arr.length === 0) continue;
|
||||
|
||||
if (t === "p") {
|
||||
if (arr.length < 2 || (arr.length % 2) !== 0) continue;
|
||||
|
||||
outShapes.push({
|
||||
type: "path",
|
||||
points: decodePath(arr, q),
|
||||
color: state.color,
|
||||
strokeWidth: state.strokeWidth,
|
||||
strokeOpacity: state.strokeOpacity
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((arr.length % 4) !== 0) continue;
|
||||
|
||||
if (t === "l") {
|
||||
let prevX2 = null, prevY2 = null;
|
||||
|
||||
for (let i = 0; i < arr.length; i += 4) {
|
||||
const a = arr[i], b = arr[i + 1], c = arr[i + 2], d = arr[i + 3];
|
||||
|
||||
let x1, y1;
|
||||
if (i === 0) {
|
||||
x1 = a; y1 = b;
|
||||
} else {
|
||||
x1 = prevX2 + a;
|
||||
y1 = prevY2 + b;
|
||||
}
|
||||
|
||||
const x2 = x1 + c;
|
||||
const y2 = y1 + d;
|
||||
|
||||
outShapes.push({
|
||||
type: "line",
|
||||
x1: x1 / q, y1: y1 / q, x2: x2 / q, y2: y2 / q,
|
||||
color: state.color,
|
||||
strokeWidth: state.strokeWidth,
|
||||
strokeOpacity: state.strokeOpacity
|
||||
});
|
||||
|
||||
prevX2 = x2; prevY2 = y2;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (t === "r" || t === "e") {
|
||||
let prevBRx = null, prevBRy = null;
|
||||
|
||||
for (let i = 0; i < arr.length; i += 4) {
|
||||
const a = arr[i], b = arr[i + 1], c = arr[i + 2], d = arr[i + 3];
|
||||
|
||||
let x, y;
|
||||
if (i === 0) {
|
||||
x = a; y = b;
|
||||
} else {
|
||||
x = prevBRx + a;
|
||||
y = prevBRy + b;
|
||||
}
|
||||
|
||||
const w = c, h = d;
|
||||
|
||||
outShapes.push({
|
||||
type: (t === "r") ? "rect" : "ellipse",
|
||||
x: x / q, y: y / q, w: w / q, h: h / q,
|
||||
color: state.color,
|
||||
fill: state.fill,
|
||||
fillOpacity: state.fillOpacity,
|
||||
strokeWidth: state.strokeWidth,
|
||||
strokeOpacity: state.strokeOpacity
|
||||
});
|
||||
|
||||
prevBRx = x + w;
|
||||
prevBRy = y + h;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: Number(doc?.v) || 1,
|
||||
cellSize: cs,
|
||||
shapes: outShapes
|
||||
};
|
||||
}
|
||||
117
inventory/static/js/components/grid/geometry.js
Normal file
117
inventory/static/js/components/grid/geometry.js
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
export function dist2(a, b) {
|
||||
const dx = a.x - b.x, dy = a.y - b.y;
|
||||
return dx * dx + dy * dy;
|
||||
}
|
||||
|
||||
export function pointToSegmentDist2(p, a, b) {
|
||||
const vx = b.x - a.x, vy = b.y - a.y;
|
||||
const wx = p.x - a.x, wy = p.y - a.y;
|
||||
|
||||
const c1 = vx * wx + vy * wy;
|
||||
if (c1 <= 0) return dist2(p, a);
|
||||
|
||||
const c2 = vx * vx + vy * vy;
|
||||
if (c2 <= c1) return dist2(p, b);
|
||||
|
||||
const t = c1 / c2;
|
||||
const proj = { x: a.x + t * vx, y: a.y + t * vy };
|
||||
return dist2(p, proj);
|
||||
}
|
||||
|
||||
function hitShape(p, s, tol) {
|
||||
if (s.type === 'line') {
|
||||
const a = { x: s.x1, y: s.y1 };
|
||||
const b = { x: s.x2, y: s.y2 };
|
||||
const sw = Math.max(0, Number(s.strokeWidth) || 0) / 2;
|
||||
const t = tol + sw;
|
||||
return pointToSegmentDist2(p, a, b) <= (t * t);
|
||||
}
|
||||
|
||||
if (s.type === 'path') {
|
||||
const pts = (s.renderPoints?.length >= 2) ? s.renderPoints : s.points;
|
||||
if (!pts || pts.length < 2) return false;
|
||||
|
||||
const sw = Math.max(0, Number(s.strokeWidth) || 0) / 2;
|
||||
const t = tol + sw;
|
||||
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
if (pointToSegmentDist2(p, pts[i], pts[i + 1]) <= (t * t)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (s.type === 'rect') {
|
||||
return hitRect(p, s, tol);
|
||||
}
|
||||
|
||||
if (s.type === 'ellipse') {
|
||||
return hitEllipse(p, s, tol);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hitRect(p, r, tol) {
|
||||
const x1 = r.x, y1 = r.y, x2 = r.x + r.w, y2 = r.y + r.h;
|
||||
const minX = Math.min(x1, x2), maxX = Math.max(x1, x2);
|
||||
const minY = Math.min(y1, y2), maxY = Math.max(y1, y2);
|
||||
|
||||
const inside = (p.x >= minX && p.x <= maxX && p.y >= minY && p.y <= maxY);
|
||||
|
||||
if (r.fill) {
|
||||
return (p.x >= minX - tol && p.x <= maxX + tol && p.y >= minY - tol && p.y <= maxY + tol);
|
||||
}
|
||||
|
||||
if (!inside) {
|
||||
if (p.x < minX - tol || p.x > maxX + tol || p.y < minY - tol || p.y > maxY + tol) return false;
|
||||
}
|
||||
|
||||
const nearLeft = Math.abs(p.x - minX) <= tol && p.y >= minY - tol && p.y <= maxY + tol;
|
||||
const nearRight = Math.abs(p.x - maxX) <= tol && p.y >= minY - tol && p.y <= maxY + tol;
|
||||
const nearTop = Math.abs(p.y - minY) <= tol && p.x >= minX - tol && p.x <= maxX + tol;
|
||||
const nearBottom = Math.abs(p.y - maxY) <= tol && p.x >= minX - tol && p.x <= maxX + tol;
|
||||
|
||||
return nearLeft || nearRight || nearTop || nearBottom;
|
||||
}
|
||||
|
||||
function hitEllipse(p, e, tol) {
|
||||
const cx = e.x + e.w / 2;
|
||||
const cy = e.y + e.h / 2;
|
||||
const rx = Math.abs(e.w / 2);
|
||||
const ry = Math.abs(e.h / 2);
|
||||
if (rx <= 0 || ry <= 0) return false;
|
||||
|
||||
const nx = (p.x - cx) / rx;
|
||||
const ny = (p.y - cy) / ry;
|
||||
const d = nx * nx + ny * ny;
|
||||
|
||||
if (e.fill) {
|
||||
const rx2 = (rx + tol);
|
||||
const ry2 = (ry + tol);
|
||||
const nnx = (p.x - cx) / rx2;
|
||||
const nny = (p.y - cy) / ry2;
|
||||
return (nnx * nnx + nny * nny) <= 1;
|
||||
}
|
||||
|
||||
const minR = Math.max(1e-6, Math.min(rx, ry));
|
||||
const band = tol / minR;
|
||||
return Math.abs(d - 1) <= Math.max(0.02, band);
|
||||
}
|
||||
|
||||
export function pickShapeAt(docPt, shapes, cellSize, opts = {}) {
|
||||
const pxTol = opts.pxTol ?? 6;
|
||||
const cs = Number(cellSize);
|
||||
const safeCellSize = (Number.isFinite(cs) && cs > 0) ? cs : 25;
|
||||
|
||||
const tol = pxTol / safeCellSize;
|
||||
|
||||
for (let i = shapes.length - 1; i >= 0; i--) {
|
||||
const s = shapes[i];
|
||||
if (!s) continue;
|
||||
|
||||
if (hitShape(docPt, s, tol)) {
|
||||
return { index: i, shape: s };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
23
inventory/static/js/components/grid/global-bindings.js
Normal file
23
inventory/static/js/components/grid/global-bindings.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
(function bindGridGlobalOnce() {
|
||||
if (window.__gridGlobalBound) return;
|
||||
window.__gridGlobalBound = true;
|
||||
|
||||
window.activeGridWidget = null;
|
||||
|
||||
// Keydown (undo/redo, escape)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const w = window.activeGridWidget;
|
||||
if (!w || typeof w.handleKeyDown !== 'function') return;
|
||||
w.handleKeyDown(e);
|
||||
});
|
||||
|
||||
// Pointer finalize (for drawing finishing outside the element)
|
||||
const forwardPointer = (e) => {
|
||||
const w = window.__gridPointerOwner || window.activeGridWidget;
|
||||
if (!w || typeof w.handleGlobalPointerUp !== 'function') return;
|
||||
w.handleGlobalPointerUp(e);
|
||||
};
|
||||
|
||||
window.addEventListener('pointerup', forwardPointer, { capture: true });
|
||||
window.addEventListener('pointercancel', forwardPointer, { capture: true });
|
||||
})();
|
||||
46
inventory/static/js/components/grid/index.js
Normal file
46
inventory/static/js/components/grid/index.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import './global-bindings.js';
|
||||
import { initGridWidget } from './widget-init.js';
|
||||
|
||||
const GRID_BOOT = window.__gridBootMap || (window.__gridBootMap = new WeakMap());
|
||||
|
||||
(function autoBootGridWidgets() {
|
||||
function bootRoot(root) {
|
||||
if (GRID_BOOT.has(root)) return;
|
||||
GRID_BOOT.set(root, true);
|
||||
|
||||
const mode = root.dataset.mode || 'editor';
|
||||
const storageKey = root.dataset.storageKey || root.dataset.key || 'gridDoc';
|
||||
|
||||
const api = initGridWidget(root, { mode, storageKey });
|
||||
root.__gridApi = api;
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-grid-widget]').forEach(bootRoot);
|
||||
|
||||
const mo = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
for (const node of m.removedNodes) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
|
||||
const roots = [];
|
||||
if (node.matches?.('[data-grid-widget]')) roots.push(node);
|
||||
node.querySelectorAll?.('[data-grid-widget]').forEach(r => roots.push(r));
|
||||
|
||||
for (const r of roots) {
|
||||
r.__gridApi?.destroy?.();
|
||||
r.__gridApi = null;
|
||||
GRID_BOOT.delete(r);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of m.addedNodes) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
|
||||
if (node.matches?.('[data-grid-widget]')) bootRoot(node);
|
||||
node.querySelectorAll?.('[data-grid-widget]').forEach(bootRoot);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mo.observe(document.documentElement, { childList: true, subtree: true });
|
||||
})();
|
||||
42
inventory/static/js/components/grid/simplify.js
Normal file
42
inventory/static/js/components/grid/simplify.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { pointToSegmentDist2 } from './geometry.js';
|
||||
|
||||
export function simplifyRDP(points, epsilon) {
|
||||
if (!Array.isArray(points) || points.length < 3) return points || [];
|
||||
const e = Number(epsilon);
|
||||
const eps2 = Number.isFinite(e) ? e * e : 0;
|
||||
|
||||
function rdp(first, last, out) {
|
||||
let maxD2 = 0;
|
||||
let idx = -1;
|
||||
|
||||
const a = points[first];
|
||||
const b = points[last];
|
||||
|
||||
for (let i = first + 1; i < last; ++i) {
|
||||
const d2 = pointToSegmentDist2(points[i], a, b);
|
||||
if (d2 > maxD2) {
|
||||
maxD2 = d2;
|
||||
idx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxD2 > eps2 && idx !== -1) {
|
||||
rdp(first, idx, out);
|
||||
out.pop();
|
||||
rdp(idx, last, out);
|
||||
} else {
|
||||
out.push(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
const out = [];
|
||||
rdp(0, points.length - 1, out);
|
||||
|
||||
const deduped = [out[0]];
|
||||
for (let i = 1; i < out.length; i++) {
|
||||
const prev = deduped[deduped.length - 1];
|
||||
const cur = out[i];
|
||||
if (prev.x !== cur.x || prev.y !== cur.y) deduped.push(cur);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
90
inventory/static/js/components/grid/spline.js
Normal file
90
inventory/static/js/components/grid/spline.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
export function catmullRomResample(points, {
|
||||
alpha = 0.5,
|
||||
samplesPerSeg = 8,
|
||||
maxSamplesPerSeg = 32,
|
||||
minSamplesPerSeg = 4,
|
||||
closed = false,
|
||||
maxOutputPoints = 5000
|
||||
} = {}) {
|
||||
if (!Array.isArray(points) || points.length < 2) return points || [];
|
||||
|
||||
const dist = (a, b) => {
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
return Math.hypot(dx, dy);
|
||||
};
|
||||
|
||||
const tj = (ti, pi, pj) => ti + Math.pow(dist(pi, pj), alpha);
|
||||
|
||||
const lerp2 = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
|
||||
|
||||
function evalSegment(p0, p1, p2, p3, t) {
|
||||
let t0 = 0;
|
||||
let t1 = tj(t0, p0, p1);
|
||||
let t2 = tj(t1, p1, p2);
|
||||
let t3 = tj(t2, p2, p3);
|
||||
|
||||
const eps = 1e-6;
|
||||
if (t1 - t0 < eps) t1 = t0 + eps;
|
||||
if (t2 - t1 < eps) t2 = t1 + eps;
|
||||
if (t3 - t2 < eps) t3 = t2 + eps;
|
||||
|
||||
const u = t1 + (t2 - t1) * t;
|
||||
|
||||
const A1 = lerp2(p0, p1, (u - t0) / (t1 - t0));
|
||||
const A2 = lerp2(p1, p2, (u - t1) / (t2 - t1));
|
||||
const A3 = lerp2(p2, p3, (u - t2) / (t3 - t2));
|
||||
|
||||
const B1 = lerp2(A1, A2, (u - t0) / (t2 - t0));
|
||||
const B2 = lerp2(A2, A3, (u - t1) / (t3 - t1));
|
||||
|
||||
const C = lerp2(B1, B2, (u - t1) / (t2 - t1));
|
||||
return C;
|
||||
}
|
||||
|
||||
const src = (points || []).filter(p =>
|
||||
p && Number.isFinite(p.x) && Number.isFinite(p.y)
|
||||
);
|
||||
if (src.length < 2) return src;
|
||||
|
||||
const n = src.length;
|
||||
|
||||
const get = (i) => {
|
||||
if (closed) {
|
||||
const k = (i % n + n) % n;
|
||||
return src[k];
|
||||
}
|
||||
if (i < 0) return src[0];
|
||||
if (i >= n) return src[n - 1];
|
||||
return src[i];
|
||||
};
|
||||
|
||||
const out = [];
|
||||
const pushPoint = (p) => {
|
||||
if (out.length >= maxOutputPoints) return false;
|
||||
const prev = out[out.length - 1];
|
||||
if (!prev || prev.x !== p.x || prev.y !== p.y) out.push(p);
|
||||
return true;
|
||||
};
|
||||
|
||||
pushPoint({ x: src[0].x, y: src[0].y });
|
||||
|
||||
const segCount = closed ? n : (n - 1);
|
||||
for (let i = 0; i < segCount; i++) {
|
||||
const p0 = get(i - 1);
|
||||
const p1 = get(i);
|
||||
const p2 = get(i + 1);
|
||||
const p3 = get(i + 2);
|
||||
|
||||
const segLen = dist(p1, p2);
|
||||
const adaptive = Math.round(samplesPerSeg * Math.max(1, segLen * 0.75));
|
||||
const steps = Math.max(minSamplesPerSeg, Math.min(maxSamplesPerSeg, adaptive));
|
||||
|
||||
for (let s = 1; s <= steps; s++) {
|
||||
const t = s / steps;
|
||||
const p = evalSegment(p0, p1, p2, p3, t);
|
||||
if (!pushPoint(p)) return out;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
459
inventory/static/js/components/grid/widget-core.js
Normal file
459
inventory/static/js/components/grid/widget-core.js
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
import { catmullRomResample } from './spline.js';
|
||||
|
||||
export const DEFAULT_DOC = { version: 1, cellSize: 25, shapes: [] };
|
||||
export const SHAPE_DEFAULTS = {
|
||||
strokeWidth: 0.12,
|
||||
strokeOpacity: 1,
|
||||
fillOpacity: 1
|
||||
};
|
||||
|
||||
export function createWidgetCore(env) {
|
||||
let {
|
||||
root, mode, storageKey,
|
||||
gridEl, canvasEl,
|
||||
viewerOffset = { x: 0, y: 0 },
|
||||
} = env;
|
||||
|
||||
let doc = env.doc || structuredClone(DEFAULT_DOC);
|
||||
let cellSize = Number(env.cellSize) || 25;
|
||||
let shapes = Array.isArray(env.shapes) ? env.shapes : [];
|
||||
let selectedShape = env.selectedShape || null;
|
||||
|
||||
let ctx = null;
|
||||
let dpr = 1;
|
||||
|
||||
function clamp01(n, fallback = 1) {
|
||||
const x = Number(n);
|
||||
return Number.isFinite(x) ? Math.min(1, Math.max(0, x)) : fallback;
|
||||
}
|
||||
|
||||
function isFiniteNum(n) { return Number.isFinite(Number(n)); }
|
||||
|
||||
// Document and shape lifecycle
|
||||
|
||||
function loadDoc() {
|
||||
try { return JSON.parse(localStorage.getItem(storageKey)) || structuredClone(DEFAULT_DOC); }
|
||||
catch { return structuredClone(DEFAULT_DOC); }
|
||||
}
|
||||
|
||||
function saveDoc(nextDoc = doc) {
|
||||
const safeDoc = {
|
||||
...nextDoc,
|
||||
shapes: stripCaches(Array.isArray(nextDoc.shapes) ? nextDoc.shapes : [])
|
||||
};
|
||||
doc = safeDoc;
|
||||
try { localStorage.setItem(storageKey, JSON.stringify(safeDoc)); } catch { }
|
||||
}
|
||||
|
||||
function setDoc(nextDoc) {
|
||||
const d = nextDoc && typeof nextDoc === 'object' ? nextDoc : DEFAULT_DOC;
|
||||
cellSize = Number(d.cellSize) || 25;
|
||||
shapes = rebuildPathCaches(
|
||||
sanitizeShapes(Array.isArray(d.shapes) ? d.shapes : [])
|
||||
);
|
||||
doc = { version: Number(d.version) || 1, cellSize, shapes };
|
||||
|
||||
if (mode === 'editor') {
|
||||
saveDoc(doc);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => resizeAndSetupCanvas());
|
||||
}
|
||||
|
||||
function sanitizeShapes(list) {
|
||||
const allowed = new Set(['rect', 'ellipse', 'line', 'path']);
|
||||
|
||||
const normStroke = (v, fallback = 0.12) => {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.max(0, n);
|
||||
};
|
||||
|
||||
return list.flatMap((s) => {
|
||||
if (!s || typeof s !== 'object' || !allowed.has(s.type)) return [];
|
||||
|
||||
const color = typeof s.color === 'string' ? s.color : '#000000';
|
||||
const fillOpacity = clamp01(s.fillOpacity, SHAPE_DEFAULTS.fillOpacity);
|
||||
const strokeOpacity = clamp01(s.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
|
||||
|
||||
if (s.type === 'line') {
|
||||
if (!['x1', 'y1', 'x2', 'y2'].every(k => isFiniteNum(s[k]))) return [];
|
||||
return [{
|
||||
type: 'line',
|
||||
x1: +s.x1, y1: +s.y1, x2: +s.x2, y2: +s.y2,
|
||||
color,
|
||||
strokeWidth: normStroke(s.strokeWidth, SHAPE_DEFAULTS.strokeWidth),
|
||||
strokeOpacity
|
||||
}];
|
||||
}
|
||||
|
||||
if (s.type === 'path') {
|
||||
if (!Array.isArray(s.points) || s.points.length < 2) return [];
|
||||
|
||||
const points = s.points.flatMap(p => {
|
||||
if (!p || !isFiniteNum(p.x) || !isFiniteNum(p.y)) return [];
|
||||
return [{ x: +p.x, y: +p.y }];
|
||||
});
|
||||
if (points.length < 2) return [];
|
||||
|
||||
return [{
|
||||
type: 'path',
|
||||
points,
|
||||
color,
|
||||
strokeWidth: normStroke(s.strokeWidth, SHAPE_DEFAULTS.strokeWidth),
|
||||
strokeOpacity
|
||||
}];
|
||||
}
|
||||
|
||||
if (!['x', 'y', 'w', 'h'].every(k => isFiniteNum(s[k]))) return [];
|
||||
return [{
|
||||
type: s.type,
|
||||
x: +s.x, y: +s.y, w: +s.w, h: +s.h,
|
||||
color,
|
||||
fill: !!s.fill,
|
||||
fillOpacity,
|
||||
strokeOpacity,
|
||||
strokeWidth: normStroke(s.strokeWidth, SHAPE_DEFAULTS.strokeWidth)
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function stripCaches(shapes) {
|
||||
return shapes.map(s => {
|
||||
if (s.type === 'path') {
|
||||
return {
|
||||
type: 'path',
|
||||
points: s.points,
|
||||
color: s.color,
|
||||
strokeWidth: s.strokeWidth,
|
||||
strokeOpacity: s.strokeOpacity
|
||||
};
|
||||
}
|
||||
if (s.type === 'line') {
|
||||
return {
|
||||
type: 'line',
|
||||
x1: s.x1, y1: s.y1, x2: s.x2, y2: s.y2,
|
||||
color: s.color,
|
||||
strokeWidth: s.strokeWidth,
|
||||
strokeOpacity: s.strokeOpacity
|
||||
};
|
||||
}
|
||||
if (s.type === 'rect' || s.type === 'ellipse') {
|
||||
return {
|
||||
type: s.type,
|
||||
x: s.x, y: s.y, w: s.w, h: s.h,
|
||||
color: s.color,
|
||||
fill: !!s.fill,
|
||||
fillOpacity: s.fillOpacity,
|
||||
strokeOpacity: s.strokeOpacity,
|
||||
strokeWidth: s.strokeWidth
|
||||
};
|
||||
}
|
||||
return s; // shouldn't happen
|
||||
});
|
||||
}
|
||||
|
||||
function rebuildPathCaches(list) {
|
||||
const MIN_PTS_FOR_SMOOTH = 4;
|
||||
const MIN_LEN = 2;
|
||||
const MIN_TURN = 0.15;
|
||||
|
||||
return list.map(s => {
|
||||
if (s.type !== 'path') return s;
|
||||
|
||||
const pts = s.points;
|
||||
if (!Array.isArray(s.points) || pts.length < 2) return s;
|
||||
if (!pts.every(p => p && Number.isFinite(p.x) && Number.isFinite(p.y))) return s;
|
||||
|
||||
if (pathLength(pts) < MIN_LEN) return s;
|
||||
|
||||
if (pts.length < MIN_PTS_FOR_SMOOTH) return s;
|
||||
|
||||
if (MIN_TURN != null && totalTurning(pts) < MIN_TURN) return s;
|
||||
|
||||
const renderPoints = catmullRomResample(s.points, {
|
||||
alpha: 0.5,
|
||||
samplesPerSeg: 10,
|
||||
maxSamplesPerSeg: 40,
|
||||
minSamplesPerSeg: 6,
|
||||
closed: false,
|
||||
maxOutputPoints: 4000
|
||||
});
|
||||
|
||||
return {
|
||||
...s,
|
||||
...(renderPoints?.length >= 2 ? { renderPoints } : {})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function totalTurning(points) {
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const p0 = points[i - 1];
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1];
|
||||
|
||||
const v1x = p1.x - p0.x;
|
||||
const v1y = p1.y - p0.y;
|
||||
const v2x = p2.x - p1.x;
|
||||
const v2y = p2.y - p1.y;
|
||||
|
||||
const len1 = Math.hypot(v1x, v1y);
|
||||
const len2 = Math.hypot(v2x, v2y);
|
||||
|
||||
if (len1 === 0 || len2 === 0) continue;
|
||||
|
||||
const cross = Math.abs(v1x * v2y - v1y * v2x);
|
||||
|
||||
sum += cross / (len1 * len2);
|
||||
}
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
function pathLength(pts) {
|
||||
let L = 0;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const dx = pts[i].x - pts[i - 1].x;
|
||||
const dy = pts[i].y - pts[i - 1].y;
|
||||
L += Math.hypot(dx, dy);
|
||||
}
|
||||
return L;
|
||||
}
|
||||
|
||||
function getShapesBounds(shapes) {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
const expand = (x1, y1, x2, y2) => {
|
||||
minX = Math.min(minX, x1);
|
||||
minY = Math.min(minY, y1);
|
||||
maxX = Math.max(maxX, x2);
|
||||
maxY = Math.max(maxY, y2);
|
||||
};
|
||||
|
||||
for (const s of shapes || []) {
|
||||
if (!s) continue;
|
||||
|
||||
if (s.type === 'rect' || s.type === 'ellipse') {
|
||||
expand(s.x, s.y, s.x + s.w, s.y + s.h);
|
||||
} else if (s.type === 'line') {
|
||||
expand(
|
||||
Math.min(s.x1, s.x2), Math.min(s.y1, s.y2),
|
||||
Math.max(s.x1, s.x2), Math.max(s.y1, s.y2)
|
||||
);
|
||||
} else if (s.type === 'path') {
|
||||
const pts = (s.renderPoints?.length >= 2) ? s.renderPoints : s.points;
|
||||
if (!pts?.length) continue;
|
||||
for (const p of pts) expand(p.x, p.y, p.x, p.y);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(minX)) return null;
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
// Canvas pipeline
|
||||
|
||||
function resizeAndSetupCanvas() {
|
||||
dpr = window.devicePixelRatio || 1;
|
||||
|
||||
const w = gridEl.clientWidth;
|
||||
const h = gridEl.clientHeight;
|
||||
|
||||
canvasEl.width = Math.round(w * dpr);
|
||||
canvasEl.height = Math.round(h * dpr);
|
||||
|
||||
canvasEl.style.width = `${w}px`;
|
||||
canvasEl.style.height = `${h}px`;
|
||||
|
||||
ctx = canvasEl.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
redrawAll();
|
||||
}
|
||||
|
||||
function clearCanvas() {
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, canvasEl.width / dpr, canvasEl.height / dpr);
|
||||
}
|
||||
|
||||
function drawShape(shape) {
|
||||
if (!ctx) return;
|
||||
const toPx = (v) => v * cellSize;
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = shape.color || '#000000';
|
||||
ctx.lineWidth = Math.max(1, toPx(shape.strokeWidth ?? SHAPE_DEFAULTS.strokeWidth));
|
||||
|
||||
if (shape.type === 'rect' || shape.type === 'ellipse') {
|
||||
const x = toPx(shape.x);
|
||||
const y = toPx(shape.y);
|
||||
const w = toPx(shape.w);
|
||||
const h = toPx(shape.h);
|
||||
|
||||
ctx.globalAlpha = clamp01(shape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
|
||||
if (shape.type === 'rect') {
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
} else {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
if (shape.fill) {
|
||||
ctx.globalAlpha = clamp01(shape.fillOpacity, SHAPE_DEFAULTS.fillOpacity);
|
||||
ctx.fillStyle = shape.color;
|
||||
if (shape.type === 'rect') {
|
||||
ctx.fillRect(x, y, w, h);
|
||||
} else {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
} else if (shape.type === 'line') {
|
||||
const x1 = toPx(shape.x1);
|
||||
const y1 = toPx(shape.y1);
|
||||
const x2 = toPx(shape.x2);
|
||||
const y2 = toPx(shape.y2);
|
||||
|
||||
ctx.globalAlpha = clamp01(shape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
} else if (shape.type === 'path') {
|
||||
ctx.globalAlpha = clamp01(shape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
|
||||
|
||||
ctx.lineWidth = Math.max(1, toPx(shape.strokeWidth ?? SHAPE_DEFAULTS.strokeWidth));
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
|
||||
const pts = (shape.renderPoints && shape.renderPoints.length >= 2)
|
||||
? shape.renderPoints
|
||||
: shape.points;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toPx(pts[0].x), toPx(pts[0].y));
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
ctx.lineTo(toPx(pts[i].x), toPx(pts[i].y));
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function redrawAll() {
|
||||
if (!ctx || !shapes) return;
|
||||
|
||||
clearCanvas();
|
||||
|
||||
ctx.save();
|
||||
if (mode !== 'editor') {
|
||||
ctx.translate(viewerOffset.x, viewerOffset.y);
|
||||
}
|
||||
shapes.forEach(drawShape);
|
||||
|
||||
if (mode === 'editor' && selectedShape) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.setLineDash([6, 4]);
|
||||
drawShape({
|
||||
...selectedShape,
|
||||
fill: false,
|
||||
strokeWidth: Math.max(selectedShape.strokeWidth ?? 0.12, 0.12) + (2 / cellSize),
|
||||
strokeOpacity: 1
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function renderAllWithPreview(previewShape = null, dashed = true) {
|
||||
if (!ctx) return;
|
||||
clearCanvas();
|
||||
shapes.forEach(drawShape);
|
||||
|
||||
if (!previewShape) return;
|
||||
|
||||
ctx.save();
|
||||
if (dashed) ctx.setLineDash([5, 3]);
|
||||
drawShape(previewShape);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Coordinate conversion
|
||||
|
||||
function pxToGrid(v) {
|
||||
return v / cellSize;
|
||||
}
|
||||
|
||||
function pxToDocPoint(clientX, clientY) {
|
||||
const rect = gridEl.getBoundingClientRect();
|
||||
const x = Math.min(Math.max(clientX, rect.left), rect.right) - rect.left;
|
||||
const y = Math.min(Math.max(clientY, rect.top), rect.bottom) - rect.top;
|
||||
return { x: pxToGrid(x), y: pxToGrid(y) };
|
||||
}
|
||||
|
||||
// Tool state helpers
|
||||
|
||||
function getActiveTool() {
|
||||
const checked = root.querySelector('input[data-tool]:checked');
|
||||
return checked ? checked.value : 'pen';
|
||||
}
|
||||
|
||||
function setActiveTool(toolValue) {
|
||||
const el = root.querySelector(`input[data-tool][value="${CSS.escape(toolValue)}"]`);
|
||||
if (el) el.checked = true;
|
||||
}
|
||||
|
||||
function getActiveType() {
|
||||
const checked = root.querySelector('input[data-gridtype]:checked');
|
||||
return checked ? checked.value : 'noGrid';
|
||||
}
|
||||
|
||||
function setActiveType(typeValue) {
|
||||
const el = root.querySelector(`input[data-gridtype][value="${CSS.escape(typeValue)}"]`);
|
||||
if (el) el.checked = true;
|
||||
}
|
||||
|
||||
return {
|
||||
DEFAULT_DOC,
|
||||
SHAPE_DEFAULTS,
|
||||
|
||||
get doc() { return doc; },
|
||||
get cellSize() { return cellSize; },
|
||||
get shapes() { return shapes; },
|
||||
get ctx() { return ctx; },
|
||||
get selectedShape() { return selectedShape; },
|
||||
set selectedShape(v) { selectedShape = v; },
|
||||
set viewerOffset(v) { viewerOffset = v; },
|
||||
|
||||
loadDoc, saveDoc, setDoc,
|
||||
sanitizeShapes, stripCaches, rebuildPathCaches,
|
||||
getShapesBounds,
|
||||
|
||||
resizeAndSetupCanvas,
|
||||
redrawAll,
|
||||
renderAllWithPreview,
|
||||
pxToDocPoint,
|
||||
|
||||
getActiveTool, setActiveTool,
|
||||
getActiveType, setActiveType,
|
||||
|
||||
clamp01, pxToGrid, isFiniteNum,
|
||||
};
|
||||
}
|
||||
831
inventory/static/js/components/grid/widget-editor.js
Normal file
831
inventory/static/js/components/grid/widget-editor.js
Normal file
|
|
@ -0,0 +1,831 @@
|
|||
import { encode, decode } from './encode-decode.js';
|
||||
import { dist2, pickShapeAt } from './geometry.js';
|
||||
import { simplifyRDP } from './simplify.js';
|
||||
import { SHAPE_DEFAULTS } from './widget-core.js';
|
||||
|
||||
export function initWidgetEditor(core, env) {
|
||||
const { root, gridEl, gridWrapEl, toastMessage, storageKey } = env;
|
||||
|
||||
const MAX_HISTORY = 100;
|
||||
|
||||
const clearEl = root.querySelector('[data-clear]');
|
||||
const colorEl = root.querySelector('[data-color]');
|
||||
const coordsEl = root.querySelector('[data-coords]');
|
||||
const dotEl = root.querySelector('[data-dot]');
|
||||
const dotSVGEl = root.querySelector('[data-dot-svg]');
|
||||
const exportEl = root.querySelector('[data-export]');
|
||||
const importButtonEl = root.querySelector('[data-import-button]');
|
||||
const importEl = root.querySelector('[data-import]');
|
||||
const cellSizeEl = root.querySelector('[data-cell-size]');
|
||||
const toolBarEl = root.querySelector('[data-toolbar]');
|
||||
const fillOpacityEl = root.querySelector('[data-fill-opacity]');
|
||||
const strokeOpacityEl = root.querySelector('[data-stroke-opacity]');
|
||||
const strokeWidthEl = root.querySelector('[data-stroke-width]');
|
||||
const cellSizeValEl = root.querySelector('[data-cell-size-val]');
|
||||
const fillValEl = root.querySelector('[data-fill-opacity-val]');
|
||||
const strokeValEl = root.querySelector('[data-stroke-opacity-val]');
|
||||
const widthValEl = root.querySelector('[data-stroke-width-val]');
|
||||
|
||||
function bindRangeWithLabel(inputEl, labelEl, format = (v) => v) {
|
||||
const sync = () => { labelEl.textContent = format(inputEl.value); };
|
||||
inputEl.addEventListener('input', sync);
|
||||
inputEl.addEventListener('change', sync);
|
||||
sync();
|
||||
}
|
||||
|
||||
if (cellSizeEl && cellSizeValEl) bindRangeWithLabel(cellSizeEl, cellSizeValEl, v => `${v}px`);
|
||||
if (fillOpacityEl && fillValEl) bindRangeWithLabel(fillOpacityEl, fillValEl, v => `${parseInt(Number(v) * 100)}%`);
|
||||
if (strokeOpacityEl && strokeValEl) bindRangeWithLabel(strokeOpacityEl, strokeValEl, v => `${parseInt(Number(v) * 100)}%`);
|
||||
if (strokeWidthEl && widthValEl) bindRangeWithLabel(strokeWidthEl, widthValEl, v => `${Math.round(Number(v) * Number(cellSizeEl.value || 0))}px`);
|
||||
|
||||
core.saveDoc({ ...core.doc, shapes: core.shapes });
|
||||
|
||||
const savedTool = localStorage.getItem(`${storageKey}:tool`);
|
||||
if (savedTool) core.setActiveTool(savedTool);
|
||||
|
||||
const savedType = localStorage.getItem(`${storageKey}:gridType`);
|
||||
if (savedType) core.setActiveType(savedType);
|
||||
|
||||
cellSizeEl.value = core.cellSize;
|
||||
let dotSize = Math.floor(Math.max(core.cellSize * 1.25, 32));
|
||||
|
||||
let selectedColor;
|
||||
let currentFillOpacity = core.clamp01(fillOpacityEl?.value ?? 1, 1);
|
||||
let currentStrokeOpacity = core.clamp01(strokeOpacityEl?.value ?? 1, 1);
|
||||
let currentStrokeWidth = Number(strokeWidthEl?.value ?? 0.12) || 0.12;
|
||||
let selectedIndex = -1;
|
||||
|
||||
selectedColor = colorEl?.value || '#000000';
|
||||
if (dotSVGEl) {
|
||||
const circle = dotSVGEl.querySelector('circle');
|
||||
circle?.setAttribute('fill', selectedColor);
|
||||
}
|
||||
|
||||
let currentShape = null;
|
||||
let suppressNextClick = false;
|
||||
|
||||
const history = [structuredClone(core.shapes)];
|
||||
let historyIndex = 0
|
||||
|
||||
let sizingRAF = 0;
|
||||
let lastApplied = { w: 0, h: 0 };
|
||||
|
||||
const ro = new ResizeObserver(scheduleSnappedCellSize);
|
||||
ro.observe(gridWrapEl);
|
||||
|
||||
setGrid();
|
||||
scheduleSnappedCellSize();
|
||||
|
||||
let activePointerId = null;
|
||||
|
||||
if (toolBarEl && window.bootstrap?.Dropdown) {
|
||||
toolBarEl.querySelectorAll('[data-bs-toggle="dropdown"]').forEach((toggle) => {
|
||||
window.bootstrap.Dropdown.getOrCreateInstance(toggle, {
|
||||
popperConfig(defaultConfig) {
|
||||
return {
|
||||
...defaultConfig,
|
||||
strategy: 'fixed',
|
||||
modifiers: [
|
||||
...(defaultConfig.modifiers || []),
|
||||
{ name: 'preventOverflow', options: { boundary: 'viewport' } },
|
||||
{ name: 'flip', options: { boundary: 'viewport', padding: 8 } },
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
requestAnimationFrame(() => requestAnimationFrame(scheduleSnappedCellSize));
|
||||
|
||||
const api = {
|
||||
handleKeyDown(e) {
|
||||
const key = e.key.toLowerCase();
|
||||
const t = e.target;
|
||||
|
||||
const isTextField = t && root.contains(t) && (t.matches('input, textarea, select') || t.isContentEditable);
|
||||
|
||||
if (isTextField) {
|
||||
const isUndo = (e.ctrlKey || e.metaKey) && key === 'z';
|
||||
const isRedo = (e.ctrlKey || e.metaKey) && (key === 'y' || (key === 'z' && e.shiftKey));
|
||||
|
||||
if (!isUndo && !isRedo) return;
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && key === 'z') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) redo();
|
||||
else undo();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && key === 'y') {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'escape' && currentShape) {
|
||||
e.preventDefault();
|
||||
currentShape = null;
|
||||
core.redrawAll();
|
||||
}
|
||||
},
|
||||
|
||||
handleGlobalPointerUp(e) {
|
||||
finishPointer(e);
|
||||
},
|
||||
|
||||
cancelStroke() { cancelStroke(); }
|
||||
};
|
||||
|
||||
function destroy() {
|
||||
if (window.activeGridWidget === api) window.activeGridWidget = null;
|
||||
|
||||
currentShape = null;
|
||||
activePointerId = null;
|
||||
|
||||
try {
|
||||
if (window.__gridPointerId != null && gridEl.hasPointerCapture?.(window.__gridPointerId)) {
|
||||
gridEl.releasePointerCapture(window.__gridPointerId);
|
||||
}
|
||||
} catch { }
|
||||
|
||||
if (window.__gridPointerOwner === api) {
|
||||
window.__gridPointerOwner = null;
|
||||
window.__gridPointerId = null;
|
||||
}
|
||||
|
||||
ro.disconnect();
|
||||
}
|
||||
|
||||
|
||||
api.destroy = destroy;
|
||||
|
||||
root.addEventListener('focusin', () => { window.activeGridWidget = api; });
|
||||
|
||||
root.addEventListener('pointerdown', () => {
|
||||
window.activeGridWidget = api;
|
||||
}, { capture: true });
|
||||
|
||||
function setGrid() {
|
||||
const type = core.getActiveType();
|
||||
|
||||
gridEl.style.backgroundImage = "";
|
||||
gridEl.style.backgroundSize = "";
|
||||
gridEl.style.backgroundPosition = "";
|
||||
gridEl.style.boxShadow = "none";
|
||||
dotEl.classList.add('d-none');
|
||||
|
||||
// Minor dots
|
||||
const dotPx = Math.max(1, Math.round(core.cellSize * 0.08));
|
||||
const minorColor = '#ddd';
|
||||
|
||||
// Major dots (every 5 cells)
|
||||
const majorStep = core.cellSize * 5;
|
||||
const majorDotPx = Math.max(dotPx + 1, Math.round(core.cellSize * 0.12));
|
||||
const majorColor = '#c4c4c4';
|
||||
|
||||
const minorLayer = `radial-gradient(circle, ${minorColor} ${dotPx}px, transparent ${dotPx}px)`;
|
||||
const majorLayer = `radial-gradient(circle, ${majorColor} ${majorDotPx}px, transparent ${majorDotPx}px)`;
|
||||
|
||||
if (type === 'fullGrid') {
|
||||
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
|
||||
gridEl.style.backgroundSize = `${majorStep}px ${majorStep}px, ${core.cellSize}px ${core.cellSize}px`;
|
||||
gridEl.style.backgroundPosition =
|
||||
`${majorStep / 2}px ${majorStep / 2}px, ${core.cellSize / 2}px ${core.cellSize / 2}px`;
|
||||
gridEl.style.boxShadow = "inset 0 0 0 1px #ccc";
|
||||
|
||||
} else if (type === 'verticalGrid') {
|
||||
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
|
||||
gridEl.style.backgroundSize = `${majorStep}px 100%, ${core.cellSize}px 100%`;
|
||||
gridEl.style.backgroundPosition =
|
||||
`${majorStep / 2}px 0px, ${core.cellSize / 2}px 0px`;
|
||||
gridEl.style.boxShadow = "inset 0 1px 0 0 #ccc, inset 0 -1px 0 0 #ccc";
|
||||
|
||||
} else if (type === 'horizontalGrid') {
|
||||
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
|
||||
gridEl.style.backgroundSize = `100% ${majorStep}px, 100% ${core.cellSize}px`;
|
||||
gridEl.style.backgroundPosition =
|
||||
`0px ${majorStep / 2}px, 0px ${core.cellSize / 2}px`;
|
||||
gridEl.style.boxShadow = "inset 1px 0 0 0 #ccc, inset -1px 0 0 0 #ccc";
|
||||
|
||||
} else { // noGrid
|
||||
gridEl.style.boxShadow = "inset 0 0 0 1px #ccc";
|
||||
}
|
||||
}
|
||||
|
||||
function isInsideRect(clientX, clientY, rect) {
|
||||
return clientX >= rect.left && clientX <= rect.right &&
|
||||
clientY >= rect.top && clientY <= rect.bottom;
|
||||
}
|
||||
|
||||
function finishPointer(e) {
|
||||
if (window.__gridPointerOwner !== api) return;
|
||||
if (!currentShape) return;
|
||||
if (e.pointerId !== activePointerId) return;
|
||||
|
||||
onPointerUp(e);
|
||||
activePointerId = null;
|
||||
|
||||
window.__gridPointerOwner = null;
|
||||
window.__gridPointerId = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function penAddPoint(shape, clientX, clientY, minStep = 0.02, maxDtMs = 16) {
|
||||
const p = core.pxToDocPoint(clientX, clientY);
|
||||
|
||||
if (!Array.isArray(shape.points)) shape.points = [];
|
||||
if (shape._lastAddTime == null) shape._lastAddTime = performance.now();
|
||||
|
||||
const pts = shape.points;
|
||||
const last = pts[pts.length - 1];
|
||||
|
||||
const now = performance.now();
|
||||
const dt = now - shape._lastAddTime;
|
||||
|
||||
if (!last) {
|
||||
pts.push(p);
|
||||
shape._lastAddTime = now;
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = p.x - last.x;
|
||||
const dy = p.y - last.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
|
||||
if (d2 >= minStep * minStep || dt >= maxDtMs) {
|
||||
pts.push(p);
|
||||
shape._lastAddTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (historyIndex <= 0) return;
|
||||
historyIndex--;
|
||||
const nextShapes = core.rebuildPathCaches(structuredClone(history[historyIndex]));
|
||||
core.setDoc({ ...core.doc, cellSize: core.cellSize, shapes: nextShapes });
|
||||
core.redrawAll();
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (historyIndex >= history.length - 1) return;
|
||||
historyIndex++;
|
||||
const nextShapes = core.rebuildPathCaches(structuredClone(history[historyIndex]));
|
||||
core.setDoc({ ...core.doc, cellSize: core.cellSize, shapes: nextShapes });
|
||||
core.redrawAll();
|
||||
}
|
||||
|
||||
function commit(nextShapes) {
|
||||
history.splice(historyIndex + 1);
|
||||
|
||||
history.push(structuredClone(nextShapes));
|
||||
historyIndex++;
|
||||
|
||||
if (history.length > MAX_HISTORY) {
|
||||
const overflow = history.length - MAX_HISTORY;
|
||||
history.splice(0, overflow);
|
||||
historyIndex -= overflow;
|
||||
if (historyIndex < 0) historyIndex = 0;
|
||||
}
|
||||
|
||||
const rebuilt = core.rebuildPathCaches(nextShapes);
|
||||
|
||||
core.setDoc({ ...core.doc, shapes: rebuilt, cellSize: core.cellSize });
|
||||
core.redrawAll();
|
||||
}
|
||||
|
||||
function snapDown(n, step) {
|
||||
return Math.floor(n / step) * step;
|
||||
}
|
||||
|
||||
function applySnappedCellSize() {
|
||||
sizingRAF = 0;
|
||||
|
||||
const grid = core.cellSize;
|
||||
if (!Number.isFinite(grid) || grid < 1) return;
|
||||
|
||||
const w = gridWrapEl.clientWidth;
|
||||
const h = gridWrapEl.clientHeight;
|
||||
|
||||
const snappedW = snapDown(w, grid);
|
||||
const snappedH = snapDown(h, grid);
|
||||
|
||||
// Only touch width-related CSS if width changed
|
||||
const wChanged = snappedW !== lastApplied.w;
|
||||
const hChanged = snappedH !== lastApplied.h;
|
||||
if (!wChanged && !hChanged) return;
|
||||
|
||||
lastApplied = { w: snappedW, h: snappedH };
|
||||
|
||||
// critical: don't let observer see our own updates as layout input
|
||||
ro.disconnect();
|
||||
|
||||
gridEl.style.width = `${snappedW}px`;
|
||||
gridEl.style.height = `${snappedH}px`;
|
||||
|
||||
if (wChanged) {
|
||||
root.style.setProperty('--grid-maxw', `${snappedW}px`);
|
||||
}
|
||||
|
||||
ro.observe(gridWrapEl);
|
||||
|
||||
core.resizeAndSetupCanvas();
|
||||
}
|
||||
|
||||
|
||||
function scheduleSnappedCellSize() {
|
||||
if (sizingRAF) return;
|
||||
sizingRAF = requestAnimationFrame(applySnappedCellSize);
|
||||
}
|
||||
|
||||
function applyCellSize(newSize) {
|
||||
const n = Number(newSize);
|
||||
if (!Number.isFinite(n) || n < 1) return;
|
||||
|
||||
core.setDoc({ ...core.doc, cellSize: n, shapes: core.shapes });
|
||||
dotSize = Math.floor(Math.max(core.cellSize * 1.25, 32));
|
||||
dotSVGEl?.setAttribute('width', dotSize);
|
||||
dotSVGEl?.setAttribute('height', dotSize);
|
||||
setGrid();
|
||||
scheduleSnappedCellSize();
|
||||
}
|
||||
|
||||
function snapToGrid(x, y) {
|
||||
const rect = gridEl.getBoundingClientRect();
|
||||
const clampedX = Math.min(Math.max(x, rect.left), rect.right);
|
||||
const clampedY = Math.min(Math.max(y, rect.top), rect.bottom);
|
||||
|
||||
const localX = clampedX - rect.left;
|
||||
const localY = clampedY - rect.top;
|
||||
|
||||
const grid = core.cellSize;
|
||||
const maxIx = Math.floor(rect.width / grid);
|
||||
const maxIy = Math.floor(rect.height / grid);
|
||||
|
||||
const ix = Math.min(Math.max(Math.round(localX / grid), 0), maxIx);
|
||||
const iy = Math.min(Math.max(Math.round(localY / grid), 0), maxIy);
|
||||
|
||||
const type = core.getActiveType();
|
||||
|
||||
let snapX = localX;
|
||||
let snapY = localY;
|
||||
|
||||
if (type === 'fullGrid' || type === 'verticalGrid') {
|
||||
snapX = Math.min(ix * grid, rect.width);
|
||||
}
|
||||
|
||||
if (type === 'fullGrid' || type === 'horizontalGrid') {
|
||||
snapY = Math.min(iy * grid, rect.height);
|
||||
}
|
||||
|
||||
return {
|
||||
ix,
|
||||
iy,
|
||||
x: snapX,
|
||||
y: snapY,
|
||||
localX,
|
||||
localY
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRect(shape) {
|
||||
const x1 = core.pxToGrid(shape.x1);
|
||||
const y1 = core.pxToGrid(shape.y1);
|
||||
const x2 = core.pxToGrid(shape.x2);
|
||||
const y2 = core.pxToGrid(shape.y2);
|
||||
|
||||
return {
|
||||
type: 'rect',
|
||||
x: Math.min(x1, x2),
|
||||
y: Math.min(y1, y2),
|
||||
w: Math.abs(x2 - x1),
|
||||
h: Math.abs(y2 - y1),
|
||||
color: shape.color,
|
||||
fill: shape.fill,
|
||||
fillOpacity: core.clamp01(shape.fillOpacity, 1),
|
||||
strokeOpacity: core.clamp01(shape.strokeOpacity, 1),
|
||||
strokeWidth: core.isFiniteNum(shape.strokeWidth) ? Math.max(0, +shape.strokeWidth) : 0.12
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEllipse(shape) {
|
||||
const r = normalizeRect(shape);
|
||||
return { ...r, type: 'ellipse' };
|
||||
}
|
||||
|
||||
function normalizeLine(shape) {
|
||||
return {
|
||||
type: 'line',
|
||||
x1: core.pxToGrid(shape.x1),
|
||||
y1: core.pxToGrid(shape.y1),
|
||||
x2: core.pxToGrid(shape.x2),
|
||||
y2: core.pxToGrid(shape.y2),
|
||||
color: shape.color,
|
||||
strokeWidth: core.isFiniteNum(shape.strokeWidth) ? Math.max(0, +shape.strokeWidth) : 0.12,
|
||||
strokeOpacity: core.clamp01(shape.strokeOpacity)
|
||||
};
|
||||
}
|
||||
|
||||
function cancelStroke(e) {
|
||||
const owns = (window.__gridPointerOwner === api) &&
|
||||
(e ? window.__gridPointerId === e.pointerId : true);
|
||||
|
||||
if (!owns) return;
|
||||
|
||||
currentShape = null;
|
||||
activePointerId = null;
|
||||
|
||||
window.__gridPointerOwner = null;
|
||||
window.__gridPointerId = null;
|
||||
|
||||
core.redrawAll();
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
if (!currentShape) return;
|
||||
|
||||
// Only finalize if this pointer is the captured one (or we failed to capture, sigh)
|
||||
if (gridEl.hasPointerCapture?.(e.pointerId)) {
|
||||
gridEl.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
const { x: snapX, y: snapY } = snapToGrid(e.clientX, e.clientY);
|
||||
|
||||
currentShape.x2 = snapX;
|
||||
currentShape.y2 = snapY;
|
||||
|
||||
let finalShape = null;
|
||||
|
||||
if (currentShape.tool === 'pen') {
|
||||
const pts = currentShape.points;
|
||||
|
||||
if (pts.length >= 2) {
|
||||
const coarse = [pts[0]];
|
||||
const minStepPx = 0.75;
|
||||
const minStep = minStepPx / core.cellSize;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
if (dist2(pts[i], coarse[coarse.length - 1]) >= minStep * minStep) {
|
||||
coarse.push(pts[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (coarse.length >= 2) {
|
||||
const epsilon = Math.max(0.01, (currentShape.strokeWidth ?? 0.12) * 0.75);
|
||||
|
||||
const simplified = simplifyRDP(coarse, epsilon);
|
||||
|
||||
if (simplified.length >= 2) {
|
||||
finalShape = {
|
||||
type: 'path',
|
||||
points: simplified,
|
||||
color: currentShape.color || '#000000',
|
||||
strokeWidth: core.isFiniteNum(currentShape.strokeWidth) ? Math.max(0, +currentShape.strokeWidth) : SHAPE_DEFAULTS.strokeWidth,
|
||||
strokeOpacity: core.clamp01(currentShape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (currentShape.tool === 'line') {
|
||||
const line = normalizeLine(currentShape);
|
||||
if (line.x1 !== line.x2 || line.y1 !== line.y2) finalShape = line;
|
||||
|
||||
} else if (currentShape.tool === 'filled' || currentShape.tool === 'outline') {
|
||||
const rect = normalizeRect(currentShape);
|
||||
if (rect.w > 0 && rect.h > 0) finalShape = rect;
|
||||
|
||||
} else if (currentShape.tool === 'filledEllipse' || currentShape.tool === 'outlineEllipse') {
|
||||
const ellipse = normalizeEllipse(currentShape);
|
||||
if (ellipse.w > 0 && ellipse.h > 0) finalShape = ellipse;
|
||||
}
|
||||
|
||||
if (finalShape) {
|
||||
if (finalShape && ('_lastAddTime' in finalShape)) delete finalShape._lastAddTime;
|
||||
commit([...core.shapes, finalShape]);
|
||||
|
||||
|
||||
suppressNextClick = true;
|
||||
setTimeout(() => { suppressNextClick = false; }, 0);
|
||||
}
|
||||
|
||||
currentShape = null;
|
||||
core.renderAllWithPreview(null);
|
||||
}
|
||||
|
||||
gridEl.addEventListener('pointerup', finishPointer);
|
||||
|
||||
function setSelection(hit) {
|
||||
if (!hit) {
|
||||
selectedIndex = -1;
|
||||
core.selectedShape = null;
|
||||
core.redrawAll();
|
||||
return;
|
||||
}
|
||||
selectedIndex = hit.index;
|
||||
core.selectedShape = hit.shape;
|
||||
core.redrawAll();
|
||||
}
|
||||
|
||||
gridEl.addEventListener('click', (e) => {
|
||||
if (suppressNextClick) {
|
||||
suppressNextClick = false;
|
||||
return;
|
||||
}
|
||||
if (currentShape) return;
|
||||
if (e.target.closest('[data-toolbar]')) return;
|
||||
|
||||
const docPt = core.pxToDocPoint(e.clientX, e.clientY);
|
||||
const hit = pickShapeAt(docPt, core.shapes, core.cellSize, { pxTol: 7 });
|
||||
setSelection(hit);
|
||||
|
||||
if (hit) root.dispatchEvent(new CustomEvent('shape:click', { detail: hit }));
|
||||
});
|
||||
|
||||
gridEl.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
if (currentShape) return;
|
||||
|
||||
const docPt = core.pxToDocPoint(e.clientX, e.clientY);
|
||||
const hit = pickShapeAt(docPt, core.shapes, core.cellSize, { pxTol: 7 });
|
||||
setSelection(hit);
|
||||
|
||||
root.dispatchEvent(new CustomEvent('shape:contextmenu', {
|
||||
detail: { hit, clientX: e.clientX, clientY: e.clientY }
|
||||
}));
|
||||
});
|
||||
|
||||
gridEl.addEventListener('dblclick', (e) => {
|
||||
if (currentShape) return;
|
||||
if (e.target.closest('[data-toolbar]')) return;
|
||||
|
||||
const docPt = core.pxToDocPoint(e.clientX, e.clientY);
|
||||
const hit = pickShapeAt(docPt, core.shapes, core.cellSize, { pxTol: 7 });
|
||||
setSelection(hit);
|
||||
|
||||
if (hit) root.dispatchEvent(new CustomEvent('shape:dblclick', { detail: hit }));
|
||||
});
|
||||
|
||||
root.querySelectorAll('input[data-tool]').forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
if (input.checked) {
|
||||
localStorage.setItem(`${storageKey}:tool`, input.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll('input[data-gridtype]').forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
if (input.checked) {
|
||||
localStorage.setItem(`${storageKey}:gridType`, input.value);
|
||||
}
|
||||
setGrid();
|
||||
core.redrawAll();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
cellSizeEl.addEventListener('input', () => applyCellSize(cellSizeEl.value));
|
||||
cellSizeEl.addEventListener('change', () => applyCellSize(cellSizeEl.value));
|
||||
|
||||
importButtonEl.addEventListener('click', () => importEl.click());
|
||||
|
||||
importEl.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const data = decode(JSON.parse(reader.result));
|
||||
|
||||
if (Number.isFinite(Number(data.cellSize)) && Number(data.cellSize) >= 1) {
|
||||
cellSizeEl.value = data.cellSize;
|
||||
applyCellSize(data.cellSize);
|
||||
}
|
||||
|
||||
const loadedShapes = Array.isArray(data?.shapes) ? data.shapes : [];
|
||||
const rebuilt = core.rebuildPathCaches(core.sanitizeShapes(loadedShapes));
|
||||
core.setDoc({ version: Number(data?.version) || 1, cellSize: Number(data?.cellSize) || core.cellSize, shapes: rebuilt });
|
||||
|
||||
history.length = 0;
|
||||
history.push(structuredClone(core.shapes));
|
||||
historyIndex = 0;
|
||||
|
||||
core.redrawAll();
|
||||
} catch {
|
||||
toastMessage('Failed to load data from JSON file.', 'danger');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
exportEl.addEventListener('click', () => {
|
||||
const payload = encode({ cellSize: core.cellSize, shapes: core.shapes, stripCaches: core.stripCaches, SHAPE_DEFAULTS });
|
||||
const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'grid-shapes.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
clearEl.addEventListener('click', () => {
|
||||
cellSizeEl.value = 25;
|
||||
core.setDoc({ ...core.doc, cellSize: 25, shapes: [] });
|
||||
|
||||
history.length = 0;
|
||||
history.push([]);
|
||||
historyIndex = 0;
|
||||
|
||||
core.redrawAll();
|
||||
});
|
||||
|
||||
colorEl.addEventListener('input', () => {
|
||||
selectedColor = colorEl.value || '#000000';
|
||||
const circle = dotSVGEl.querySelector('circle');
|
||||
if (circle) {
|
||||
circle.setAttribute('fill', selectedColor);
|
||||
}
|
||||
});
|
||||
|
||||
fillOpacityEl?.addEventListener('input', () => {
|
||||
currentFillOpacity = core.clamp01(fillOpacityEl.value, 1);
|
||||
});
|
||||
|
||||
fillOpacityEl?.addEventListener('change', () => {
|
||||
currentFillOpacity = core.clamp01(fillOpacityEl.value, 1);
|
||||
});
|
||||
|
||||
strokeOpacityEl?.addEventListener('input', () => {
|
||||
currentStrokeOpacity = core.clamp01(strokeOpacityEl.value, 1);
|
||||
});
|
||||
|
||||
strokeOpacityEl?.addEventListener('change', () => {
|
||||
currentStrokeOpacity = core.clamp01(strokeOpacityEl.value, 1);
|
||||
});
|
||||
|
||||
strokeWidthEl?.addEventListener('input', () => {
|
||||
currentStrokeWidth = Math.max(0, Number(strokeWidthEl.value) || 0.12);
|
||||
});
|
||||
|
||||
strokeWidthEl?.addEventListener('change', () => {
|
||||
currentStrokeWidth = Math.max(0, Number(strokeWidthEl.value) || 0.12);
|
||||
});
|
||||
|
||||
gridEl.addEventListener('pointercancel', (e) => cancelStroke(e));
|
||||
gridEl.addEventListener('lostpointercapture', (e) => cancelStroke(e));
|
||||
|
||||
gridEl.addEventListener('pointermove', (e) => {
|
||||
if (!core.ctx) return;
|
||||
|
||||
const rect = gridEl.getBoundingClientRect();
|
||||
const inside = isInsideRect(e.clientX, e.clientY, rect);
|
||||
const drawing = !!currentShape;
|
||||
|
||||
const { ix, iy, x: snapX, y: snapY, localX, localY } = snapToGrid(e.clientX, e.clientY);
|
||||
const tool = core.getActiveTool();
|
||||
|
||||
if (!drawing && !inside) {
|
||||
coordsEl.classList.add('d-none');
|
||||
dotEl.classList.add('d-none');
|
||||
} else {
|
||||
coordsEl.classList.remove('d-none');
|
||||
|
||||
if (core.getActiveType() !== 'noGrid' && tool !== 'pen') {
|
||||
dotEl.classList.remove('d-none');
|
||||
|
||||
const wrapRect = gridWrapEl.getBoundingClientRect();
|
||||
const offsetX = rect.left - wrapRect.left;
|
||||
const offsetY = rect.top - wrapRect.top;
|
||||
|
||||
dotEl.style.left = `${offsetX + snapX}px`;
|
||||
dotEl.style.top = `${offsetY + snapY}px`;
|
||||
} else {
|
||||
dotEl.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
if (core.getActiveType() == 'noGrid') {
|
||||
coordsEl.innerText = `(px x=${Math.round(localX)} y=${Math.round(localY)})`;
|
||||
} else {
|
||||
coordsEl.innerText = `(x=${ix} (${snapX}px) y=${iy} (${snapY}px))`;
|
||||
}
|
||||
|
||||
if (!currentShape) return;
|
||||
|
||||
// PEN: mutate points and preview the same shape object
|
||||
if (currentShape.tool === 'pen') {
|
||||
const minStepPx = 0.75;
|
||||
const minStep = minStepPx / core.cellSize;
|
||||
penAddPoint(currentShape, e.clientX, e.clientY, minStep, 16);
|
||||
|
||||
// realtime instrumentation
|
||||
coordsEl.innerText += ` | pts=${currentShape.points?.length ?? 0}`;
|
||||
|
||||
core.renderAllWithPreview(currentShape, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Other tools: build a normalized preview shape
|
||||
let preview = null;
|
||||
|
||||
if (currentShape.tool === 'line') {
|
||||
preview = normalizeLine({
|
||||
x1: currentShape.x1,
|
||||
y1: currentShape.y1,
|
||||
x2: snapX,
|
||||
y2: snapY,
|
||||
color: currentShape.color,
|
||||
strokeWidth: currentShape.strokeWidth,
|
||||
strokeOpacity: currentShape.strokeOpacity
|
||||
});
|
||||
} else if (currentShape.tool === 'filled' || currentShape.tool === 'outline') {
|
||||
preview = normalizeRect({ ...currentShape, x2: snapX, y2: snapY });
|
||||
} else if (currentShape.tool === 'filledEllipse' || currentShape.tool === 'outlineEllipse') {
|
||||
preview = normalizeEllipse({ ...currentShape, x2: snapX, y2: snapY });
|
||||
}
|
||||
|
||||
core.renderAllWithPreview(preview, currentShape.tool !== 'pen');
|
||||
});
|
||||
|
||||
gridEl.addEventListener('pointerleave', (e) => {
|
||||
coordsEl.classList.add('d-none');
|
||||
dotEl.classList.add('d-none');
|
||||
});
|
||||
|
||||
gridEl.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
if (e.target.closest('[data-toolbar]')) return;
|
||||
|
||||
e.preventDefault();
|
||||
activePointerId = e.pointerId;
|
||||
|
||||
window.__gridPointerOwner = api;
|
||||
window.__gridPointerId = e.pointerId;
|
||||
|
||||
try {
|
||||
gridEl.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// ignore: some browsers / scenarios won't allow capture
|
||||
}
|
||||
|
||||
const { x: snapX, y: snapY } = snapToGrid(e.clientX, e.clientY);
|
||||
const tool = core.getActiveTool();
|
||||
|
||||
if (tool === 'line') {
|
||||
currentShape = {
|
||||
tool,
|
||||
type: 'line',
|
||||
x1: snapX,
|
||||
y1: snapY,
|
||||
x2: snapX,
|
||||
y2: snapY,
|
||||
color: selectedColor,
|
||||
strokeWidth: currentStrokeWidth,
|
||||
strokeOpacity: currentStrokeOpacity
|
||||
};
|
||||
} else if (tool === 'outline' || tool === 'filled') {
|
||||
currentShape = {
|
||||
tool,
|
||||
x1: snapX,
|
||||
y1: snapY,
|
||||
x2: snapX,
|
||||
y2: snapY,
|
||||
color: selectedColor,
|
||||
fill: (tool === 'filled'),
|
||||
fillOpacity: currentFillOpacity,
|
||||
strokeOpacity: currentStrokeOpacity,
|
||||
strokeWidth: currentStrokeWidth
|
||||
};
|
||||
} else if (tool === 'outlineEllipse' || tool === 'filledEllipse') {
|
||||
currentShape = {
|
||||
tool,
|
||||
x1: snapX,
|
||||
y1: snapY,
|
||||
x2: snapX,
|
||||
y2: snapY,
|
||||
color: selectedColor,
|
||||
fill: (tool === 'filledEllipse'),
|
||||
fillOpacity: currentFillOpacity,
|
||||
strokeOpacity: currentStrokeOpacity,
|
||||
strokeWidth: currentStrokeWidth
|
||||
};
|
||||
} else if (tool === 'pen') {
|
||||
const p = core.pxToDocPoint(e.clientX, e.clientY);
|
||||
currentShape = {
|
||||
tool,
|
||||
type: 'path',
|
||||
points: [p],
|
||||
color: selectedColor,
|
||||
strokeWidth: currentStrokeWidth,
|
||||
strokeOpacity: currentStrokeOpacity,
|
||||
_lastAddTime: performance.now()
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
56
inventory/static/js/components/grid/widget-init.js
Normal file
56
inventory/static/js/components/grid/widget-init.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { createWidgetCore, DEFAULT_DOC } from "./widget-core.js";
|
||||
import { initWidgetEditor } from "./widget-editor.js";
|
||||
|
||||
export function initGridWidget(root, opts = {}) {
|
||||
const mode = opts.mode || 'editor';
|
||||
const storageKey = opts.storageKey ?? 'gridDoc';
|
||||
|
||||
const canvasEl = root.querySelector('[data-canvas]');
|
||||
const gridEl = root.querySelector('[data-grid]');
|
||||
const gridWrapEl = root.querySelector('[data-grid-wrap]');
|
||||
|
||||
if (!canvasEl || !gridEl || !gridWrapEl) {
|
||||
throw new Error("Grid widget: missing required viewer elements.");
|
||||
}
|
||||
|
||||
const toastMessage = opts.toastMessage || (() => { });
|
||||
|
||||
const core = createWidgetCore({
|
||||
root,
|
||||
mode,
|
||||
storageKey,
|
||||
gridEl,
|
||||
canvasEl,
|
||||
viewerOffset: opts.viewerOffset || { x: 0, y: 0 },
|
||||
doc: opts.doc,
|
||||
cellSize: opts.cellSize,
|
||||
shapes: opts.shapes
|
||||
});
|
||||
|
||||
const env = { root, gridEl, gridWrapEl, toastMessage, storageKey };
|
||||
|
||||
let editorApi = null;
|
||||
if (mode === 'editor') {
|
||||
editorApi = initWidgetEditor(core, env);
|
||||
}
|
||||
|
||||
const api = {
|
||||
core,
|
||||
mode,
|
||||
|
||||
redraw() { core.redrawAll(); },
|
||||
destroy() { editorApi?.destroy?.(); },
|
||||
|
||||
get doc() { return core.doc; },
|
||||
get shapes() { return core.shapes; },
|
||||
get cellSize() { return core.cellSize; },
|
||||
};
|
||||
|
||||
if(editorApi) {
|
||||
api.handleKeyDown = editorApi.handleKeyDown;
|
||||
api.handleGlobalPointerUp = editorApi.handleGlobalPointerUp;
|
||||
api.cancelStroke = editorApi.cancelStroke;
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
66
inventory/static/js/components/grid/widget-viewer.js
Normal file
66
inventory/static/js/components/grid/widget-viewer.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { decode } from "./encode-decode.js";
|
||||
|
||||
export function initWidgetViewer(core, env) {
|
||||
const { mode, gridEl, gridWrapEl } = env;
|
||||
|
||||
if (mode === 'editor') return null;
|
||||
|
||||
let resizeRAF = 0;
|
||||
|
||||
function applyViewerBoundsSizing() {
|
||||
const b = core.getShapesBounds(core.shapes);
|
||||
const padCells = 0.5;
|
||||
|
||||
const wCells = b ? (b.maxX - b.minX + padCells * 2) : 10;
|
||||
const hCells = b ? (b.maxY - b.minY + padCells * 2) : 10;
|
||||
|
||||
const wPx = Math.max(1, Math.ceil(wCells * core.cellSize));
|
||||
const hPx = Math.max(1, Math.ceil(hCells * core.cellSize));
|
||||
|
||||
gridEl.style.width = `${wPx}px`;
|
||||
gridEl.style.height = `${hPx}px`;
|
||||
gridWrapEl.style.width = `${wPx}px`;
|
||||
gridWrapEl.style.height = `${hPx}px`;
|
||||
|
||||
if (b) {
|
||||
core.viewerOffset = {
|
||||
x: (-b.minX + padCells) * core.cellSize,
|
||||
y: (-b.minY + padCells) * core.cellSize
|
||||
};
|
||||
} else {
|
||||
core.viewerOffset = { x: 0, y: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleResize = () => {
|
||||
if (resizeRAF) return;
|
||||
resizeRAF = requestAnimationFrame(() => {
|
||||
resizeRAF = 0;
|
||||
applyViewerBoundsSizing();
|
||||
core.resizeAndSetupCanvas();
|
||||
});
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(scheduleResize);
|
||||
ro.observe(gridWrapEl);
|
||||
|
||||
window.addEventListener('resize', scheduleResize, { passive: true });
|
||||
|
||||
requestAnimationFrame(scheduleResize);
|
||||
|
||||
function setDoc(nextDoc) {
|
||||
core.setDoc(nextDoc);
|
||||
applyViewerBoundsSizing();
|
||||
core.resizeAndSetupCanvas();
|
||||
}
|
||||
|
||||
return {
|
||||
setDoc,
|
||||
redraw: () => core.redrawAll(),
|
||||
destroy() {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', scheduleResize);
|
||||
},
|
||||
decode
|
||||
};
|
||||
}
|
||||
|
|
@ -28,15 +28,15 @@
|
|||
<div class="col">
|
||||
{{ draw.drawWidget('test6') }}
|
||||
</div -->
|
||||
<!-- div class="col" style="height: 80vh;">
|
||||
<div class="col" style="height: 80vh;">
|
||||
I am testing a thing.
|
||||
{{ draw.viewWidget('test2', jsonImage) }}
|
||||
{{ draw.viewWidget('test3', jsonImage2) }}
|
||||
The thing has been tested.
|
||||
</div -->
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scriptincludes %}
|
||||
<script src="{{ url_for('static', filename='js/components/draw.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='js/components/grid/index.js') }}"></script>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue