mini-whiteboard / script.js
ALV1's picture
All broken everything broken worse than before. no drawwing no pasting nothing. all brokern
b9c763a verified
Raw History Blame Contribute Delete
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();
})();