diff --git a/inventory/templates/testing.html b/inventory/templates/testing.html index 6e66761..d645a78 100644 --- a/inventory/templates/testing.html +++ b/inventory/templates/testing.html @@ -1,14 +1,13 @@ {% extends 'base.html' %} {% block style %} - -#gridWrap { +.grid-widget .grid-wrap { width: 100%; height: 80vh; position: relative; } -#grid { +.grid-widget [data-grid] { position: relative; cursor: crosshair; width: 100%; @@ -17,27 +16,27 @@ margin: 0 auto; } -#toolBar::-webkit-scrollbar { +.grid-widget [data-toolbar]::-webkit-scrollbar { height: 8px; } -#coords { +.grid-widget [data-coords] { bottom: 10px; pointer-events: none; left: 10px; } -#overlay { +.grid-widget [data-canvas] { z-index: 9999; pointer-events: none; inset: 0; } -#toolBar { +.grid-widget [data-toolbar] { margin: 0 auto; } -#dot { +.grid-widget [data-dot] { transform: translate(-50%, -50%); z-index: 10000; pointer-events: none; @@ -45,992 +44,956 @@ {% endblock %} {% block main %} +{% macro drawWidget(uid) %}
-
-
- Cell Size: - +
+
+
+ Cell Size: + - Fill Opacity: - + Fill Opacity: + +
+
+ +
+
+ + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + +
-
- -
-
- -
-
- - - - - -
-
- + +
+
+ +
+{% 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