diff --git a/inventory/static/js/components/draw.js b/inventory/static/js/components/draw.js index a7fb387..7963123 100644 --- a/inventory/static/js/components/draw.js +++ b/inventory/static/js/components/draw.js @@ -10,8 +10,10 @@ document.addEventListener('keydown', (e) => { }); function initGridWidget(root, opts = {}) { + const mode = opts.mode || 'editor'; + const storageKey = opts.storageKey ?? 'gridDoc'; + const DEFAULT_DOC = { version: 1, cellSize: 25, shapes: [] }; - const MAX_HISTORY = 100; const SHAPE_DEFAULTS = { strokeWidth: 0.12, strokeOpacity: 1, @@ -19,17 +21,226 @@ function initGridWidget(root, opts = {}) { }; 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 = sanitizeShapes(Array.isArray(doc.shapes) ? doc.shapes : []); + let cellSize = Number(doc.cellSize) || 25; + + let ctx; + let dpr = 1; + + function clamp01(n, fallback = 1) { + const x = Number(n); + return Number.isFinite(x) ? Math.min(1, Math.max(0, x)) : fallback; + } + + function isFiniteNum(n) { return Number.isFinite(Number(n)); } + + 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 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 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.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(); + shapes.forEach(drawShape); + } + + function setDoc(nextDoc) { + const d = nextDoc && typeof nextDoc === 'object' ? nextDoc : DEFAULT_DOC; + cellSize = Number(d.cellSize) || 25; + shapes = sanitizeShapes(Array.isArray(d.shapes) ? d.shapes : []); + saveDoc({ version: Number(d.version) || 1, cellSize, shapes }); + resizeAndSetupCanvas(); + } + + resizeAndSetupCanvas(); + redrawAll(); + + if (mode !== 'editor') { + return { setDoc, redraw: redrawAll }; + } + + 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 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 strokeOpacityEl = root.querySelector('[data-stroke-opacity]'); @@ -44,10 +255,6 @@ function initGridWidget(root, opts = {}) { 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`); - const storageKey = opts.storageKey ?? 'gridDoc'; - - let doc = loadDoc(); - let shapes = sanitizeShapes(Array.isArray(doc.shapes) ? doc.shapes : []); saveDoc({ ...doc, shapes }); const savedTool = localStorage.getItem(`${storageKey}:tool`); @@ -56,12 +263,9 @@ function initGridWidget(root, opts = {}) { const savedType = localStorage.getItem(`${storageKey}:gridType`); if (savedType) setActiveType(savedType); - let cellSize = Number(doc.cellSize) || 25; cellSizeEl.value = cellSize; let dotSize = Math.floor(Math.max(cellSize * 1.25, 32)); - let ctx; - let dpr = 1; let selectedColor; let currentFillOpacity = clamp01(fillOpacityEl?.value ?? 1, 1); let currentStrokeOpacity = clamp01(strokeOpacityEl?.value ?? 1, 1); @@ -77,8 +281,6 @@ function initGridWidget(root, opts = {}) { const ro = new ResizeObserver(scheduleSnappedCellSize); ro.observe(gridWrapEl); - resizeAndSetupCanvas(); - setGrid(); scheduleSnappedCellSize(); @@ -143,6 +345,53 @@ function initGridWidget(root, opts = {}) { activeGridWidget = api; }, { capture: true }); + 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; @@ -285,80 +534,6 @@ function initGridWidget(root, opts = {}) { 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']); - - 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 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; } @@ -528,165 +703,6 @@ function initGridWidget(root, opts = {}) { }; } - 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'; - 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.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 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; @@ -915,7 +931,7 @@ function initGridWidget(root, opts = {}) { // PEN: mutate points and preview the same shape object if (currentShape.tool === 'pen') { penAddPoint(currentShape, e.clientX, e.clientY, 0.02); - renderAllWithPreview(currentShape, true); + renderAllWithPreview(currentShape, false); return; } @@ -938,7 +954,7 @@ function initGridWidget(root, opts = {}) { preview = normalizeEllipse({ ...currentShape, x2: snapX, y2: snapY }); } - renderAllWithPreview(preview, true); + renderAllWithPreview(preview, currentShape.tool !== 'pen'); }); gridEl.addEventListener('pointerleave', (e) => { diff --git a/inventory/templates/components/draw.html b/inventory/templates/components/draw.html index b916248..4627b83 100644 --- a/inventory/templates/components/draw.html +++ b/inventory/templates/components/draw.html @@ -241,4 +241,7 @@ -{% endmacro %} \ No newline at end of file +{% endmacro %} + +{% macro viewWidget(uid, json) %} +{% endmacro %}