diff --git a/inventory/static/css/components/draw.css b/inventory/static/css/components/draw.css new file mode 100644 index 0000000..36d6bc0 --- /dev/null +++ b/inventory/static/css/components/draw.css @@ -0,0 +1,40 @@ +.grid-widget .grid-wrap { + width: 100%; + height: 80vh; + position: relative; +} + +.grid-widget [data-grid] { + position: relative; + cursor: crosshair; + width: 100%; + height: 100%; + touch-action: none; + margin: 0 auto; +} + +.grid-widget [data-toolbar]::-webkit-scrollbar { + height: 8px; +} + +.grid-widget [data-coords] { + bottom: 10px; + pointer-events: none; + left: 10px; +} + +.grid-widget [data-canvas] { + z-index: 9999; + pointer-events: none; + inset: 0; +} + +.grid-widget [data-toolbar] { + margin: 0 auto; +} + +.grid-widget [data-dot] { + transform: translate(-50%, -50%); + z-index: 10000; + pointer-events: none; +} \ No newline at end of file diff --git a/inventory/static/js/components/draw.js b/inventory/static/js/components/draw.js new file mode 100644 index 0000000..b968cc7 --- /dev/null +++ b/inventory/static/js/components/draw.js @@ -0,0 +1,877 @@ +document.querySelectorAll('[data-grid-widget]').forEach((root, index) => { + initGridWidget(root, { storageKey: `gridDoc:${index}` }); +}); + + +let activeGridWidget = null; + +document.addEventListener('keydown', (e) => { + if (!activeGridWidget) return; + activeGridWidget.handleKeyDown(e); +}); + + +function initGridWidget(root, opts = {}) { + const DEFAULT_DOC = { version: 1, cellSize: 25, shapes: [] }; + + 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 strokeOpacityEl = root.querySelector('[data-stroke-opacity]'); + const strokeWidthEl = root.querySelector('[data-stroke-width]'); + + 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`); + if (savedTool) setActiveTool(savedTool); + + 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); + let currentStrokeWidth = Number(strokeWidthEl?.value ?? 0.12) || 0.12; + + let currentShape = null; + const history = [structuredClone(shapes)]; + let historyIndex = 0 + + let sizingRAF = 0; + let lastApplied = { w: 0, h: 0 }; + + const ro = new ResizeObserver(scheduleSnappedCellSize); + ro.observe(gridWrapEl); + + resizeAndSetupCanvas(); + + setGrid(); + scheduleSnappedCellSize(); + + let activePointerId = null; + + const api = { + handleKeyDown(e) { + const key = e.key.toLowerCase(); + const t = e.target; + + const isTextField = t && root.contains(t) && (t.matches('input, textarea, select') || t.isContentEditable); + + if (isTextField) { + const isUndo = (e.ctrlKey || e.metaKey) && key === 'z'; + const isRedo = (e.ctrlKey || e.metaKey) && (key === 'y' || (key === 'z' && e.shiftKey)); + + if (!isUndo && !isRedo) return; + } + + if ((e.ctrlKey || e.metaKey) && key === 'z') { + e.preventDefault(); + if (e.shiftKey) redo(); + else undo(); + return; + } + + if ((e.ctrlKey || e.metaKey) && key === 'y') { + e.preventDefault(); + redo(); + return; + } + + if (key === 'escape' && currentShape) { + e.preventDefault(); + currentShape = null; + redrawAll(); + } + } + }; + + root.addEventListener('focusin', () => { activeGridWidget = api; }); + + root.addEventListener('pointerdown', () => { + activeGridWidget = api; + }, { capture: true }); + + function finishPointer(e) { + if (!currentShape) return; + if (activePointerId !== null && e.pointerId !== activePointerId) return; + + onPointerUp(e); + activePointerId = null; + } + + function renderAllWithPreview(previewShape = null, dashed = true) { + if (!ctx) return; + clearCanvas(); + shapes.forEach(drawShape); + + if (!previewShape) return; + + ctx.save(); + if (dashed) ctx.setLineDash([5, 3]); + drawShape(previewShape); + ctx.restore(); + } + + function penAddPoint(shape, clientX, clientY, minStep = 0.02) { + const p = pxToDocPoint(clientX, clientY); + const pts = shape.points; + const last = pts[pts.length - 1]; + if (!last || dist2(p, last) >= minStep * minStep) pts.push(p); + } + + function pxToDocPoint(clientX, clientY) { + const rect = gridEl.getBoundingClientRect(); + const x = Math.min(Math.max(clientX, rect.left), rect.right) - rect.left; + const y = Math.min(Math.max(clientY, rect.top), rect.bottom) - rect.top; + return { x: pxToGrid(x), y: pxToGrid(y) }; + } + + function dist2(a, b) { + const dx = a.x - b.x, dy = a.y - b.y; + return dx * dx + dy * dy + } + + function 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']); + + 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, 1); + const strokeOpacity = clamp01(s.strokeOpacity, 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, + strokeWidth: normStroke(s.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, + width: normStroke(s.width, 0.12), + 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) + }]; + }); + } + + 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 (type === 'fullGrid' || type === 'horizontalGrid') { + snapY = Math.min(iy * grid, rect.height); + } + + return { + ix, + iy, + x: snapX, + y: snapY, + localX, + localY + }; + } + + function normalizeRect(shape) { + const x1 = pxToGrid(shape.x1); + const y1 = pxToGrid(shape.y1); + const x2 = pxToGrid(shape.x2); + const y2 = pxToGrid(shape.y2); + + return { + type: 'rect', + x: Math.min(x1, x2), + y: Math.min(y1, y2), + w: Math.abs(x2 - x1), + h: Math.abs(y2 - y1), + color: shape.color, + fill: shape.fill, + fillOpacity: clamp01(shape.fillOpacity, 1), + strokeOpacity: clamp01(shape.strokeOpacity, 1), + strokeWidth: isFiniteNum(shape.strokeWidth) ? Math.max(0, +shape.strokeWidth) : 0.12 + }; + } + + function normalizeEllipse(shape) { + const r = normalizeRect(shape); + return { ...r, type: 'ellipse' }; + } + + function normalizeLine(shape) { + return { + type: 'line', + x1: pxToGrid(shape.x1), + y1: pxToGrid(shape.y1), + x2: pxToGrid(shape.x2), + y2: pxToGrid(shape.y2), + color: shape.color, + strokeWidth: isFiniteNum(shape.strokeWidth) ? Math.max(0, +shape.strokeWidth) : 0.12, + strokeOpacity: clamp01(shape.strokeOpacity) + }; + } + + function 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 ?? 0.12)); + + 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, 1); + 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, 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; + } + + } 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, 1); + 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, 1); + + ctx.lineWidth = Math.max(1, toPx(shape.width ?? 0.12)); + 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; + + // 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 }; + } + } + } else if (currentShape.tool === 'line') { + const line = normalizeLine(currentShape); + if (line.x1 !== line.x2 || line.y1 !== line.y2) finalShape = line; + + } else if (currentShape.tool === 'filled' || currentShape.tool === 'outline') { + const rect = normalizeRect(currentShape); + if (rect.w > 0 && rect.h > 0) finalShape = rect; + + } else if (currentShape.tool === 'filledEllipse' || currentShape.tool === 'outlineEllipse') { + const ellipse = normalizeEllipse(currentShape); + if (ellipse.w > 0 && ellipse.h > 0) finalShape = ellipse; + } + + if (finalShape) commit([...shapes, finalShape]); + + currentShape = null; + renderAllWithPreview(null); // clean final render + } + + gridEl.addEventListener('pointerup', finishPointer); + window.addEventListener('pointerup', finishPointer); + window.addEventListener('pointercancel', finishPointer); + + root.querySelectorAll('input[data-tool]').forEach((input) => { + input.addEventListener('change', () => { + if (input.checked) { + localStorage.setItem(`${storageKey}:tool`, input.value); + } + }); + }); + + root.querySelectorAll('input[data-gridtype]').forEach((input) => { + input.addEventListener('change', () => { + if (input.checked) { + localStorage.setItem(`${storageKey}:gridType`, input.value); + } + setGrid(); + redrawAll(); + }); + }); + + + cellSizeEl.addEventListener('input', () => applyCellSize(cellSizeEl.value)); + cellSizeEl.addEventListener('change', () => applyCellSize(cellSizeEl.value)); + + importButtonEl.addEventListener('click', () => importEl.click()); + + importEl.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = () => { + try { + const data = 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); + + history.length = 0; + history.push(structuredClone(shapes)); + historyIndex = 0; + + redrawAll(); + } catch { + toastMessage('Failed to load data from JSON file.', 'danger'); + } + }; + reader.readAsText(file); + }); + + exportEl.addEventListener('click', () => { + const payload = { + version: 1, + cellSize: cellSize, + shapes + }; + 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); + } + }); + + fillOpacityEl?.addEventListener('input', () => { + currentFillOpacity = clamp01(fillOpacityEl.value, 1); + }); + + fillOpacityEl?.addEventListener('change', () => { + currentFillOpacity = clamp01(fillOpacityEl.value, 1); + }); + + strokeOpacityEl?.addEventListener('input', () => { + currentStrokeOpacity = clamp01(strokeOpacityEl.value, 1); + }); + + strokeOpacityEl?.addEventListener('change', () => { + currentStrokeOpacity = clamp01(strokeOpacityEl.value, 1); + }); + + strokeWidthEl?.addEventListener('input', () => { + currentStrokeWidth = Math.max(0, Number(strokeWidthEl.value) || 0.12); + }); + + strokeWidthEl?.addEventListener('change', () => { + currentStrokeWidth = Math.max(0, Number(strokeWidthEl.value) || 0.12); + }); + + gridEl.addEventListener('pointercancel', () => { + currentShape = null; + redrawAll(); + }); + + gridEl.addEventListener('lostpointercapture', () => { + currentShape = null; + redrawAll(); + }); + + gridEl.addEventListener('pointermove', (e) => { + if (!ctx) return; + + const { ix, iy, x: snapX, y: snapY, localX, localY } = snapToGrid(e.clientX, e.clientY); + const tool = getActiveTool(); + + coordsEl.classList.remove('d-none'); + + if (getActiveType() !== 'noGrid' && tool !== 'pen') { + dotEl.classList.remove('d-none'); + + const gridRect = gridEl.getBoundingClientRect(); + const wrapRect = gridWrapEl.getBoundingClientRect(); + const offsetX = gridRect.left - wrapRect.left; + const offsetY = gridRect.top - wrapRect.top; + + dotEl.style.left = `${offsetX + snapX}px`; + dotEl.style.top = `${offsetY + snapY}px`; + } else { + dotEl.classList.add('d-none'); + } + + if (getActiveType() == 'noGrid') { + coordsEl.innerText = `(px x=${Math.round(localX)} y=${Math.round(localY)})`; + } else { + coordsEl.innerText = `(x=${ix} (${snapX}px) y=${iy} (${snapY}px))`; + } + + if (!currentShape) return; + + // PEN: mutate points and preview the same shape object + if (currentShape.tool === 'pen') { + penAddPoint(currentShape, e.clientX, e.clientY, 0.02); + renderAllWithPreview(currentShape, true); + return; + } + + // Other tools: build a normalized preview shape + let preview = null; + + if (currentShape.tool === 'line') { + preview = normalizeLine({ + x1: currentShape.x1, + y1: currentShape.y1, + x2: snapX, + y2: snapY, + color: currentShape.color, + strokeWidth: currentShape.strokeWidth, + strokeOpacity: currentShape.strokeOpacity + }); + } else if (currentShape.tool === 'filled' || currentShape.tool === 'outline') { + preview = normalizeRect({ ...currentShape, x2: snapX, y2: snapY }); + } else if (currentShape.tool === 'filledEllipse' || currentShape.tool === 'outlineEllipse') { + preview = normalizeEllipse({ ...currentShape, x2: snapX, y2: snapY }); + } + + renderAllWithPreview(preview, 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('[data-toolbar]')) return; + + e.preventDefault(); + activePointerId = e.pointerId; + try { + gridEl.setPointerCapture(e.pointerId); + } catch { + // ignore: some browsers / scenarios won't allow capture + } + + const { x: snapX, y: snapY } = snapToGrid(e.clientX, e.clientY); + const tool = getActiveTool(); + + if (tool === 'line') { + currentShape = { + tool, + type: 'line', + x1: snapX, + y1: snapY, + x2: snapX, + y2: snapY, + color: selectedColor, + strokeWidth: currentStrokeWidth, + strokeOpacity: currentStrokeOpacity + }; + } else if (tool === 'outline' || tool === 'filled') { + currentShape = { + tool, + x1: snapX, + y1: snapY, + x2: snapX, + y2: snapY, + color: selectedColor, + fill: (tool === 'filled'), + fillOpacity: currentFillOpacity, + strokeOpacity: currentStrokeOpacity, + strokeWidth: currentStrokeWidth + }; + } else if (tool === 'outlineEllipse' || tool === 'filledEllipse') { + currentShape = { + tool, + x1: snapX, + y1: snapY, + x2: snapX, + y2: snapY, + color: selectedColor, + fill: (tool === 'filledEllipse'), + fillOpacity: currentFillOpacity, + strokeOpacity: currentStrokeOpacity, + strokeWidth: currentStrokeWidth + }; + } else if (tool === 'pen') { + const p = pxToDocPoint(e.clientX, e.clientY); + currentShape = { + tool, + type: 'path', + points: [p], + color: selectedColor, + width: currentStrokeWidth, + strokeOpacity: currentStrokeOpacity + }; + } + }); +} \ No newline at end of file diff --git a/inventory/templates/components/draw.html b/inventory/templates/components/draw.html new file mode 100644 index 0000000..8b7af44 --- /dev/null +++ b/inventory/templates/components/draw.html @@ -0,0 +1,177 @@ +{% macro drawWidget(uid) %} +