-
Cell Size:
-
+
+{% endmacro %}
+{{ drawWidget('test') }}
{% endblock %}
{% block script %}
-const DEFAULT_DOC = { version: 1, cellSize: 25, shapes: [] };
-const canvasEl = document.getElementById('overlay');
-const clearEl = document.getElementById('clear');
-const colorEl = document.getElementById('color');
-const coordsEl = document.getElementById('coords');
-const dotEl = document.getElementById('dot');
-const dotSVGEl = document.getElementById('dotSVG');
-const exportEl = document.getElementById('export');
-const gridEl = document.getElementById('grid');
-const importButtonEl = document.getElementById('importButton');
-const importEl = document.getElementById('import');
-const cellSizeEl = document.getElementById('cellSize');
-const gridWrapEl = document.getElementById('gridWrap');
-const toolBarEl = document.getElementById('toolBar');
-const fillOpacityEl = document.getElementById('fillOpacity');
+document.querySelectorAll('[data-grid-widget]').forEach((root, index) => {
+ initGridWidget(root, { storageKey: `gridDoc:${index}` });
+});
-let doc = loadDoc();
-let shapes = sanitizeShapes(Array.isArray(doc.shapes) ? doc.shapes : []);
-saveDoc({ ...doc, shapes });
-let cellSize = Number(doc.cellSize) || 25;
-cellSizeEl.value = cellSize;
-let dotSize = Math.floor(Math.max(cellSize * 1.25, 32));
+let activeGridWidget = null;
-let ctx;
-let dpr = 1;
-let selectedColor;
-let currentOpacity = clamp01(fillOpacityEl?.value ?? 1, 1);
+document.addEventListener('keydown', (e) => {
+ if (!activeGridWidget) return;
+ activeGridWidget.handleKeyDown(e);
+});
-let currentShape = null;
-const history = [structuredClone(shapes)];
-let historyIndex = 0
-let sizingRAF = 0;
-let lastApplied = { w: 0, h: 0 };
+function initGridWidget(root, opts = {}) {
+ const DEFAULT_DOC = { version: 1, cellSize: 25, shapes: [] };
-const ro = new ResizeObserver(scheduleSnappedCellSize);
-ro.observe(gridWrapEl);
+ const canvasEl = root.querySelector('[data-canvas]');
+ 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 gridEl = root.querySelector('[data-grid]');
+ const importButtonEl = root.querySelector('[data-import-button]');
+ const importEl = root.querySelector('[data-import]');
+ const cellSizeEl = root.querySelector('[data-cell-size]');
+ const gridWrapEl = root.querySelector('[data-grid-wrap]');
+ const toolBarEl = root.querySelector('[data-toolbar]');
+ const fillOpacityEl = root.querySelector('[data-fill-opacity]');
-const savedTool = localStorage.getItem('gridTool');
-if (savedTool) {
- setActiveTool(savedTool);
-}
+ const storageKey = opts.storageKey ?? 'gridDoc';
-const savedType = localStorage.getItem('gridType');
-if (savedType) {
- setActiveType(savedType);
-}
+ let doc = loadDoc();
+ let shapes = sanitizeShapes(Array.isArray(doc.shapes) ? doc.shapes : []);
+ saveDoc({ ...doc, shapes });
-resizeAndSetupCanvas();
+ const savedTool = localStorage.getItem(`${storageKey}:tool`);
+ if (savedTool) setActiveTool(savedTool);
-setGrid();
-scheduleSnappedCellSize();
+ const savedType = localStorage.getItem(`${storageKey}:gridType`);
+ if (savedType) setActiveType(savedType);
-function renderAllWithPreview(previewShape = null, dashed = true) {
- if (!ctx) return;
- clearCanvas();
- shapes.forEach(drawShape);
+ let cellSize = Number(doc.cellSize) || 25;
+ cellSizeEl.value = cellSize;
+ let dotSize = Math.floor(Math.max(cellSize * 1.25, 32));
- if (!previewShape) return;
+ let ctx;
+ let dpr = 1;
+ let selectedColor;
+ let currentOpacity = clamp01(fillOpacityEl?.value ?? 1, 1);
- ctx.save();
- if (dashed) ctx.setLineDash([5, 3]);
- drawShape(previewShape);
- ctx.restore();
-}
+ let currentShape = null;
+ const history = [structuredClone(shapes)];
+ let historyIndex = 0
-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);
-}
+ let sizingRAF = 0;
+ let lastApplied = { w: 0, h: 0 };
-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) };
-}
+ const ro = new ResizeObserver(scheduleSnappedCellSize);
+ ro.observe(gridWrapEl);
-function dist2(a, b) {
- const dx = a.x - b.x, dy = a.y - b.y;
- return dx*dx + dy*dy
-}
-
-function undo() {
- if (historyIndex <= 0) return;
- historyIndex--;
- shapes = structuredClone(history[historyIndex]);
- saveDoc({ ...doc, shapes, cellSize });
- redrawAll();
-}
-
-function redo() {
- if (historyIndex >= history.length - 1) return;
- historyIndex++;
- shapes = structuredClone(history[historyIndex]);
- saveDoc({ ...doc, shapes, cellSize });
- redrawAll();
-}
-
-function commit(nextShapes) {
- history.splice(historyIndex + 1);
- history.push(structuredClone(nextShapes));
- historyIndex++;
- shapes = nextShapes;
-
- saveDoc({ ...doc, shapes, cellSize });
- redrawAll();
-}
-
-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']);
-
- return list.flatMap((s) => {
- if (!s || typeof s !== 'object' || !allowed.has(s.type)) return [];
- const color = typeof s.color === 'string' ? s.color : '#000000';
- const opacity = clamp01(s.opacity, 1);
-
- 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 }];
- }
-
- 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 [];
-
- const width = isFiniteNum(s.width) ? Math.max(0.05, +s.width) : 0.12;
- const opacity = clamp01(s.opacity, 1);
-
- return [{
- type: 'path',
- points,
- color,
- width,
- opacity
- }];
- }
-
- 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,
- opacity
- }];
- });
-}
-
-function loadDoc() {
- try { return JSON.parse(localStorage.getItem("gridDoc")) || structuredClone(DEFAULT_DOC); }
- catch { return structuredClone(DEFAULT_DOC); }
-}
-
-function saveDoc(nextDoc = doc) {
- doc = nextDoc;
- try { localStorage.setItem("gridDoc", JSON.stringify(nextDoc)); } catch {}
-}
-
-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);
-
- if (snappedW === lastApplied.w && snappedH === lastApplied.h) return;
- lastApplied = { w: snappedW, h: snappedH };
-
- gridEl.style.width = `${snappedW}px`;
- gridEl.style.height = `${snappedH}px`;
-
- toolBarEl.style.width = `${snappedW}px`;
-
- gridEl.getBoundingClientRect();
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;
-}
+ const api = {
+ handleKeyDown(e) {
+ const key = e.key.toLowerCase();
-function getActiveTool() {
- const checked = document.querySelector('input[name="tool"]:checked');
- return checked ? checked.id : 'outline';
-}
+ // Only act if the focus isn't in an input inside this widget.
+ // (Otherwise Ctrl+Z in a number field becomes a drawing undo, which is… rude.)
+ const t = e.target;
+ if (t && root.contains(t) && (t.matches('input, textarea, select') || t.isContentEditable)) return;
-function setActiveTool(toolId) {
- const el = document.getElementById(toolId);
- if (el) {
- el.checked = true;
- localStorage.setItem('gridTool', toolId);
- }
-}
+ if ((e.ctrlKey || e.metaKey) && key === 'z') {
+ e.preventDefault();
+ if (e.shiftKey) redo();
+ else undo();
+ return;
+ }
-function getActiveType() {
- const checked = document.querySelector('input[name="gridType"]:checked');
- return checked ? checked.id : 'noGrid';
-}
+ if ((e.ctrlKey || e.metaKey) && key === 'y') {
+ e.preventDefault();
+ redo();
+ return;
+ }
-function setActiveType(typeId) {
- const el = document.getElementById(typeId);
- if (el) {
- el.checked = true;
- localStorage.setItem('gridType', typeId);
- }
-}
-
-function snapToGrid(x, y) {
- /*
- Shapes are stored in grid units (document units), not pixels.
- 1 unit renders as cellSize pixels, so changing cellSize rescales (zooms) the whole drawing.
- Grid modes only affect snapping/visuals; storage is always in document units for portability.
- */
-
- 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
+ if (key === 'escape' && currentShape) {
+ e.preventDefault();
+ currentShape = null;
+ redrawAll();
+ }
+ }
};
-}
-function normalizeRect(shape) {
- const x1 = pxToGrid(shape.x1);
- const y1 = pxToGrid(shape.y1);
- const x2 = pxToGrid(shape.x2);
- const y2 = pxToGrid(shape.y2);
+ root.addEventListener('focusin', () => { activeGridWidget = api; });
- 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,
- opacity: clamp01(shape.opacity, 1)
- };
-}
+ root.addEventListener('pointerdown', () => {
+ activeGridWidget = api;
+ }, { capture: true });
-function normalizeEllipse(shape) {
- const r = normalizeRect(shape);
- return { ...r, type: 'ellipse' };
-}
+ function renderAllWithPreview(previewShape = null, dashed = true) {
+ if (!ctx) return;
+ clearCanvas();
+ shapes.forEach(drawShape);
-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
- };
-}
+ if (!previewShape) return;
-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);
-
- selectedColor = colorEl.value || '#000000';
- const circle = dotSVGEl.querySelector('circle');
- if (circle) {
- circle.setAttribute('fill', selectedColor);
+ ctx.save();
+ if (dashed) ctx.setLineDash([5, 3]);
+ drawShape(previewShape);
+ ctx.restore();
}
- redrawAll();
-}
+ 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 redrawAll() {
- if (!ctx || !shapes) return;
+ 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) };
+ }
- clearCanvas();
- shapes.forEach(drawShape);
-}
+ function dist2(a, b) {
+ const dx = a.x - b.x, dy = a.y - b.y;
+ return dx * dx + dy * dy
+ }
-function drawShape(shape) {
- if (!ctx) return;
- const toPx = (v) => v * cellSize;
+ function undo() {
+ if (historyIndex <= 0) return;
+ historyIndex--;
+ shapes = structuredClone(history[historyIndex]);
+ saveDoc({ ...doc, shapes, cellSize });
+ redrawAll();
+ }
- ctx.save();
- ctx.strokeStyle = shape.color || '#000000';
+ function redo() {
+ if (historyIndex >= history.length - 1) return;
+ historyIndex++;
+ shapes = structuredClone(history[historyIndex]);
+ saveDoc({ ...doc, shapes, cellSize });
+ redrawAll();
+ }
- 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);
+ function commit(nextShapes) {
+ history.splice(historyIndex + 1);
+ history.push(structuredClone(nextShapes));
+ historyIndex++;
+ shapes = nextShapes;
- 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();
+ saveDoc({ ...doc, shapes, cellSize });
+ redrawAll();
+ }
+
+ 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']);
+
+ return list.flatMap((s) => {
+ if (!s || typeof s !== 'object' || !allowed.has(s.type)) return [];
+ const color = typeof s.color === 'string' ? s.color : '#000000';
+ const opacity = clamp01(s.opacity, 1);
+
+ 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 }];
+ }
+
+ 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 [];
+
+ const width = isFiniteNum(s.width) ? Math.max(0.05, +s.width) : 0.12;
+ const opacity = clamp01(s.opacity, 1);
+
+ return [{
+ type: 'path',
+ points,
+ color,
+ width,
+ opacity
+ }];
+ }
+
+ 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,
+ opacity
+ }];
+ });
+ }
+
+ function loadDoc() {
+ try { return JSON.parse(localStorage.getItem(storageKey)) || structuredClone(DEFAULT_DOC); }
+ catch { return structuredClone(DEFAULT_DOC); }
+ }
+
+ function saveDoc(nextDoc = doc) {
+ doc = nextDoc;
+ try { localStorage.setItem(storageKey, JSON.stringify(nextDoc)); } catch { }
+ }
+
+ 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);
+
+ if (snappedW === lastApplied.w && snappedH === lastApplied.h) return;
+ lastApplied = { w: snappedW, h: snappedH };
+
+ gridEl.style.width = `${snappedW}px`;
+ gridEl.style.height = `${snappedH}px`;
+
+ toolBarEl.style.width = `${snappedW}px`;
+
+ gridEl.getBoundingClientRect();
+ 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) {
+ /*
+ Shapes are stored in grid units (document units), not pixels.
+ 1 unit renders as cellSize pixels, so changing cellSize rescales (zooms) the whole drawing.
+ Grid modes only affect snapping/visuals; storage is always in document units for portability.
+ */
+
+ 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 (shape.fill) {
- ctx.globalAlpha = clamp01(shape.opacity, 1);
- ctx.fillStyle = shape.color;
+ 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,
+ opacity: clamp01(shape.opacity, 1)
+ };
+ }
+
+ 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
+ };
+ }
+
+ 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);
+
+ selectedColor = colorEl.value || '#000000';
+ const circle = dotSVGEl.querySelector('circle');
+ if (circle) {
+ circle.setAttribute('fill', selectedColor);
+ }
+
+ redrawAll();
+ }
+
+ function redrawAll() {
+ if (!ctx || !shapes) return;
+
+ clearCanvas();
+ shapes.forEach(drawShape);
+ }
+
+ function drawShape(shape) {
+ if (!ctx) return;
+ const toPx = (v) => v * cellSize;
+
+ ctx.save();
+ ctx.strokeStyle = shape.color || '#000000';
+
+ 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);
+
if (shape.type === 'rect') {
- ctx.fillRect(x, y, w, h);
+ 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.fill()
+ ctx.stroke();
}
- 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);
+ if (shape.fill) {
+ ctx.globalAlpha = clamp01(shape.opacity, 1);
+ 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;
+ }
- ctx.beginPath();
- ctx.moveTo(x1, y1);
- ctx.lineTo(x2, y2);
- ctx.stroke();
- } else if (shape.type === 'path') {
- const toPx = (v) => v * cellSize;
+ } 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.opacity, 1);
+ ctx.beginPath();
+ ctx.moveTo(x1, y1);
+ ctx.lineTo(x2, y2);
+ ctx.stroke();
+ } else if (shape.type === 'path') {
+ const toPx = (v) => v * cellSize;
- ctx.lineWidth = Math.max(1, toPx(shape.width ?? 0.12));
- ctx.lineJoin = 'round';
- ctx.lineCap = 'round';
+ ctx.globalAlpha = clamp01(shape.opacity, 1);
- const pts = 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.lineWidth = Math.max(1, toPx(shape.width ?? 0.12));
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
- ctx.restore();
-}
-
-function clearCanvas() {
- if (!ctx) return;
- ctx.clearRect(0, 0, canvasEl.width / dpr, canvasEl.height / dpr);
-}
-
-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 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 simplified = [pts[0]];
- const minStep = 0.03;
+ const pts = shape.points;
+ ctx.beginPath();
+ ctx.moveTo(toPx(pts[0].x), toPx(pts[0].y));
for (let i = 1; i < pts.length; i++) {
- if (dist2(pts[i], simplified[simplified.length - 1]) >= minStep * minStep) {
- simplified.push(pts[i]);
+ ctx.lineTo(toPx(pts[i].x), toPx(pts[i].y));
+ }
+ ctx.stroke();
+ }
+
+ ctx.restore();
+ }
+
+ function clearCanvas() {
+ if (!ctx) return;
+ ctx.clearRect(0, 0, canvasEl.width / dpr, canvasEl.height / dpr);
+ }
+
+ 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 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 simplified = [pts[0]];
+ const minStep = 0.03;
+ for (let i = 1; i < pts.length; i++) {
+ if (dist2(pts[i], simplified[simplified.length - 1]) >= minStep * minStep) {
+ simplified.push(pts[i]);
+ }
+ }
+ if (simplified.length >= 2) {
+ finalShape = { ...currentShape, points: simplified };
}
}
- if (simplified.length >= 2) {
- finalShape = { ...currentShape, points: simplified };
- }
+ } 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;
}
- } 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;
+ if (finalShape) commit([...shapes, finalShape]);
- } else if (currentShape.tool === 'filledEllipse' || currentShape.tool === 'outlineEllipse') {
- const ellipse = normalizeEllipse(currentShape);
- if (ellipse.w > 0 && ellipse.h > 0) finalShape = ellipse;
+ currentShape = null;
+ renderAllWithPreview(null); // clean final render
}
- if (finalShape) commit([...shapes, finalShape]);
+ gridEl.addEventListener('pointerup', onPointerUp);
- currentShape = null;
- renderAllWithPreview(null); // clean final render
-}
-
-gridEl.addEventListener('pointerup', onPointerUp);
-window.addEventListener('pointerup', onPointerUp, { capture: true });
-
-document.querySelectorAll('input[name="tool"]').forEach(input => {
- input.addEventListener('change', () => {
- if (input.checked) {
- localStorage.setItem('gridTool', input.id);
- }
- });
-});
-
-document.querySelectorAll('input[name="gridType"]').forEach(input => {
- input.addEventListener('change', () => {
- if (input.checked) {
- localStorage.setItem('gridType', input.id);
- }
- 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 = JSON.parse(reader.result);
-
- if (Number.isFinite(Number(data.cellSize)) && Number(data.cellSize) >= 1) {
- cellSizeEl.value = data.cellSize;
- applyCellSize(data.cellSize);
+ root.querySelectorAll('input[data-tool]').forEach((input) => {
+ input.addEventListener('change', () => {
+ if (input.checked) {
+ localStorage.setItem(`${storageKey}:tool`, input.value);
}
+ });
+ });
- const loadedShapes = Array.isArray(data) ? data : data.shapes;
- if (!Array.isArray(loadedShapes)) return;
-
- shapes = sanitizeShapes(loadedShapes);
-
- doc = {
- version: Number(data?.version) || 1,
- cellSize: Number(data?.cellSize) || cellSize,
- shapes
- };
-
- saveDoc(doc);
+ root.querySelectorAll('input[data-gridtype]').forEach((input) => {
+ input.addEventListener('change', () => {
+ if (input.checked) {
+ localStorage.setItem(`${storageKey}:gridType`, input.value);
+ }
+ setGrid();
redrawAll();
- } catch {
- toastMessage('Failed to load data from JSON file.', 'danger');
+ });
+ });
+
+
+ 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 = 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 = sanitizeShapes(loadedShapes);
+
+ doc = {
+ version: Number(data?.version) || 1,
+ cellSize: Number(data?.cellSize) || cellSize,
+ shapes
+ };
+
+ saveDoc(doc);
+ redrawAll();
+ } catch {
+ toastMessage('Failed to load data from JSON file.', 'danger');
+ }
+ };
+ reader.readAsText(file);
+ });
+
+ exportEl.addEventListener('click', () => {
+ const payload = {
+ version: 1,
+ cellSize: cellSize,
+ shapes
+ };
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { 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);
}
- };
- reader.readAsText(file);
-});
+ });
-exportEl.addEventListener('click', () => {
- const payload = {
- version: 1,
- cellSize: cellSize,
- shapes
- };
- const blob = new Blob([JSON.stringify(payload, null, 2)], { 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);
-});
+ fillOpacityEl?.addEventListener('input', () => {
+ currentOpacity = clamp01(fillOpacityEl.value, 0);
+ });
-clearEl.addEventListener('click', () => {
- cellSize = 25;
- cellSizeEl.value = 25;
- applyCellSize(25);
- commit([]);
-});
+ fillOpacityEl?.addEventListener('change', () => {
+ currentOpacity = clamp01(fillOpacityEl.value, 0);
+ });
-colorEl.addEventListener('input', () => {
- selectedColor = document.getElementById('color').value;
- const circle = dotSVGEl.querySelector('circle');
- if (circle) {
- circle.setAttribute('fill', selectedColor);
- }
-});
-
-document.addEventListener('keydown', (e) => {
- const key = e.key.toLowerCase();
-
- if ((e.ctrlKey || e.metaKey) && key === 'z') {
- e.preventDefault();
- undo();
- }
-
- if ((e.ctrlKey || e.metaKey) && (key === 'y' || (e.shiftKey && key === 'z'))) {
- e.preventDefault(); redo();
- }
-
- if (key === 'escape' && currentShape) {
+ gridEl.addEventListener('pointercancel', () => {
currentShape = null;
redrawAll();
- }
-});
+ });
-fillOpacityEl?.addEventListener('input', () => {
- currentOpacity = clamp01(fillOpacityEl.value, 0);
-});
+ gridEl.addEventListener('lostpointercapture', () => {
+ currentShape = null;
+ redrawAll();
+ });
-fillOpacityEl?.addEventListener('change', () => {
- currentOpacity = clamp01(fillOpacityEl.value, 0);
-});
+ gridEl.addEventListener('pointermove', (e) => {
+ if (!ctx) return;
-gridEl.addEventListener('pointercancel', () => {
- currentShape = null;
- redrawAll();
-});
+ const { ix, iy, x: snapX, y: snapY, localX, localY } = snapToGrid(e.clientX, e.clientY);
+ const tool = getActiveTool();
-gridEl.addEventListener('lostpointercapture', () => {
- currentShape = null;
- redrawAll();
-});
+ coordsEl.classList.remove('d-none');
-gridEl.addEventListener('pointermove', (e) => {
- if (!ctx) return;
+ if (getActiveType() !== 'noGrid' && tool !== 'pen') {
+ dotEl.classList.remove('d-none');
- const { ix, iy, x: snapX, y: snapY, localX, localY } = snapToGrid(e.clientX, e.clientY);
- const tool = getActiveTool();
+ const gridRect = gridEl.getBoundingClientRect();
+ const wrapRect = gridWrapEl.getBoundingClientRect();
+ const offsetX = gridRect.left - wrapRect.left;
+ const offsetY = gridRect.top - wrapRect.top;
- coordsEl.classList.remove('d-none');
+ dotEl.style.left = `${offsetX + snapX}px`;
+ dotEl.style.top = `${offsetY + snapY}px`;
+ } else {
+ dotEl.classList.add('d-none');
+ }
- if (getActiveType() !== 'noGrid' && tool !== 'pen') {
- dotEl.classList.remove('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))`;
+ }
- const gridRect = gridEl.getBoundingClientRect();
- const wrapRect = gridWrapEl.getBoundingClientRect();
- const offsetX = gridRect.left - wrapRect.left;
- const offsetY = gridRect.top - wrapRect.top;
+ if (!currentShape) return;
- dotEl.style.left = `${offsetX + snapX}px`;
- dotEl.style.top = `${offsetY + snapY}px`;
- } else {
+ // PEN: mutate points and preview the same shape object
+ if (currentShape.tool === 'pen') {
+ penAddPoint(currentShape, e.clientX, e.clientY, 0.02);
+ renderAllWithPreview(currentShape, true);
+ return;
+ }
+
+ // Other tools: build a normalized preview shape
+ let preview = null;
+
+ if (currentShape.tool === 'line') {
+ preview = normalizeLine({
+ type: 'line',
+ x1: currentShape.x1,
+ y1: currentShape.y1,
+ x2: snapX,
+ y2: snapY,
+ color: currentShape.color
+ });
+ } 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, true);
+ });
+
+ gridEl.addEventListener('pointerleave', (e) => {
+ coordsEl.classList.add('d-none');
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))`;
- }
+ gridEl.addEventListener('pointerdown', (e) => {
+ if (e.button !== 0) return;
- if (!currentShape) return;
+ if (e.target.closest('[data-toolbar]')) return;
- // PEN: mutate points and preview the same shape object
- if (currentShape.tool === 'pen') {
- penAddPoint(currentShape, e.clientX, e.clientY, 0.02);
- renderAllWithPreview(currentShape, true);
- return;
- }
+ e.preventDefault();
+ gridEl.setPointerCapture(e.pointerId);
- // Other tools: build a normalized preview shape
- let preview = null;
+ const { x: snapX, y: snapY } = snapToGrid(e.clientX, e.clientY);
+ const tool = getActiveTool();
- if (currentShape.tool === 'line') {
- preview = normalizeLine({
- type: 'line',
- x1: currentShape.x1,
- y1: currentShape.y1,
- x2: snapX,
- y2: snapY,
- color: currentShape.color
- });
- } 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, true);
-});
-
-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('#toolBar')) return;
-
- e.preventDefault();
- gridEl.setPointerCapture(e.pointerId);
-
- 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
- };
- } else if (tool === 'outline' || tool === 'filled') {
- currentShape = {
- tool,
- x1: snapX,
- y1: snapY,
- x2: snapX,
- y2: snapY,
- color: selectedColor,
- fill: (tool === 'filled'),
- opacity: currentOpacity
- };
- } else if (tool === 'outlineEllipse' || tool === 'filledEllipse') {
- currentShape = {
- tool,
- x1: snapX,
- y1: snapY,
- x2: snapX,
- y2: snapY,
- color: selectedColor,
- fill: (tool === 'filledEllipse'),
- opacity: currentOpacity
- };
- } else if (tool === 'pen') {
- const p = pxToDocPoint(e.clientX, e.clientY);
- currentShape = {
- tool,
- type: 'path',
- points: [p],
- color: selectedColor,
- width: 0.12,
- opacity: 1
- };
- }
-});
-
-window.addEventListener('pointerup', (e) => {
- if (!currentShape) return;
-
- 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 simplified = [pts[0]];
- const minStep = 0.03;
- for (let i = 1; i < pts.length; i++) {
- if (dist2(pts[i], simplified[simplified.length - 1]) >= minStep * minStep) {
- simplified.push(pts[i])
- }
- }
- if (simplified.length >= 2) {
- finalShape = { ...currentShape, points: simplified };
- }
+ if (tool === 'line') {
+ currentShape = {
+ tool,
+ type: 'line',
+ x1: snapX,
+ y1: snapY,
+ x2: snapX,
+ y2: snapY,
+ color: selectedColor
+ };
+ } else if (tool === 'outline' || tool === 'filled') {
+ currentShape = {
+ tool,
+ x1: snapX,
+ y1: snapY,
+ x2: snapX,
+ y2: snapY,
+ color: selectedColor,
+ fill: (tool === 'filled'),
+ opacity: currentOpacity
+ };
+ } else if (tool === 'outlineEllipse' || tool === 'filledEllipse') {
+ currentShape = {
+ tool,
+ x1: snapX,
+ y1: snapY,
+ x2: snapX,
+ y2: snapY,
+ color: selectedColor,
+ fill: (tool === 'filledEllipse'),
+ opacity: currentOpacity
+ };
+ } else if (tool === 'pen') {
+ const p = pxToDocPoint(e.clientX, e.clientY);
+ currentShape = {
+ tool,
+ type: 'path',
+ points: [p],
+ color: selectedColor,
+ width: 0.12,
+ opacity: 1
+ };
}
- }
-
- 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]);
-
- clearCanvas();
- shapes.forEach(drawShape);
-
- currentShape = null;
-});
+ });
+}
{% endblock %}
\ No newline at end of file