(() => { // 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, '
'); 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(); })();