inventory/inventory/static/js/components/draw.js
2026-01-14 11:37:00 -06:00

2006 lines
63 KiB
JavaScript

(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 });
})();
function initGridWidget(root, opts = {}) {
const mode = opts.mode || 'editor';
const storageKey = opts.storageKey ?? 'gridDoc';
const DEFAULT_DOC = { version: 1, cellSize: 25, shapes: [] };
const SHAPE_DEFAULTS = {
strokeWidth: 0.12,
strokeOpacity: 1,
fillOpacity: 1
};
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.");
}
let doc = loadDoc();
let shapes = rebuildPathCaches(
sanitizeShapes(Array.isArray(doc.shapes) ? doc.shapes : [])
);
let cellSize = Number(doc.cellSize) || 25;
let viewerOffset = { x: 0, y: 0 };
let selectedIndex = -1;
let selectedShape = null;
let ctx;
let dpr = 1;
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) {
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 };
});
}
function encode() {
const payload = {
v: 1,
cs: cellSize,
q: 100,
d: {
cl: "#000000",
f: false,
sw: 12,
so: 100,
fo: 100
},
s: shortenKeys(shortenShapes(encodeStates(encodeRuns(computeDeltas(collapseStateChanges(stateCode(stripCaches(shapes))))))))
};
return payload;
}
function decodeLine(arr, q) {
const [x1, y1, dx, dy] = arr;
const x2 = x1 + dx;
const y2 = y1 + dy;
return { x1: x1 / q, y1: y1 / q, x2: x2 / q, y2: y2 / q };
}
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;
}
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 applyStateChange = (op) => {
if ("cl" in op) state.color = op.cl;
if ("f" in op) state.fill = !!op.f;
if ("sw" in op) state.strokeWidth = Number(op.sw) / 100;
if ("so" in op) state.strokeOpacity = Number(op.so) / 100;
if ("fo" in op) state.fillOpacity = Number(op.fo) / 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
};
}
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)); }
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) {
return list.map(s => {
if (s.type !== 'path') return s;
if (!Array.isArray(s.points) || s.points.length < 2) 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 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 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') {
const toPx = (v) => v * cellSize;
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 (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 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 : [])
);
if (mode === 'editor') {
saveDoc({ version: Number(d.version) || 1, cellSize, shapes });
}
if (mode !== 'editor') {
const b = getShapesBounds(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 * cellSize));
const hPx = Math.max(1, Math.ceil(hCells * cellSize));
gridEl.style.width = `${wPx}px`;
gridEl.style.height = `${hPx}px`;
gridWrapEl.style.width = `${wPx}px`;
gridWrapEl.style.height = `${hPx}px`;
if (b) {
viewerOffset.x = (-b.minX + padCells) * cellSize;
viewerOffset.y = (-b.minY + padCells) * cellSize;
} else {
viewerOffset.x = 0;
viewerOffset.y = 0;
}
}
requestAnimationFrame(() => resizeAndSetupCanvas());
}
resizeAndSetupCanvas();
redrawAll();
if (mode !== 'editor') {
let resizeRAF = 0;
const scheduleResize = () => {
if (resizeRAF) return;
resizeRAF = requestAnimationFrame(() => {
resizeRAF = 0;
resizeAndSetupCanvas();
});
};
const ro = new ResizeObserver(scheduleResize);
ro.observe(gridWrapEl);
window.addEventListener('resize', scheduleResize, { passive: true });
requestAnimationFrame(scheduleResize);
return {
setDoc,
redraw: redrawAll,
destroy() {
if (window.activeGridWidget === api) window.activeGridWidget = null;
ro.disconnect();
window.removeEventListener('resize', scheduleResize);
},
decode
};
}
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]');
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`);
saveDoc({ ...doc, shapes });
const savedTool = localStorage.getItem(`${storageKey}:tool`);
if (savedTool) setActiveTool(savedTool);
const savedType = localStorage.getItem(`${storageKey}:gridType`);
if (savedType) setActiveType(savedType);
cellSizeEl.value = cellSize;
let dotSize = Math.floor(Math.max(cellSize * 1.25, 32));
let selectedColor;
let currentFillOpacity = clamp01(fillOpacityEl?.value ?? 1, 1);
let currentStrokeOpacity = clamp01(strokeOpacityEl?.value ?? 1, 1);
let currentStrokeWidth = Number(strokeWidthEl?.value ?? 0.12) || 0.12;
selectedColor = colorEl?.value || '#000000';
if (dotSVGEl) {
const circle = dotSVGEl.querySelector('circle');
circle?.setAttribute('fill', selectedColor);
}
let currentShape = null;
let suppressNextClick = false;
const history = [structuredClone(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;
toolBarEl.querySelectorAll('[data-bs-toggle="dropdown"]').forEach((toggle) => {
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;
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 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 };
}
function setGrid() {
const type = 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(cellSize * 0.08));
const minorColor = '#ddd';
// Major dots (every 5 cells)
const majorStep = cellSize * 5;
const majorDotPx = Math.max(dotPx + 1, Math.round(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, ${cellSize}px ${cellSize}px`;
gridEl.style.backgroundPosition =
`${majorStep / 2}px ${majorStep / 2}px, ${cellSize / 2}px ${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%, ${cellSize}px 100%`;
gridEl.style.backgroundPosition =
`${majorStep / 2}px 0px, ${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% ${cellSize}px`;
gridEl.style.backgroundPosition =
`0px ${majorStep / 2}px, 0px ${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 bindRangeWithLabel(inputEl, labelEl, format = (v) => v) {
const sync = () => { labelEl.textContent = format(inputEl.value); };
inputEl.addEventListener('input', sync);
inputEl.addEventListener('change', sync);
sync();
}
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 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();
}
function penAddPoint(shape, clientX, clientY, minStep = 0.02) {
const p = pxToDocPoint(clientX, clientY);
const pts = shape.points;
const last = pts[pts.length - 1];
if (!last || dist2(p, last) >= minStep * minStep) pts.push(p);
}
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) };
}
function dist2(a, b) {
const dx = a.x - b.x, dy = a.y - b.y;
return dx * dx + dy * dy;
}
function pickShapeAt(docPt, shapes, cellSize, opts = {}) {
const pxTol = opts.pxTol ?? 6;
const tol = pxTol / cellSize;
const tol2 = tol * tol;
for (let i = shapes.length - 1; i >= 0; i--) {
const s = shapes[i];
if (!s) continue;
if (hitShape(docPt, s, tol, tol2)) {
return { index: i, shape: s };
}
}
return null;
}
function hitShape(p, s, tol, tol2) {
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;
}
}
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);
}
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 simplifyRDP(points, epsilon) {
if (!Array.isArray(points) || points.length < 3) return points || [];
const eps2 = epsilon * epsilon;
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;
}
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;
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;
}
function undo() {
if (historyIndex <= 0) return;
historyIndex--;
shapes = rebuildPathCaches(structuredClone(history[historyIndex]));
saveDoc({ ...doc, shapes, cellSize });
redrawAll();
}
function redo() {
if (historyIndex >= history.length - 1) return;
historyIndex++;
shapes = rebuildPathCaches(structuredClone(history[historyIndex]));
saveDoc({ ...doc, shapes, cellSize });
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;
}
shapes = rebuildPathCaches(nextShapes);
saveDoc({ ...doc, shapes, cellSize });
redrawAll();
}
function snapDown(n, step) {
return Math.floor(n / step) * step;
}
function applySnappedCellSize() {
sizingRAF = 0;
const grid = 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);
resizeAndSetupCanvas();
}
function scheduleSnappedCellSize() {
if (sizingRAF) return;
sizingRAF = requestAnimationFrame(applySnappedCellSize);
}
function applyCellSize(newSize) {
const n = Number(newSize);
if (!Number.isFinite(n) || n < 1) return;
cellSize = n;
saveDoc({ ...doc, shapes, cellSize });
dotSize = Math.floor(Math.max(cellSize * 1.25, 32));
dotSVGEl.setAttribute('width', dotSize);
dotSVGEl.setAttribute('height', dotSize);
setGrid();
scheduleSnappedCellSize();
}
function pxToGrid(v) {
return v / cellSize;
}
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;
}
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 = 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 = 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 = pxToGrid(shape.x1);
const y1 = pxToGrid(shape.y1);
const x2 = pxToGrid(shape.x2);
const y2 = 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: clamp01(shape.fillOpacity, 1),
strokeOpacity: clamp01(shape.strokeOpacity, 1),
strokeWidth: 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: pxToGrid(shape.x1),
y1: pxToGrid(shape.y1),
x2: pxToGrid(shape.x2),
y2: pxToGrid(shape.y2),
color: shape.color,
strokeWidth: isFiniteNum(shape.strokeWidth) ? Math.max(0, +shape.strokeWidth) : 0.12,
strokeOpacity: 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;
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 / 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: isFiniteNum(currentShape.strokeWidth) ? Math.max(0, +currentShape.strokeWidth) : SHAPE_DEFAULTS.strokeWidth,
strokeOpacity: 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) {
commit([...shapes, finalShape]);
suppressNextClick = true;
setTimeout(() => { suppressNextClick = false; }, 0);
}
currentShape = null;
renderAllWithPreview(null);
}
gridEl.addEventListener('pointerup', finishPointer);
function setSelection(hit) {
if (!hit) {
selectedIndex = -1;
selectedShape = null;
redrawAll();
return;
}
selectedIndex = hit.index;
selectedShape = hit.shape;
redrawAll();
}
gridEl.addEventListener('click', (e) => {
if (suppressNextClick) {
suppressNextClick = false;
return;
}
if (currentShape) return;
if (e.target.closest('[data-toolbar]')) return;
const docPt = pxToDocPoint(e.clientX, e.clientY);
const hit = pickShapeAt(docPt, shapes, 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 = pxToDocPoint(e.clientX, e.clientY);
const hit = pickShapeAt(docPt, shapes, 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 = pxToDocPoint(e.clientX, e.clientY);
const hit = pickShapeAt(docPt, shapes, 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();
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) ? data : data.shapes;
if (!Array.isArray(loadedShapes)) return;
shapes = rebuildPathCaches(sanitizeShapes(loadedShapes));
doc = {
version: Number(data?.version) || 1,
cellSize: Number(data?.cellSize) || cellSize,
shapes
};
saveDoc(doc);
history.length = 0;
history.push(structuredClone(shapes));
historyIndex = 0;
redrawAll();
} catch {
toastMessage('Failed to load data from JSON file.', 'danger');
}
};
reader.readAsText(file);
});
exportEl.addEventListener('click', () => {
/*
const payload = {
version: 1,
cellSize: cellSize,
shapes: stripCaches(shapes)
};
*/
const payload = encode();
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', () => {
cellSize = 25;
cellSizeEl.value = 25;
applyCellSize(25);
commit([]);
});
colorEl.addEventListener('input', () => {
selectedColor = colorEl.value || '#000000';
const circle = dotSVGEl.querySelector('circle');
if (circle) {
circle.setAttribute('fill', selectedColor);
}
});
fillOpacityEl?.addEventListener('input', () => {
currentFillOpacity = clamp01(fillOpacityEl.value, 1);
});
fillOpacityEl?.addEventListener('change', () => {
currentFillOpacity = clamp01(fillOpacityEl.value, 1);
});
strokeOpacityEl?.addEventListener('input', () => {
currentStrokeOpacity = clamp01(strokeOpacityEl.value, 1);
});
strokeOpacityEl?.addEventListener('change', () => {
currentStrokeOpacity = 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 (!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 = getActiveTool();
if (!drawing && !inside) {
coordsEl.classList.add('d-none');
dotEl.classList.add('d-none');
} else {
coordsEl.classList.remove('d-none');
if (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 (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') {
penAddPoint(currentShape, e.clientX, e.clientY, 0.02);
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 });
}
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 = 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 = pxToDocPoint(e.clientX, e.clientY);
currentShape = {
tool,
type: 'path',
points: [p],
color: selectedColor,
strokeWidth: currentStrokeWidth,
strokeOpacity: currentStrokeOpacity
};
}
});
return api;
}
(function autoBootGridWidgets() {
const GRID_BOOT = new WeakMap();
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;
if (mode !== 'editor') {
const script = root.querySelector('[data-grid-doc]');
if (script?.textContent?.trim()) {
try {
const parsed = JSON.parse(script.textContent);
api.setDoc(api.decode(parsed));
} catch (err) {
console.error("viewer JSON.parse failed:", err, script.textContent);
}
return;
}
const src = root.dataset.src;
if (src) {
fetch(src, { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
.then(doc => api.setDoc(api.decode(doc)))
.catch(() => { });
return;
}
const raw = root.dataset.doc;
if (raw) {
try {
const parsed = JSON.parse(raw);
api.setDoc(api.decode(parsed));
} catch { }
}
}
}
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;
}
}
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 });
})();