Spaces:
Running
Running
Download script.js from ALV1/mini-whiteboard: direct link, hf CLI and curl.
- Browser
- Download file 37.1 kB
-
https://huggingface.co/spaces/ALV1/mini-whiteboard/resolve/main/script.js
- Command line
-
hf download hf://spaces/ALV1/mini-whiteboard/script.js
-
curl -L -o script.js https://huggingface.co/spaces/ALV1/mini-whiteboard/resolve/main/script.js
37.1 kB
| (() => { | |
| // DOM | |
| const container = document.getElementById('stageContainer'); | |
| const viewportEl = document.getElementById('viewport'); | |
| const worldEl = document.getElementById('world'); | |
| const imgLayer = document.getElementById('imgLayer'); | |
| const shapeLayer = document.getElementById('shapeLayer'); | |
| const drawLayer = document.getElementById('drawLayer'); | |
| const selectionLayer = document.getElementById('selectionLayer'); | |
| const textEditor = document.getElementById('textEditor'); | |
| const toolButtons = { | |
| select: document.getElementById('tool-select'), | |
| pan: document.getElementById('tool-pan'), | |
| brush: document.getElementById('tool-brush'), | |
| eraser: document.getElementById('tool-eraser'), | |
| text: document.getElementById('tool-text'), | |
| rect: document.getElementById('tool-rect'), | |
| circle: document.getElementById('tool-circle'), | |
| line: document.getElementById('tool-line'), | |
| }; | |
| const primaryColorEl = document.getElementById('primaryColor'); | |
| const secondaryColorEl = document.getElementById('secondaryColor'); | |
| const fillColorEl = document.getElementById('fillColor'); | |
| const brushSizeEl = document.getElementById('brushSize'); | |
| const brushSizeLabel = document.getElementById('brushSizeLabel'); | |
| const undoBtn = document.getElementById('undoBtn'); | |
| const redoBtn = document.getElementById('redoBtn'); | |
| const fitBtn = document.getElementById('fitBtn'); | |
| const resetViewBtn = document.getElementById('resetViewBtn'); | |
| const imageInput = document.getElementById('imageInput'); | |
| const pasteBtn = document.getElementById('pasteBtn'); | |
| const clearBtn = document.getElementById('clearBtn'); | |
| // State | |
| const state = { | |
| tool: 'brush', // brush, eraser, pan, text, rect, circle, line, select | |
| strokeColor: primaryColorEl.value, | |
| secondaryColor: secondaryColorEl.value, | |
| fillColor: fillColorEl.value, | |
| brushSize: parseInt(brushSizeEl.value, 10), | |
| panX: 0, panY: 0, | |
| scale: 1, | |
| isDrawing: false, | |
| isPanning: false, | |
| activePath: null, | |
| activeShape: null, | |
| startX: 0, startY: 0, | |
| images: [], // {id, x, y, w, h, el, dataUrl} | |
| shapes: [], // {id, type, x, y, w, h, stroke, fill, size, x1,y1,x2,y2} | |
| strokes: [], // {id, points: [x,y,...], color, size, mode:'draw'|'erase'} | |
| nextId: 1, | |
| selection: { type: null, id: null }, // {type:'image'|'shape'|'stroke', id} | |
| history: [], | |
| historyIndex: -1, | |
| clipDataUrl: null, // last pasted image | |
| }; | |
| // Canvas contexts | |
| const dpr = window.devicePixelRatio || 1; | |
| function setupCanvas(canvas) { | |
| const rect = worldEl.getBoundingClientRect(); | |
| canvas.width = rect.width * dpr; | |
| canvas.height = rect.height * dpr; | |
| canvas.style.width = rect.width + 'px'; | |
| canvas.style.height = rect.height + 'px'; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.scale(dpr, dpr); | |
| return ctx; | |
| } | |
| let imgCtx = setupCanvas(imgLayer); | |
| let shapeCtx = setupCanvas(shapeLayer); | |
| let drawCtx = setupCanvas(drawLayer); | |
| // Utilities | |
| const clamp = (n, min, max) => Math.min(max, Math.max(min, n)); | |
| const dist = (x1, y1, x2, y2) => Math.hypot(x2 - x1, y2 - y1); | |
| const screenToWorld = (clientX, clientY) => { | |
| const rect = container.getBoundingClientRect(); | |
| const x = (clientX - rect.left - state.panX) / state.scale; | |
| const y = (clientY - rect.top - state.panY) / state.scale; | |
| return { x, y }; | |
| }; | |
| const applyTransform = () => { | |
| worldEl.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.scale})`; | |
| }; | |
| const setActiveTool = (tool) => { | |
| state.tool = tool; | |
| for (const [k, btn] of Object.entries(toolButtons)) { | |
| btn.classList.toggle('active', k === tool); | |
| } | |
| container.style.cursor = tool === 'pan' ? 'grab' : (tool === 'brush' || tool === 'eraser') ? 'crosshair' : 'default'; | |
| }; | |
| const pushHistory = () => { | |
| const snapshot = { | |
| images: state.images.map(i => ({...i})), | |
| shapes: state.shapes.map(s => ({...s})), | |
| strokes: state.strokes.map(s => ({...s})), | |
| panX: state.panX, panY: state.panY, scale: state.scale, | |
| }; | |
| // Truncate redo | |
| if (state.historyIndex < state.history.length - 1) { | |
| state.history = state.history.slice(0, state.historyIndex + 1); | |
| } | |
| state.history.push(snapshot); | |
| state.historyIndex++; | |
| updateUndoRedoButtons(); | |
| }; | |
| const restoreSnapshot = (snap) => { | |
| state.images = snap.images.map(i => { | |
| const el = document.createElement('img'); | |
| el.src = i.dataUrl; | |
| el.style.position = 'absolute'; | |
| el.style.left = i.x + 'px'; | |
| el.style.top = i.y + 'px'; | |
| el.style.width = i.w + 'px'; | |
| el.style.height = i.h + 'px'; | |
| el.draggable = false; | |
| el.dataset.id = i.id; | |
| worldEl.appendChild(el); | |
| return { ...i, el }; | |
| }); | |
| state.shapes = [...snap.shapes]; | |
| state.strokes = [...snap.strokes]; | |
| state.panX = snap.panX; state.panY = snap.panY; state.scale = snap.scale; | |
| applyTransform(); | |
| renderAll(); | |
| drawSelection(); | |
| }; | |
| const undo = () => { | |
| if (state.historyIndex <= 0) return; | |
| state.historyIndex--; | |
| const snap = state.history[state.historyIndex]; | |
| clearLayers(); | |
| restoreSnapshot(snap); | |
| updateUndoRedoButtons(); | |
| }; | |
| const redo = () => { | |
| if (state.historyIndex >= state.history.length - 1) return; | |
| state.historyIndex++; | |
| const snap = state.history[state.historyIndex]; | |
| clearLayers(); | |
| restoreSnapshot(snap); | |
| updateUndoRedoButtons(); | |
| }; | |
| const updateUndoRedoButtons = () => { | |
| undoBtn.disabled = state.historyIndex <= 0; | |
| redoBtn.disabled = state.historyIndex >= state.history.length - 1; | |
| }; | |
| // Rendering | |
| const renderAll = () => { | |
| // images | |
| for (const img of state.images) { | |
| if (!img.el.isConnected) { | |
| worldEl.appendChild(img.el); | |
| } | |
| } | |
| // shapes | |
| shapeCtx.clearRect(0, 0, shapeLayer.width / dpr, shapeLayer.height / dpr); | |
| for (const s of state.shapes) { | |
| drawShape(shapeCtx, s); | |
| } | |
| // strokes | |
| drawCtx.clearRect(0, 0, drawLayer.width / dpr, drawLayer.height / dpr); | |
| for (const st of state.strokes) { | |
| drawStroke(drawCtx, st); | |
| } | |
| }; | |
| const drawStroke = (ctx, s) => { | |
| if (s.points.length < 2) return; | |
| ctx.save(); | |
| ctx.lineCap = 'round'; | |
| ctx.lineJoin = 'round'; | |
| ctx.lineWidth = s.size; | |
| if (s.mode === 'erase') { | |
| ctx.globalCompositeOperation = 'destination-out'; | |
| ctx.strokeStyle = 'rgba(0,0,0,1)'; | |
| } else { | |
| ctx.globalCompositeOperation = 'source-over'; | |
| ctx.strokeStyle = s.color; | |
| } | |
| ctx.beginPath(); | |
| ctx.moveTo(s.points[0], s.points[1]); | |
| for (let i = 2; i < s.points.length; i += 2) { | |
| ctx.lineTo(s.points[i], s.points[i + 1]); | |
| } | |
| ctx.stroke(); | |
| ctx.restore(); | |
| }; | |
| const drawShape = (ctx, s) => { | |
| ctx.save(); | |
| ctx.lineWidth = s.size || 2; | |
| ctx.strokeStyle = s.stroke || '#111'; | |
| if (s.fill && s.fill.length === 9) { | |
| // #RRGGBBAA | |
| ctx.fillStyle = s.fill; | |
| } else if (s.fill && s.fill.length === 7) { | |
| ctx.fillStyle = s.fill; | |
| } else { | |
| ctx.fillStyle = 'transparent'; | |
| } | |
| if (s.type === 'rect') { | |
| if (ctx.fillStyle !== 'transparent') { | |
| ctx.fillRect(s.x, s.y, s.w, s.h); | |
| } | |
| ctx.strokeRect(s.x, s.y, s.w, s.h); | |
| } else if (s.type === 'circle') { | |
| const cx = s.x + s.w / 2; | |
| const cy = s.y + s.h / 2; | |
| ctx.beginPath(); | |
| ctx.ellipse(cx, cy, Math.abs(s.w / 2), Math.abs(s.h / 2), 0, 0, Math.PI * 2); | |
| if (ctx.fillStyle !== 'transparent') ctx.fill(); | |
| ctx.stroke(); | |
| } else if (s.type === 'line') { | |
| ctx.beginPath(); | |
| ctx.moveTo(s.x1, s.y1); | |
| ctx.lineTo(s.x2, s.y2); | |
| ctx.stroke(); | |
| } else if (s.type === 'text') { | |
| ctx.fillStyle = s.stroke || '#111'; | |
| ctx.font = `${s.fontWeight || 500} ${s.fontSize || 24}px ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto`; | |
| ctx.textBaseline = 'top'; | |
| wrapText(ctx, s.text || 'Text', s.x, s.y, s.maxWidth || 400, s.lineHeight || 1.2); | |
| } | |
| ctx.restore(); | |
| }; | |
| // Simple text wrapping | |
| function wrapText(ctx, text, x, y, maxWidth, lineHeight) { | |
| const words = text.split(/\s+/); | |
| let line = ''; | |
| let yy = y; | |
| for (let n = 0; n < words.length; n++) { | |
| const testLine = line + words[n] + ' '; | |
| const metrics = ctx.measureText(testLine); | |
| if (metrics.width > maxWidth && n > 0) { | |
| ctx.fillText(line, x, yy); | |
| line = words[n] + ' '; | |
| yy += (parseInt(ctx.font, 10) || 24) * (lineHeight || 1.2); | |
| } else { | |
| line = testLine; | |
| } | |
| } | |
| ctx.fillText(line, x, yy); | |
| } | |
| // Selection | |
| const drawSelection = () => { | |
| selectionLayer.innerHTML = ''; | |
| const { type, id } = state.selection; | |
| if (!type || !id) return; | |
| if (type === 'image') { | |
| const img = state.images.find(i => i.id === id); | |
| if (!img) return; | |
| drawImageSelection(img); | |
| } else if (type === 'shape') { | |
| const s = state.shapes.find(s => s.id === id); | |
| if (!s) return; | |
| drawShapeSelection(s); | |
| } else if (type === 'stroke') { | |
| const st = state.strokes.find(s => s.id === id); | |
| if (!st) return; | |
| const bb = strokeBoundingBox(st); | |
| drawRectSelection(bb.x, bb.y, bb.w, bb.h); | |
| } | |
| }; | |
| const drawImageSelection = (img) => { | |
| const rect = { x: img.x, y: img.y, w: img.w, h: img.h }; | |
| drawRectSelection(rect.x, rect.y, rect.w, rect.h); | |
| const handles = [ | |
| [rect.x, rect.y], | |
| [rect.x + rect.w / 2, rect.y], | |
| [rect.x + rect.w, rect.y], | |
| [rect.x, rect.y + rect.h / 2], | |
| [rect.x + rect.w, rect.y + rect.h / 2], | |
| [rect.x, rect.y + rect.h], | |
| [rect.x + rect.w / 2, rect.y + rect.h], | |
| [rect.x + rect.w, rect.y + rect.h], | |
| ]; | |
| handles.forEach(([hx, hy], i) => { | |
| const h = document.createElement('div'); | |
| h.className = 'handle'; | |
| h.dataset.action = i < 2 ? 'scale-nw' : i === 2 ? 'scale-ne' : i === 3 ? 'scale-w' : i === 4 ? 'scale-e' : i === 5 ? 'scale-sw' : i === 6 ? 'scale-s' : 'scale-se'; | |
| h.style.left = hx - 4 + 'px'; | |
| h.style.top = hy - 4 + 'px'; | |
| selectionLayer.appendChild(h); | |
| }); | |
| }; | |
| const drawShapeSelection = (s) => { | |
| if (s.type === 'rect' || s.type === 'circle') { | |
| const x = s.type === 'rect' ? s.x : Math.min(s.x, s.x + s.w); | |
| const y = s.type === 'rect' ? s.y : Math.min(s.y, s.y + s.h); | |
| const w = Math.abs(s.w); | |
| const h = Math.abs(s.h); | |
| drawRectSelection(x, y, w, h); | |
| } else if (s.type === 'line') { | |
| // endpoints as handles | |
| [ [s.x1, s.y1], [s.x2, s.y2] ].forEach(([hx, hy]) => { | |
| const h = document.createElement('div'); | |
| h.className = 'handle'; | |
| h.dataset.action = 'line-end'; | |
| h.style.left = hx - 4 + 'px'; | |
| h.style.top = hy - 4 + 'px'; | |
| selectionLayer.appendChild(h); | |
| }); | |
| } else if (s.type === 'text') { | |
| const w = Math.min(s.maxWidth || 400, s.w || 400); | |
| const h = (s.h || 100); | |
| drawRectSelection(s.x, s.y, w, h); | |
| } | |
| }; | |
| const drawRectSelection = (x, y, w, h) => { | |
| const box = document.createElement('div'); | |
| box.className = 'absolute border border-primary-500'; | |
| box.style.left = x + 'px'; | |
| box.style.top = y + 'px'; | |
| box.style.width = w + 'px'; | |
| box.style.height = h + 'px'; | |
| box.pointerEvents = 'none'; | |
| selectionLayer.appendChild(box); | |
| }; | |
| const strokeBoundingBox = (st) => { | |
| const xs = []; | |
| const ys = []; | |
| for (let i = 0; i < st.points.length; i += 2) { | |
| xs.push(st.points[i]); ys.push(st.points[i + 1]); | |
| } | |
| const minX = Math.min(...xs), maxX = Math.max(...xs); | |
| const minY = Math.min(...ys), maxY = Math.max(...ys); | |
| return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; | |
| }; | |
| // Hit testing | |
| const hitTest = (x, y) => { | |
| // images topmost first | |
| for (let i = state.images.length - 1; i >= 0; i--) { | |
| const img = state.images[i]; | |
| if (x >= img.x && y >= img.y && x <= img.x + img.w && y <= img.y + img.h) { | |
| return { type: 'image', id: img.id }; | |
| } | |
| } | |
| // shapes | |
| for (let i = state.shapes.length - 1; i >= 0; i--) { | |
| const s = state.shapes[i]; | |
| if (s.type === 'rect') { | |
| if (x >= s.x && y >= s.y && x <= s.x + s.w && y <= s.y + s.h) return { type: 'shape', id: s.id }; | |
| } else if (s.type === 'circle') { | |
| const cx = s.x + s.w / 2; | |
| const cy = s.y + s.h / 2; | |
| const rx = Math.abs(s.w / 2); | |
| const ry = Math.abs(s.h / 2); | |
| if (((x - cx) ** 2) / (rx ** 2) + ((y - cy) ** 2) / (ry ** 2) <= 1) { | |
| return { type: 'shape', id: s.id }; | |
| } | |
| } else if (s.type === 'line') { | |
| // distance to segment | |
| const d = pointToSegmentDistance(x, y, s.x1, s.y1, s.x2, s.y2); | |
| if (d <= Math.max(6, (s.size || 2))) return { type: 'shape', id: s.id }; | |
| } else if (s.type === 'text') { | |
| const w = Math.min(s.maxWidth || 400, s.w || 400); | |
| const h = s.h || 100; | |
| if (x >= s.x && y >= s.y && x <= s.x + w && y <= s.y + h) return { type: 'shape', id: s.id }; | |
| } | |
| } | |
| // strokes | |
| for (let i = state.strokes.length - 1; i >= 0; i--) { | |
| const st = state.strokes[i]; | |
| const bb = strokeBoundingBox(st); | |
| if (x >= bb.x - 6 && y >= bb.y - 6 && x <= bb.x + bb.w + 6 && y <= bb.y + bb.h + 6) { | |
| return { type: 'stroke', id: st.id }; | |
| } | |
| } | |
| return null; | |
| }; | |
| function pointToSegmentDistance(px, py, x1, y1, x2, y2) { | |
| const A = px - x1; | |
| const B = py - y1; | |
| const C = x2 - x1; | |
| const D = y2 - y1; | |
| const dot = A * C + B * D; | |
| const len_sq = C * C + D * D; | |
| let param = -1; | |
| if (len_sq !== 0) param = dot / len_sq; | |
| let xx, yy; | |
| if (param < 0) { xx = x1; yy = y1; } | |
| else if (param > 1) { xx = x2; yy = y2; } | |
| else { xx = x1 + param * C; yy = y1 + param * D; } | |
| const dx = px - xx, dy = py - yy; | |
| return Math.hypot(dx, dy); | |
| } | |
| // Events | |
| Object.entries(toolButtons).forEach(([tool, btn]) => { | |
| btn.addEventListener('click', () => setActiveTool(tool)); | |
| }); | |
| primaryColorEl.addEventListener('input', () => { | |
| state.strokeColor = primaryColorEl.value; | |
| document.documentElement.style.setProperty('--color-primary', state.strokeColor); | |
| }); | |
| secondaryColorEl.addEventListener('input', () => { | |
| state.secondaryColor = secondaryColorEl.value; | |
| document.documentElement.style.setProperty('--color-secondary', state.secondaryColor); | |
| }); | |
| fillColorEl.addEventListener('input', () => { | |
| state.fillColor = fillColorEl.value; | |
| }); | |
| brushSizeEl.addEventListener('input', () => { | |
| state.brushSize = parseInt(brushSizeEl.value, 10); | |
| brushSizeLabel.textContent = String(state.brushSize); | |
| }); | |
| undoBtn.addEventListener('click', undo); | |
| redoBtn.addEventListener('click', redo); | |
| fitBtn.addEventListener('click', fitToContent); | |
| resetViewBtn.addEventListener('click', resetView); | |
| imageInput.addEventListener('change', (e) => { | |
| const file = e.target.files?.[0]; | |
| if (file) addImageFromFile(file); | |
| imageInput.value = ''; | |
| }); | |
| pasteBtn.addEventListener('click', async () => { | |
| try { | |
| const items = await navigator.clipboard.read(); | |
| for (const item of items) { | |
| const type = item.types.find(t => t.startsWith('image/')); | |
| if (type) { | |
| const blob = await item.getType(type); | |
| await addImageFromBlob(blob); | |
| break; | |
| } | |
| } | |
| } catch (err) { | |
| alert('Clipboard read not allowed. Press Ctrl/Cmd+V after copying an image.'); | |
| } | |
| }); | |
| clearBtn.addEventListener('click', () => { | |
| if (!confirm('Clear the board?')) return; | |
| pushHistory(); | |
| state.images.forEach(i => i.el.remove()); | |
| state.images = []; | |
| state.shapes = []; | |
| state.strokes = []; | |
| state.selection = { type: null, id: null }; | |
| renderAll(); | |
| drawSelection(); | |
| }); | |
| function deleteSelection() { | |
| if (!state.selection.type || !state.selection.id) return; | |
| pushHistory(); | |
| if (state.selection.type === 'image') { | |
| const idx = state.images.findIndex(i => i.id === state.selection.id); | |
| if (idx >= 0) { | |
| state.images[idx].el.remove(); | |
| state.images.splice(idx, 1); | |
| } | |
| } else if (state.selection.type === 'shape') { | |
| const idx = state.shapes.findIndex(s => s.id === state.selection.id); | |
| if (idx >= 0) state.shapes.splice(idx, 1); | |
| } else if (state.selection.type === 'stroke') { | |
| const idx = state.strokes.findIndex(s => s.id === state.selection.id); | |
| if (idx >= 0) state.strokes.splice(idx, 1); | |
| } | |
| state.selection = { type: null, id: null }; | |
| renderAll(); | |
| drawSelection(); | |
| } | |
| window.addEventListener('resize', () => { | |
| imgCtx = setupCanvas(imgLayer); | |
| shapeCtx = setupCanvas(shapeLayer); | |
| drawCtx = setupCanvas(drawLayer); | |
| renderAll(); | |
| drawSelection(); | |
| }); | |
| // Pointer events on the stage | |
| container.addEventListener('pointerdown', (e) => { | |
| container.setPointerCapture(e.pointerId); | |
| const pt = screenToWorld(e.clientX, e.clientY); | |
| state.startX = pt.x; state.startY = pt.y; | |
| if (state.tool === 'pan' || (state.tool === 'brush' && e.button === 1)) { | |
| state.isPanning = true; | |
| container.style.cursor = 'grabbing'; | |
| container.dataset.panStartX = String(e.clientX); | |
| container.dataset.panStartY = String(e.clientY); | |
| return; | |
| } | |
| if (state.tool === 'brush') { | |
| state.isDrawing = true; | |
| state.activePath = { id: state.nextId++, points: [pt.x, pt.y], color: state.strokeColor, size: state.brushSize, mode: 'draw' }; | |
| state.strokes.push(state.activePath); | |
| } else if (state.tool === 'eraser') { | |
| state.isDrawing = true; | |
| state.activePath = { id: state.nextId++, points: [pt.x, pt.y], color: '#000', size: state.brushSize, mode: 'erase' }; | |
| state.strokes.push(state.activePath); | |
| } else if (state.tool === 'text') { | |
| startTextEditor(pt.x, pt.y); | |
| } else if (state.tool === 'rect' || state.tool === 'circle' || state.tool === 'line') { | |
| const shape = { | |
| id: state.nextId++, | |
| type: state.tool, | |
| x: pt.x, y: pt.y, | |
| w: 1, h: 1, | |
| x1: pt.x, y1: pt.y, x2: pt.x, y2: pt.y, | |
| stroke: state.strokeColor, | |
| fill: state.fillColor, | |
| size: Math.max(1, state.brushSize / 2), | |
| }; | |
| state.shapes.push(shape); | |
| state.activeShape = shape; | |
| } else if (state.tool === 'select') { | |
| const hit = hitTest(pt.x, pt.y); | |
| if (hit) { | |
| state.selection = hit; | |
| // bring to top | |
| if (hit.type === 'image') { | |
| const idx = state.images.findIndex(i => i.id === hit.id); | |
| const [img] = state.images.splice(idx, 1); | |
| state.images.push(img); | |
| } else if (hit.type === 'shape') { | |
| const idx = state.shapes.findIndex(s => s.id === hit.id); | |
| const [sh] = state.shapes.splice(idx, 1); | |
| state.shapes.push(sh); | |
| } else if (hit.type === 'stroke') { | |
| const idx = state.strokes.findIndex(s => s.id === hit.id); | |
| const [st] = state.strokes.splice(idx, 1); | |
| state.strokes.push(st); | |
| } | |
| drawSelection(); | |
| } else { | |
| state.selection = { type: null, id: null }; | |
| drawSelection(); | |
| } | |
| } | |
| }); | |
| container.addEventListener('pointermove', (e) => { | |
| const pt = screenToWorld(e.clientX, e.clientY); | |
| if (state.isPanning) { | |
| const dx = e.clientX - parseFloat(container.dataset.panStartX || '0'); | |
| const dy = e.clientY - parseFloat(container.dataset.panStartY || '0'); | |
| state.panX += dx; state.panY += dy; | |
| container.dataset.panStartX = String(e.clientX); | |
| container.dataset.panStartY = String(e.clientY); | |
| applyTransform(); | |
| return; | |
| } | |
| if (state.isDrawing && state.activePath) { | |
| const lastX = state.activePath.points[state.activePath.points.length - 2]; | |
| const lastY = state.activePath.points[state.activePath.points.length - 1]; | |
| if (dist(lastX, lastY, pt.x, pt.y) >= 0.5) { | |
| state.activePath.points.push(pt.x, pt.y); | |
| // incremental render: draw last segment | |
| drawCtx.save(); | |
| drawCtx.scale(1, 1); | |
| drawCtx.lineCap = 'round'; | |
| drawCtx.lineJoin = 'round'; | |
| drawCtx.lineWidth = state.activePath.size; | |
| if (state.activePath.mode === 'erase') { | |
| drawCtx.globalCompositeOperation = 'destination-out'; | |
| drawCtx.strokeStyle = 'rgba(0,0,0,1)'; | |
| } else { | |
| drawCtx.globalCompositeOperation = 'source-over'; | |
| drawCtx.strokeStyle = state.activePath.color; | |
| } | |
| drawCtx.beginPath(); | |
| drawCtx.moveTo(lastX, lastY); | |
| drawCtx.lineTo(pt.x, pt.y); | |
| drawCtx.stroke(); | |
| drawCtx.restore(); | |
| } | |
| return; | |
| } | |
| if (state.activeShape) { | |
| if (state.activeShape.type === 'rect') { | |
| state.activeShape.w = pt.x - state.startX; | |
| state.activeShape.h = pt.y - state.startY; | |
| } else if (state.activeShape.type === 'circle') { | |
| state.activeShape.w = pt.x - state.startX; | |
| state.activeShape.h = pt.y - state.startY; | |
| } else if (state.activeShape.type === 'line') { | |
| state.activeShape.x2 = pt.x; | |
| state.activeShape.y2 = pt.y; | |
| } | |
| shapeCtx.clearRect(0, 0, shapeLayer.width / dpr, shapeLayer.height / dpr); | |
| for (const s of state.shapes) drawShape(shapeCtx, s); | |
| drawSelection(); | |
| return; | |
| } | |
| if (state.tool === 'select' && state.selection.type && state.selection.id) { | |
| // show subtle feedback for moving selection if we had drag logic | |
| } | |
| }); | |
| container.addEventListener('pointerup', (e) => { | |
| container.releasePointerCapture(e.pointerId); | |
| if (state.isPanning) { | |
| state.isPanning = false; | |
| container.style.cursor = state.tool === 'pan' ? 'grab' : 'default'; | |
| pushHistory(); | |
| } | |
| if (state.isDrawing) { | |
| state.isDrawing = false; | |
| state.activePath = null; | |
| pushHistory(); | |
| } | |
| if (state.activeShape) { | |
| state.activeShape = null; | |
| pushHistory(); | |
| } | |
| }); | |
| container.addEventListener('wheel', (e) => { | |
| if (!e.ctrlKey && !e.metaKey) { | |
| // scroll to pan | |
| e.preventDefault(); | |
| state.panX -= e.deltaX; | |
| state.panY -= e.deltaY; | |
| applyTransform(); | |
| return; | |
| } | |
| e.preventDefault(); | |
| const scaleFactor = e.deltaY > 0 ? 0.9 : 1.1; | |
| const rect = container.getBoundingClientRect(); | |
| const mx = e.clientX - rect.left; | |
| const my = e.clientY - rect.top; | |
| const wx = (mx - state.panX) / state.scale; | |
| const wy = (my - state.panY) / state.scale; | |
| state.scale = clamp(state.scale * scaleFactor, 0.1, 6); | |
| state.panX = mx - wx * state.scale; | |
| state.panY = my - wy * state.scale; | |
| applyTransform(); | |
| }, { passive: false }); | |
| // Text editor | |
| function startTextEditor(x, y) { | |
| textEditor.style.left = `${x}px`; | |
| textEditor.style.top = `${y}px`; | |
| textEditor.classList.remove('hidden'); | |
| textEditor.innerHTML = ''; | |
| textEditor.focus(); | |
| const commit = () => { | |
| const text = textEditor.innerText.replace(/\n/g, '<br>'); | |
| textEditor.classList.add('hidden'); | |
| const s = { | |
| id: state.nextId++, | |
| type: 'text', | |
| x, y, w: 400, h: 100, | |
| stroke: state.strokeColor, | |
| fill: 'transparent', | |
| size: 2, | |
| text, | |
| fontSize: 18, | |
| lineHeight: 1.2, | |
| }; | |
| state.shapes.push(s); | |
| shapeCtx.clearRect(0, 0, shapeLayer.width / dpr, shapeLayer.height / dpr); | |
| for (const sh of state.shapes) drawShape(shapeCtx, sh); | |
| pushHistory(); | |
| }; | |
| const cancel = () => { | |
| textEditor.classList.add('hidden'); | |
| }; | |
| const onBlur = () => { | |
| textEditor.removeEventListener('blur', onBlur); | |
| if (textEditor.innerText.trim().length > 0) { | |
| commit(); | |
| } else { | |
| cancel(); | |
| } | |
| }; | |
| textEditor.addEventListener('blur', onBlur); | |
| textEditor.addEventListener('keydown', (e) => { | |
| if ((e.key === 'Enter' && (e.metaKey || e.ctrlKey)) || e.key === 'Escape') { | |
| e.preventDefault(); | |
| textEditor.blur(); | |
| } | |
| }); | |
| } | |
| // Selection manipulation (move/scale) | |
| let dragState = null; | |
| selectionLayer.addEventListener('pointerdown', (e) => { | |
| if (state.tool !== 'select' || !state.selection.type || !state.selection.id) return; | |
| const target = e.target; | |
| const pt = screenToWorld(e.clientX, e.clientY); | |
| const action = target.dataset.action || 'move'; | |
| dragState = { | |
| action, | |
| startX: pt.x, startY: pt.y, | |
| origin: JSON.parse(JSON.stringify(state.selection)), | |
| }; | |
| selectionLayer.setPointerCapture(e.pointerId); | |
| }); | |
| selectionLayer.addEventListener('pointermove', (e) => { | |
| if (!dragState) return; | |
| const pt = screenToWorld(e.clientX, e.clientY); | |
| const dx = pt.x - dragState.startX; | |
| const dy = pt.y - dragState.startY; | |
| if (dragState.origin.type === 'image') { | |
| const img = state.images.find(i => i.id === dragState.origin.id); | |
| if (!img) return; | |
| if (dragState.action === 'move') { | |
| img.x += dx; img.y += dy; | |
| img.el.style.left = img.x + 'px'; | |
| img.el.style.top = img.y + 'px'; | |
| } else { | |
| // scale by handle | |
| let { x, y, w, h } = img; | |
| const aspect = img.el.naturalWidth / img.el.naturalHeight || (img.w / img.h) || 1; | |
| if (dragState.action.includes('scale')) { | |
| if (dragState.action === 'scale-nw') { x += dx; y += dy; w -= dx; h -= dy; } | |
| if (dragState.action === 'scale-ne') { y += dy; w += dx; h -= dy; } | |
| if (dragState.action === 'scale-sw') { x += dx; w -= dx; h += dy; } | |
| if (dragState.action === 'scale-se') { w += dx; h += dy; } | |
| if (dragState.action === 'scale-n') { y += dy; h -= dy; } | |
| if (dragState.action === 'scale-s') { h += dy; } | |
| if (dragState.action === 'scale-w') { x += dx; w -= dx; } | |
| if (dragState.action === 'scale-e') { w += dx; } | |
| // lock aspect with Shift | |
| if (e.shiftKey) { | |
| if (Math.abs(w) / Math.abs(h) > aspect) { | |
| // adjust height | |
| const sign = Math.sign(w) || 1; | |
| h = sign * Math.abs(w) / aspect; | |
| } else { | |
| const sign = Math.sign(h) || 1; | |
| w = sign * Math.abs(h) * aspect; | |
| } | |
| } | |
| img.x = x; img.y = y; img.w = w; img.h = h; | |
| img.el.style.left = img.x + 'px'; | |
| img.el.style.top = img.y + 'px'; | |
| img.el.style.width = img.w + 'px'; | |
| img.el.style.height = img.h + 'px'; | |
| } | |
| } | |
| drawSelection(); | |
| } else if (dragState.origin.type === 'shape') { | |
| const s = state.shapes.find(sh => sh.id === dragState.origin.id); | |
| if (!s) return; | |
| if (s.type === 'rect' || s.type === 'circle') { | |
| if (dragState.action === 'move') { | |
| s.x += dx; s.y += dy; | |
| } else if (dragState.action.startsWith('scale')) { | |
| let { x, y, w, h } = s; | |
| if (dragState.action === 'scale-nw') { x += dx; y += dy; w -= dx; h -= dy; } | |
| if (dragState.action === 'scale-ne') { y += dy; w += dx; h -= dy; } | |
| if (dragState.action === 'scale-sw') { x += dx; w -= dx; h += dy; } | |
| if (dragState.action === 'scale-se') { w += dx; h += dy; } | |
| if (dragState.action === 'scale-n') { y += dy; h -= dy; } | |
| if (dragState.action === 'scale-s') { h += dy; } | |
| if (dragState.action === 'scale-w') { x += dx; w -= dx; } | |
| if (dragState.action === 'scale-e') { w += dx; } | |
| s.x = x; s.y = y; s.w = w; s.h = h; | |
| } | |
| } else if (s.type === 'line') { | |
| if (dragState.action === 'move') { | |
| s.x1 += dx; s.y1 += dy; s.x2 += dx; s.y2 += dy; | |
| } else if (dragState.action === 'line-end') { | |
| // find which end is closer to pointer | |
| const d1 = dist(pt.x, pt.y, s.x1, s.y1); | |
| const d2 = dist(pt.x, pt.y, s.x2, s.y2); | |
| if (d1 < d2) { s.x1 += dx; s.y1 += dy; } else { s.x2 += dx; s.y2 += dy; } | |
| } | |
| } else if (s.type === 'text') { | |
| s.x += dx; s.y += dy; | |
| } | |
| shapeCtx.clearRect(0, 0, shapeLayer.width / dpr, shapeLayer.height / dpr); | |
| for (const sh of state.shapes) drawShape(shapeCtx, sh); | |
| drawSelection(); | |
| } else if (dragState.origin.type === 'stroke') { | |
| const st = state.strokes.find(ss => ss.id === dragState.origin.id); | |
| if (!st) return; | |
| for (let i = 0; i < st.points.length; i += 2) { | |
| st.points[i] += dx; | |
| st.points[i + 1] += dy; | |
| } | |
| drawCtx.clearRect(0, 0, drawLayer.width / dpr, drawLayer.height / dpr); | |
| for (const s of state.strokes) drawStroke(drawCtx, s); | |
| drawSelection(); | |
| } | |
| }); | |
| selectionLayer.addEventListener('pointerup', (e) => { | |
| if (dragState) { | |
| pushHistory(); | |
| dragState = null; | |
| selectionLayer.releasePointerCapture(e.pointerId); | |
| } | |
| }); | |
| // Keyboard shortcuts | |
| window.addEventListener('keydown', (e) => { | |
| const key = e.key.toLowerCase(); | |
| if (e.metaKey || e.ctrlKey) { | |
| if (key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); } | |
| else if ((key === 'z' && e.shiftKey) || (key === 'y')) { e.preventDefault(); redo(); } | |
| return; | |
| } | |
| if (key === 'b') setActiveTool('brush'); | |
| if (key === 'e') setActiveTool('eraser'); | |
| if (key === 'v') setActiveTool('select'); | |
| if (key === 'h') setActiveTool('pan'); | |
| if (key === 't') setActiveTool('text'); | |
| if (key === 'r') setActiveTool('rect'); | |
| if (key === 'o') setActiveTool('circle'); | |
| if (key === 'l') setActiveTool('line'); | |
| if (key === 'f') fitToContent(); | |
| if (key === '0') resetView(); | |
| if ((e.metaKey || e.ctrlKey) && key === 'c') copySelectionToClipboard(); | |
| if ((e.metaKey || e.ctrlKey) && key === 'v') { | |
| // paste handled below globally, but prevent browser focus scroll | |
| e.preventDefault(); | |
| } | |
| if (key === 'delete' || key === 'backspace') { | |
| deleteSelection(); | |
| } | |
| }); | |
| // Paste images | |
| window.addEventListener('paste', async (e) => { | |
| if (e.clipboardData) { | |
| const items = e.clipboardData.items; | |
| for (const it of items) { | |
| if (it.type.startsWith('image/')) { | |
| e.preventDefault(); | |
| const blob = it.getAsFile(); | |
| if (blob) await addImageFromBlob(blob); | |
| return; | |
| } | |
| } | |
| } | |
| }); | |
| // Drag & drop images | |
| container.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| }); | |
| container.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| const file = e.dataTransfer.files?.[0]; | |
| if (file) addImageFromFile(file); | |
| }); | |
| // Fit & reset | |
| function fitToContent() { | |
| const allBounds = []; | |
| for (const i of state.images) { | |
| allBounds.push({ x: i.x, y: i.y, w: i.w, h: i.h }); | |
| } | |
| for (const s of state.shapes) { | |
| if (s.type === 'rect' || s.type === 'circle' || s.type === 'text') { | |
| const x = s.type === 'rect' ? s.x : Math.min(s.x, s.x + s.w); | |
| const y = s.type === 'rect' ? s.y : Math.min(s.y, s.y + s.h); | |
| const w = Math.abs(s.w); | |
| const h = Math.abs(s.h); | |
| allBounds.push({ x, y, w, h }); | |
| } else if (s.type === 'line') { | |
| allBounds.push({ x: Math.min(s.x1, s.x2), y: Math.min(s.y1, s.y2), w: Math.abs(s.x2 - s.x1), h: Math.abs(s.y2 - s.y1) }); | |
| } | |
| } | |
| for (const st of state.strokes) { | |
| const bb = strokeBoundingBox(st); | |
| allBounds.push(bb); | |
| } | |
| if (allBounds.length === 0) { resetView(); return; } | |
| const minX = Math.min(...allBounds.map(b => b.x)); | |
| const minY = Math.min(...allBounds.map(b => b.y)); | |
| const maxX = Math.max(...allBounds.map(b => b.x + b.w)); | |
| const maxY = Math.max(...allBounds.map(b => b.y + b.h)); | |
| const padding = 80; | |
| const width = maxX - minX + padding * 2; | |
| const height = maxY - minY + padding * 2; | |
| const rect = container.getBoundingClientRect(); | |
| const scaleX = rect.width / width; | |
| const scaleY = rect.height / height; | |
| const scale = clamp(Math.min(scaleX, scaleY), 0.1, 6); | |
| state.scale = scale; | |
| state.panX = -minX * scale + padding * scale + (rect.width - (maxX - minX + padding * 2) * scale) / 2; | |
| state.panY = -minY * scale + padding * scale + (rect.height - (maxY - minY + padding * 2) * scale) / 2; | |
| applyTransform(); | |
| } | |
| function resetView() { | |
| state.scale = 1; | |
| state.panX = 0; | |
| state.panY = 0; | |
| applyTransform(); | |
| } | |
| async function addImageFromFile(file) { | |
| const reader = new FileReader(); | |
| reader.onload = async (e) => { | |
| const dataUrl = e.target.result; | |
| await addImage(dataUrl); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| async function addImageFromBlob(blob) { | |
| return new Promise((resolve) => { | |
| const reader = new FileReader(); | |
| reader.onload = async (e) => { | |
| const dataUrl = e.target.result; | |
| await addImage(dataUrl); | |
| resolve(); | |
| }; | |
| reader.readAsDataURL(blob); | |
| }); | |
| } | |
| async function addImage(dataUrl) { | |
| const img = new Image(); | |
| img.onload = () => { | |
| const scale = Math.min(400 / img.width, 300 / img.height, 1); | |
| const width = img.width * scale; | |
| const height = img.height * scale; | |
| const x = (4096 - width) / 2; | |
| const y = (3072 - height) / 2; | |
| const imageObj = { | |
| id: state.nextId++, | |
| x, y, w: width, h: height, | |
| dataUrl, | |
| el: null, | |
| }; | |
| const el = document.createElement('img'); | |
| el.src = dataUrl; | |
| el.style.position = 'absolute'; | |
| el.style.left = x + 'px'; | |
| el.style.top = y + 'px'; | |
| el.style.width = width + 'px'; | |
| el.style.height = height + 'px'; | |
| el.draggable = false; | |
| el.dataset.id = imageObj.id; | |
| imageObj.el = el; | |
| state.images.push(imageObj); | |
| worldEl.appendChild(el); | |
| pushHistory(); | |
| }; | |
| img.src = dataUrl; | |
| } | |
| function clearLayers() { | |
| imgCtx.clearRect(0, 0, imgLayer.width / dpr, imgLayer.height / dpr); | |
| shapeCtx.clearRect(0, 0, shapeLayer.width / dpr, shapeLayer.height / dpr); | |
| drawCtx.clearRect(0, 0, drawLayer.width / dpr, drawLayer.height / dpr); | |
| selectionLayer.innerHTML = ''; | |
| } | |
| function copySelectionToClipboard() { | |
| if (!state.selection.type || !state.selection.id) return; | |
| // Create a temporary canvas to export the selection | |
| const exportCanvas = document.createElement('canvas'); | |
| const exportCtx = exportCanvas.getContext('2d'); | |
| let bounds = { x: 0, y: 0, w: 100, h: 100 }; | |
| if (state.selection.type === 'image') { | |
| const img = state.images.find(i => i.id === state.selection.id); | |
| if (img) { | |
| bounds = { x: img.x, y: img.y, w: img.w, h: img.h }; | |
| } | |
| } else if (state.selection.type === 'shape') { | |
| const s = state.shapes.find(sh => sh.id === state.selection.id); | |
| if (s) { | |
| if (s.type === 'rect' || s.type === 'circle') { | |
| bounds = { | |
| x: s.type === 'rect' ? s.x : Math.min(s.x, s.x + s.w), | |
| y: s.type === 'rect' ? s.y : Math.min(s.y, s.y + s.h), | |
| w: Math.abs(s.w), h: Math.abs(s.h) | |
| }; | |
| } else if (s.type === 'line') { | |
| bounds = { | |
| x: Math.min(s.x1, s.x2), | |
| y: Math.min(s.y1, s.y2), | |
| w: Math.abs(s.x2 - s.x1), | |
| h: Math.abs(s.y2 - s.y1) | |
| }; | |
| } else if (s.type === 'text') { | |
| bounds = { x: s.x, y: s.y, w: s.w || 400, h: s.h || 100 }; | |
| } | |
| } | |
| } else if (state.selection.type === 'stroke') { | |
| const st = state.strokes.find(ss => ss.id === state.selection.id); | |
| if (st) { | |
| bounds = strokeBoundingBox(st); | |
| } | |
| } | |
| exportCanvas.width = bounds.w; | |
| exportCanvas.height = bounds.h; | |
| exportCtx.fillStyle = '#ffffff'; | |
| exportCtx.fillRect(0, 0, bounds.w, bounds.h); | |
| // Draw the selection onto the export canvas | |
| if (state.selection.type === 'image') { | |
| const img = state.images.find(i => i.id === state.selection.id); | |
| if (img && img.el) { | |
| exportCtx.drawImage(img.el, bounds.x - img.x, bounds.y - img.y); | |
| } | |
| } else if (state.selection.type === 'shape') { | |
| const s = state.shapes.find(sh => sh.id === state.selection.id); | |
| if (s) { | |
| exportCtx.save(); | |
| exportCtx.translate(-bounds.x, -bounds.y); | |
| drawShape(exportCtx, s); | |
| exportCtx.restore(); | |
| } | |
| } else if (state.selection.type === 'stroke') { | |
| const st = state.strokes.find(ss => ss.id === state.selection.id); | |
| if (st) { | |
| exportCtx.save(); | |
| exportCtx.translate(-bounds.x, -bounds.y); | |
| drawStroke(exportCtx, st); | |
| exportCtx.restore(); | |
| } | |
| } | |
| exportCanvas.toBlob((blob) => { | |
| if (blob) { | |
| navigator.clipboard.write([ | |
| new ClipboardItem({ 'image/png': blob }) | |
| ]).then(() => { | |
| console.log('Selection copied to clipboard'); | |
| }).catch(err => { | |
| console.warn('Failed to copy to clipboard:', err); | |
| }); | |
| } | |
| }, 'image/png'); | |
| } | |
| // Initialize | |
| applyTransform(); | |
| updateUndoRedoButtons(); | |
| })(); | |