Whiteboard
Qwen 3.6 27b MTP · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 56,770 bytes · SHA-256 38b8b2cae7d3
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Whiteboard</title>
<style>
/* ============================================================
CSS — App chrome, toolbar, properties bar, canvas overlay
============================================================ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
overflow: hidden;
background: #f8f9fa;
color: #343a40;
user-select: none;
-webkit-user-select: none;
}
#app {
width: 100vw;
height: 100vh;
position: relative;
overflow: hidden;
}
canvas {
display: block;
cursor: crosshair;
}
/* ---------- Toolbar ---------- */
#toolbar {
position: absolute;
top: 14px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 2px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 16px rgba(0,0,0,.1), 0 0 0 1px rgba(0,0,0,.05);
padding: 4px;
z-index: 100;
}
.tool-btn {
width: 40px;
height: 40px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #6c757d;
font-size: 16px;
transition: background .12s, color .12s, box-shadow .12s;
position: relative;
}
.tool-btn:hover { background: #f0f2f5; }
.tool-btn.active { background: #e8edff; color: #4a6cf7; font-weight: 600; }
.tool-btn.active::after {
content: '';
position: absolute;
bottom: 3px;
left: 50%;
transform: translateX(-50%);
width: 18px;
height: 2.5px;
background: #4a6cf7;
border-radius: 2px;
}
.tool-btn svg { width: 20px; height: 20px; pointer-events: none; }
.tool-btn .shortcut {
position: absolute;
bottom: -1px;
right: 4px;
font-size: 8px;
color: #adb5bd;
pointer-events: none;
line-height: 1;
}
/* ---------- Properties Bar ---------- */
#properties-bar {
position: absolute;
top: 62px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 14px;
align-items: center;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 16px rgba(0,0,0,.1), 0 0 0 1px rgba(0,0,0,.05);
padding: 8px 16px;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity .15s ease;
}
#properties-bar.visible {
opacity: 1;
pointer-events: auto;
}
.prop-group {
display: flex;
align-items: center;
gap: 7px;
}
.prop-label {
font-size: 10px;
color: #adb5bd;
text-transform: uppercase;
letter-spacing: .6px;
font-weight: 600;
}
.color-swatch-btn {
width: 26px;
height: 26px;
border-radius: 7px;
border: 2.5px solid #e9ecef;
cursor: pointer;
transition: border-color .1s, transform .1s;
}
.color-swatch-btn:hover { transform: scale(1.08); }
.color-swatch-btn.active { border-color: #4a6cf7; }
#stroke-width-slider {
width: 72px;
accent-color: #4a6cf7;
cursor: pointer;
}
/* ---------- Zoom Indicator ---------- */
#zoom-indicator {
position: absolute;
bottom: 14px;
right: 14px;
display: flex;
align-items: center;
gap: 6px;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0,0,0,.08), 0 0 0 1px rgba(0,0,0,.05);
padding: 6px 4px;
z-index: 100;
font-size: 13px;
color: #6c757d;
}
#zoom-indicator button {
width: 30px;
height: 30px;
border: none;
background: transparent;
border-radius: 7px;
cursor: pointer;
font-size: 16px;
color: #6c757d;
display: flex;
align-items: center;
justify-content: center;
transition: background .1s;
}
#zoom-indicator button:hover { background: #f0f2f5; }
/* ---------- Undo/Redo buttons ---------- */
#history-btns {
position: absolute;
top: 14px;
right: 18px;
display: flex;
gap: 4px;
z-index: 100;
}
.history-btn {
width: 36px;
height: 36px;
border: none;
background: #fff;
border-radius: 9px;
box-shadow: 0 2px 10px rgba(0,0,0,.08), 0 0 0 1px rgba(0,0,0,.05);
cursor: pointer;
font-size: 17px;
color: #6c757d;
display: flex;
align-items: center;
justify-content: center;
transition: background .1s;
}
.history-btn:hover { background: #f0f2f5; }
.history-btn.disabled { opacity: .3; pointer-events: none; }
/* ---------- Text input overlay ---------- */
#text-input-overlay {
position: absolute;
z-index: 200;
display: none;
}
#text-input-overlay textarea {
border: 2px solid #4a6cf7;
border-radius: 5px;
padding: 5px 9px;
font-family: inherit;
outline: none;
resize: none;
min-width: 120px;
background: rgba(255,255,255,.97);
box-shadow: 0 4px 16px rgba(0,0,0,.12);
}
/* ---------- Color picker popup ---------- */
.color-picker-popup {
position: absolute;
top: 38px;
left: -10px;
background: #fff;
border-radius: 10px;
box-shadow: 0 6px 24px rgba(0,0,0,.15);
padding: 10px;
display: none;
z-index: 300;
}
.color-picker-popup.visible { display: block; }
.color-grid {
display: grid;
grid-template-columns: repeat(6, 26px);
gap: 5px;
}
.color-grid .color-swatch { width: 26px; height: 26px; border-radius: 6px; cursor: pointer; transition: transform .1s; }
.color-grid .color-swatch:hover { transform: scale(1.15); }
/* ---------- Help hint ---------- */
#help-hint {
position: absolute;
bottom: 14px;
left: 14px;
font-size: 11px;
color: #adb5bd;
z-index: 100;
pointer-events: none;
}
</style>
</head>
<body>
<div id="app">
<canvas id="canvas"></canvas>
<!-- Toolbar -->
<div id="toolbar">
<button class="tool-btn active" data-tool="select" title="Select (V)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/><path d="M13 13l6 6"/></svg>
<span class="shortcut">V</span>
</button>
<button class="tool-btn" data-tool="freehand" title="Freehand (P)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/></svg>
<span class="shortcut">P</span>
</button>
<button class="tool-btn" data-tool="rectangle" title="Rectangle (R)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
<span class="shortcut">R</span>
</button>
<button class="tool-btn" data-tool="ellipse" title="Ellipse (O)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="10" ry="8"/></svg>
<span class="shortcut">O</span>
</button>
<button class="tool-btn" data-tool="line" title="Line/Arrow (L)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="19" x2="19" y2="5"/><polyline points="10,5 19,5 19,14"/></svg>
<span class="shortcut">L</span>
</button>
<button class="tool-btn" data-tool="text" title="Text (T)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4,7 4,4 20,4 20,7"/><line x1="9.5" y1="4" x2="9.5" y2="20"/><line x1="14.5" y1="4" x2="14.5" y2="20"/><line x1="7" y1="20" x2="17" y2="20"/></svg>
<span class="shortcut">T</span>
</button>
<button class="tool-btn" data-tool="eraser" title="Eraser (E)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 20H7L3 16l9-9 8 8-4 5z"/><line x1="6" y1="20" x2="20" y2="20"/></svg>
<span class="shortcut">E</span>
</button>
</div>
<!-- Properties Bar -->
<div id="properties-bar">
<div class="prop-group">
<span class="prop-label">Stroke</span>
<div style="position:relative">
<div class="color-swatch-btn" id="stroke-color-btn" onclick="toggleColorPicker('stroke')"></div>
<div class="color-picker-popup" id="stroke-color-picker"></div>
</div>
</div>
<div class="prop-group">
<span class="prop-label">Fill</span>
<div style="position:relative">
<div class="color-swatch-btn" id="fill-color-btn" onclick="toggleColorPicker('fill')"></div>
<div class="color-picker-popup" id="fill-color-picker"></div>
</div>
</div>
<div class="prop-group">
<span class="prop-label">Width</span>
<input type="range" id="stroke-width-slider" min="1" max="20" value="2">
<span id="stroke-width-val" style="font-size:12px;min-width:18px;color:#6c757d;">2</span>
</div>
</div>
<!-- History buttons -->
<div id="history-btns">
<button class="history-btn disabled" id="undo-btn" title="Undo (Ctrl+Z)">↩</button>
<button class="history-btn disabled" id="redo-btn" title="Redo (Ctrl+Shift+Z)">↪</button>
</div>
<!-- Zoom indicator -->
<div id="zoom-indicator">
<button onclick="zoomBy(-1)" title="Zoom out">−</button>
<span id="zoom-level" style="min-width:48px;text-align:center;">100%</span>
<button onclick="zoomBy(1)" title="Zoom in">+</button>
<div style="width:1px;height:20px;background:#e9ecef;margin:0 2px;"></div>
<button onclick="resetView()" title="Reset view" style="font-size:11px;width:auto;padding:0 10px;font-weight:600;">Fit</button>
</div>
<!-- Text input overlay -->
<div id="text-input-overlay">
<textarea id="text-area" rows="1"></textarea>
</div>
<!-- Help hint -->
<div id="help-hint">Space+drag to pan · Scroll to zoom</div>
</div>
<script>
/* ============================================================
CONSTANTS — Tunable parameters
============================================================ */
const C = {
// Colors
PALETTE: [
'#343a40', '#e63946', '#f77f00', '#fcbf49', '#06d6a0', '#118ab2',
'#073b4c', '#ef476f', '#ffd166', '#06d6a0', '#118ab2', '#8338ec',
],
STROKE_DEFAULT: '#343a40',
FILL_DEFAULT: 'transparent',
STROKE_WIDTH_DEFAULT: 2,
// Canvas
BG_COLOR: '#f8f9fa',
GRID_COLOR: '#dee2e6',
GRID_SPACING: 24,
// Zoom
ZOOM_MIN: 0.15,
ZOOM_MAX: 5,
ZOOM_STEP: 1.12,
// Selection
HANDLE_SIZE: 8, // screen pixels for handles
HIT_TOLERANCE: 6, // px for stroke hit-testing (screen)
RUBBER_BAND_BORDER: '#4a6cf7',
// Eraser
ERASER_HIT_RADIUS: 12,
// Persistence
STORAGE_KEY: 'whiteboard_scene_v3',
};
/* ============================================================
STATE — Scene, viewport, tools, history
============================================================ */
let scene = []; // Array of element objects
let selectedIds = new Set();
let currentTool = 'select';
let nextId = 1;
// Viewport transform: world → screen via (x * zoom + panX, y * zoom + panY)
let view = { x: 0, y: 0, zoom: 1 };
// Current style defaults (for new objects) and selection overrides
let currentStyle = {
strokeColor: C.STROKE_DEFAULT,
fillColor: C.FILL_DEFAULT,
strokeWidth: C.STROKE_WIDTH_DEFAULT,
};
// Interaction state — set during mouse drag operations
let interaction = null;
// History stacks
let undoStack = [];
let redoStack = [];
// Canvas refs
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
/* ============================================================
UTILITY FUNCTIONS
============================================================ */
function genId() { return nextId++; }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
// Screen → world coordinates
function screenToWorld(sx, sy) {
return { x: (sx - view.x) / view.zoom, y: (sy - view.y) / view.zoom };
}
// World → screen coordinates
function worldToScreen(wx, wy) {
return { x: wx * view.zoom + view.x, y: wy * view.zoom + view.y };
}
function resizeCanvas() {
const dpr = window.devicePixelRatio || 1;
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
canvas.style.width = window.innerWidth + 'px';
canvas.style.height = window.innerHeight + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
/* ============================================================
ELEMENT MODEL — creation helpers
============================================================ */
function createRect(x, y, w, h) {
return { id: genId(), type: 'rectangle', x, y, width: Math.abs(w), height: Math.abs(h), strokeColor: currentStyle.strokeColor, fillColor: currentStyle.fillColor, strokeWidth: currentStyle.strokeWidth };
}
function createEllipse(cx, cy, rx, ry) {
return { id: genId(), type: 'ellipse', cx, cy, rx: Math.max(0.1, Math.abs(rx)), ry: Math.max(0.1, Math.abs(ry)), strokeColor: currentStyle.strokeColor, fillColor: currentStyle.fillColor, strokeWidth: currentStyle.strokeWidth };
}
function createLine(x1, y1, x2, y2) {
return { id: genId(), type: 'line', x1, y1, x2, y2, strokeColor: currentStyle.strokeColor, strokeWidth: currentStyle.strokeWidth, arrow: true };
}
function createFreehand(points) {
return { id: genId(), type: 'freehand', points: points.map(p => ({...p})), strokeColor: currentStyle.strokeColor, strokeWidth: currentStyle.strokeWidth };
}
function createText(x, y, text) {
return { id: genId(), type: 'text', x, y, text: text || '', fontSize: 18, color: currentStyle.strokeColor };
}
/* ============================================================
HIT TESTING — all in world coordinates
============================================================ */
function hitTestElement(el, wx, wy) {
const tol = C.HIT_TOLERANCE / view.zoom; // scale tolerance with zoom for pixel-accurate selection
switch (el.type) {
case 'rectangle': return hitRect(el.x, el.y, el.width, el.height, wx, wy, tol);
case 'ellipse': return hitEllipse(el.cx, el.cy, el.rx, el.ry, wx, wy, tol);
case 'line': return hitLine(el.x1, el.y1, el.x2, el.y2, wx, wy, tol);
case 'freehand': return hitFreehand(el.points, wx, wy, tol, el.strokeWidth || C.STROKE_WIDTH_DEFAULT);
case 'text': {
const tw = measureTextWidth(el.text || 'A', el.fontSize);
const th = (el.fontSize || 18) * 1.3;
return hitRect(el.x - 4/view.zoom, el.y - th - 2/view.zoom, tw + 8/view.zoom, th + 4/view.zoom, wx, wy, tol);
}
}
return false;
}
function hitRect(x, y, w, h, px, py, tol) {
const minX = Math.min(x, x + w), maxX = Math.max(x, x + w);
const minY = Math.min(y, y + h), maxY = Math.max(y, y + h);
// Inside test
if (px >= minX && px <= maxX && py >= minY && py <= maxY) return true;
// Near border test
const nearX = px >= minX - tol && px <= maxX + tol;
const nearY = py >= minY - tol && py <= maxY + tol;
if (!nearX || !nearY) return false;
// Check proximity to edges
const dLeft = Math.abs(px - minX), dRight = Math.abs(px - maxX);
const dTop = Math.abs(py - minY), dBottom = Math.abs(py - maxY);
if (px >= minX && px <= maxX && (dTop <= tol || dBottom <= tol)) return true;
if (py >= minY && py <= maxY && (dLeft <= tol || dRight <= tol)) return true;
// Corner proximity
const corners = [[minX,minY],[maxX,minY],[minX,maxY],[maxX,maxY]];
for (const [cx,cy] of corners) {
if (Math.hypot(px - cx, py - cy) <= tol * 1.5) return true;
}
return false;
}
function hitEllipse(cx, cy, rx, ry, px, py, tol) {
// Proper ellipse equation: ((x-cx)/rx)^2 + ((y-cy)/ry)^2 <= 1
if (rx < tol && ry < tol) return Math.hypot(px - cx, py - cy) <= tol;
const dx = (px - cx) / (rx || tol);
const dy = (py - cy) / (ry || tol);
const v = dx * dx + dy * dy;
if (v <= 1.0) return true; // inside ellipse
// Near border? Check distance to ellipse boundary
if (v > 1.0 && v < 2.5) {
const angle = Math.atan2(dy, dx);
const ex = cx + rx * Math.cos(angle);
const ey = cy + ry * Math.sin(angle);
return Math.hypot(px - ex, py - ey) <= tol;
}
return false;
}
function hitLine(x1, y1, x2, y2, px, py, tol) {
return distPointToSegment(px, py, x1, y1, x2, y2) <= tol + (tol * 0.5);
}
function hitFreehand(points, px, py, tol, sw) {
const effectiveTol = tol + (sw || C.STROKE_WIDTH_DEFAULT) / view.zoom;
for (let i = 0; i < points.length - 1; i++) {
if (distPointToSegment(px, py, points[i].x, points[i].y, points[i+1].x, points[i+1].y) <= effectiveTol) return true;
}
// Also check last point
if (points.length > 0 && Math.hypot(px - points[points.length-1].x, py - points[points.length-1].y) <= effectiveTol) return true;
return false;
}
function distPointToSegment(px, py, x1, y1, x2, y2) {
const dx = x2 - x1, dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(px - x1, py - y1);
let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
t = clamp(t, 0, 1);
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
}
function measureTextWidth(text, fontSize) {
ctx.font = `${fontSize || 18}px -apple-system, BlinkMacSystemFont, sans-serif`;
return ctx.measureText(text || 'A').width;
}
/* ============================================================
SELECTION HELPERS
============================================================ */
function getSelection() {
return scene.filter(el => selectedIds.has(el.id));
}
function getSelectionBounds() {
const sel = getSelection();
if (!sel.length) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const el of sel) {
const b = getElementBounds(el);
minX = Math.min(minX, b.x);
minY = Math.min(minY, b.y);
maxX = Math.max(maxX, b.x + b.w);
maxY = Math.max(maxY, b.y + b.h);
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
function getElementBounds(el) {
switch (el.type) {
case 'rectangle':
return { x: Math.min(el.x, el.x + el.width), y: Math.min(el.y, el.y + el.height), w: Math.abs(el.width), h: Math.abs(el.height) };
case 'ellipse':
return { x: el.cx - el.rx, y: el.cy - el.ry, w: el.rx * 2, h: el.ry * 2 };
case 'line':
return { x: Math.min(el.x1, el.x2), y: Math.min(el.y1, el.y2), w: Math.abs(el.x2 - el.x1) || 0.01, h: Math.abs(el.y2 - el.y1) || 0.01 };
case 'freehand': {
if (!el.points.length) return { x: 0, y: 0, w: 0, h: 0 };
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of el.points) {
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y);
}
return { x: minX, y: minY, w: maxX - minX || 0.01, h: maxY - minY || 0.01 };
}
case 'text': {
const fs = el.fontSize || 18;
const tw = measureTextWidth(el.text || 'A', fs);
return { x: el.x, y: el.y - fs * 1.3, w: tw, h: fs * 1.3 };
}
}
return { x: 0, y: 0, w: 0, h: 0 };
}
function hitTestSelectionHandle(wx, wy) {
const bounds = getSelectionBounds();
if (!bounds || bounds.w < 4 && bounds.h < 4) return null;
// Handle size in screen pixels → world coords
const hs = C.HANDLE_SIZE / view.zoom + 2/view.zoom;
const corners = [
{ id: 'nw', x: bounds.x, y: bounds.y },
{ id: 'ne', x: bounds.x + bounds.w, y: bounds.y },
{ id: 'sw', x: bounds.x, y: bounds.y + bounds.h },
{ id: 'se', x: bounds.x + bounds.w, y: bounds.y + bounds.h },
];
for (const c of corners) {
if (Math.abs(wx - c.x) <= hs && Math.abs(wy - c.y) <= hs) return c.id;
}
return null;
}
/* ============================================================
RENDERING — draw everything
============================================================ */
function render() {
const W = window.innerWidth, H = window.innerHeight;
ctx.clearRect(0, 0, W, H);
// Background
ctx.fillStyle = C.BG_COLOR;
ctx.fillRect(0, 0, W, H);
// Grid (in world coords)
drawGrid(W, H);
// Elements (apply viewport transform)
ctx.save();
ctx.translate(view.x, view.y);
ctx.scale(view.zoom, view.zoom);
for (const el of scene) {
drawElement(el, selectedIds.has(el.id));
}
ctx.restore();
// Overlays: selection boxes, handles, rubber-band preview
drawOverlays(W, H);
}
function drawGrid(W, H) {
const sp = C.GRID_SPACING;
const tl = screenToWorld(0, 0);
const br = screenToWorld(W, H);
// Skip grid at extreme zooms
if (view.zoom < 0.25 || view.zoom > 10) return;
const startX = Math.floor(tl.x / sp) * sp;
const startY = Math.floor(tl.y / sp) * sp;
ctx.save();
ctx.translate(view.x, view.y);
ctx.scale(view.zoom, view.zoom);
// Dot grid — constant screen-size dots
const dotR = Math.max(0.8, 1.2 / view.zoom);
ctx.fillStyle = C.GRID_COLOR;
for (let x = startX; x <= br.x + sp; x += sp) {
for (let y = startY; y <= br.y + sp; y += sp) {
ctx.beginPath();
ctx.arc(x, y, dotR, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
}
function drawElement(el, selected) {
const sw = el.strokeWidth || C.STROKE_WIDTH_DEFAULT;
ctx.lineWidth = sw;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
switch (el.type) {
case 'rectangle':
if (el.fillColor && el.fillColor !== 'transparent') {
ctx.fillStyle = el.fillColor;
ctx.fillRect(el.x, el.y, el.width, el.height);
}
ctx.strokeStyle = selected ? '#4a6cf7' : el.strokeColor;
ctx.strokeRect(el.x, el.y, el.width, el.height);
break;
case 'ellipse': {
const rx = Math.max(0.1, el.rx), ry = Math.max(0.1, el.ry);
ctx.beginPath();
ctx.ellipse(el.cx, el.cy, rx, ry, 0, 0, Math.PI * 2);
if (el.fillColor && el.fillColor !== 'transparent') {
ctx.fillStyle = el.fillColor;
ctx.fill();
}
ctx.strokeStyle = selected ? '#4a6cf7' : el.strokeColor;
ctx.stroke();
break;
}
case 'line':
ctx.beginPath();
ctx.moveTo(el.x1, el.y1);
ctx.lineTo(el.x2, el.y2);
ctx.strokeStyle = selected ? '#4a6cf7' : el.strokeColor;
ctx.stroke();
// Arrow head
if (el.arrow) {
const angle = Math.atan2(el.y2 - el.y1, el.x2 - el.x1);
const aLen = 10 + sw * 2;
ctx.beginPath();
ctx.moveTo(el.x2, el.y2);
ctx.lineTo(el.x2 - aLen * Math.cos(angle - 0.4), el.y2 - aLen * Math.sin(angle - 0.4));
ctx.moveTo(el.x2, el.y2);
ctx.lineTo(el.x2 - aLen * Math.cos(angle + 0.4), el.y2 - aLen * Math.sin(angle + 0.4));
ctx.stroke();
}
break;
case 'freehand': {
const pts = el.points;
if (pts.length < 2) return;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
// Smooth with quadratic curves through midpoints
for (let i = 1; i < pts.length - 1; i++) {
const mx = (pts[i].x + pts[i+1].x) / 2;
const my = (pts[i].y + pts[i+1].y) / 2;
ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my);
}
// Last segment
if (pts.length > 2) {
const last = pts[pts.length - 1];
const prev = pts[pts.length - 2];
ctx.quadraticCurveTo(prev.x, prev.y, last.x, last.y);
} else {
ctx.lineTo(pts[1].x, pts[1].y);
}
ctx.strokeStyle = selected ? '#4a6cf7' : el.strokeColor;
ctx.stroke();
break;
}
case 'text': {
const fs = el.fontSize || 18;
ctx.font = `${fs}px -apple-system, BlinkMacSystemFont, sans-serif`;
ctx.fillStyle = selected ? '#4a6cf7' : (el.color || C.STROKE_DEFAULT);
// Draw text with word wrapping
const lines = wrapText(el.text || '', 300 / view.zoom);
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], el.x, el.y + fs * 1.2 * i);
}
break;
}
}
// Selection glow
if (selected) {
const b = getElementBounds(el);
ctx.save();
ctx.strokeStyle = 'rgba(74,108,247,.25)';
ctx.lineWidth = sw + 6;
switch (el.type) {
case 'rectangle':
ctx.strokeRect(b.x - 3, b.y - 3, b.w + 6, b.h + 6);
break;
case 'ellipse':
ctx.beginPath();
ctx.ellipse(el.cx, el.cy, Math.max(0.1,b.w/2)+3, Math.max(0.1,b.h/2)+3, 0, 0, Math.PI*2);
ctx.stroke();
break;
case 'line':
ctx.beginPath();
ctx.moveTo(el.x1, el.y1);
ctx.lineTo(el.x2, el.y2);
ctx.stroke();
break;
}
ctx.restore();
}
}
function wrapText(text, maxWidth) {
if (!text) return [''];
const lines = text.split('\n');
const result = [];
for (const line of lines) {
let current = '';
for (let i = 0; i < line.length; i++) {
const test = current + line[i];
if (ctx.measureText(test).width > maxWidth && current) {
result.push(current);
current = line[i];
} else {
current = test;
}
}
result.push(current);
}
return result.length ? result : [''];
}
function drawOverlays(W, H) {
// Selection bounding box + handles (screen coords)
const bounds = getSelectionBounds();
if (bounds && selectedIds.size > 0) {
ctx.save();
ctx.translate(view.x, view.y);
ctx.scale(view.zoom, view.zoom);
const pad = 4 / view.zoom;
ctx.strokeStyle = C.RUBBER_BAND_BORDER;
ctx.lineWidth = 1.5 / view.zoom;
ctx.setLineDash([5 / view.zoom, 3 / view.zoom]);
ctx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
ctx.setLineDash([]);
// Handles (constant screen size)
const hs = C.HANDLE_SIZE;
const corners = [
{ x: bounds.x, y: bounds.y },
{ x: bounds.x + bounds.w, y: bounds.y },
{ x: bounds.x, y: bounds.y + bounds.h },
{ x: bounds.x + bounds.w, y: bounds.y + bounds.h },
];
for (const c of corners) {
const sp = worldToScreen(c.x, c.y);
ctx.fillStyle = '#fff';
ctx.strokeStyle = C.RUBBER_BAND_BORDER;
ctx.lineWidth = 1.5;
ctx.fillRect(sp.x - hs/2, sp.y - hs/2, hs, hs);
ctx.strokeRect(sp.x - hs/2, sp.y - hs/2, hs, hs);
}
ctx.restore();
}
// Rubber-band selection rectangle (screen coords)
if (interaction && interaction.type === 'rubberBand') {
const x = Math.min(interaction.startS.x, interaction.currentS.x);
const y = Math.min(interaction.startS.y, interaction.currentS.y);
const w = Math.abs(interaction.currentS.x - interaction.startS.x);
const h = Math.abs(interaction.currentS.y - interaction.startS.y);
ctx.strokeStyle = C.RUBBER_BAND_BORDER;
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 4]);
ctx.strokeRect(x, y, w, h);
ctx.fillStyle = 'rgba(74,108,247,.06)';
ctx.fillRect(x, y, w, h);
ctx.setLineDash([]);
}
// Live preview for shape creation (world coords)
if (interaction && interaction.type === 'creating' && interaction.previewEl) {
ctx.save();
ctx.translate(view.x, view.y);
ctx.scale(view.zoom, view.zoom);
drawElement(interaction.previewEl, false);
ctx.restore();
}
// Eraser cursor indicator (world coords)
if (currentTool === 'eraser' && interaction && interaction.type === 'erasing') {
const r = C.ERASER_HIT_RADIUS / view.zoom;
ctx.save();
ctx.translate(view.x, view.y);
ctx.scale(view.zoom, view.zoom);
ctx.beginPath();
ctx.arc(interaction.currentW.x, interaction.currentW.y, r + 4/view.zoom, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(230,57,70,.4)';
ctx.lineWidth = 1.5 / view.zoom;
ctx.setLineDash([4/view.zoom, 4/view.zoom]);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
}
/* ============================================================
HISTORY — Undo/Redo
============================================================ */
function pushHistory(action) {
undoStack.push(action);
redoStack = []; // clear redo on new action
updateHistoryButtons();
}
function undo() {
if (!undoStack.length) return;
const action = undoStack.pop();
redoStack.push(action);
applyUndo(action);
updateHistoryButtons();
render();
}
function redo() {
if (!redoStack.length) return;
const action = redoStack.pop();
undoStack.push(action);
applyRedo(action);
updateHistoryButtons();
render();
}
function applyUndo(action) {
switch (action.type) {
case 'create':
scene = scene.filter(el => !action.ids.has(el.id));
for (const id of action.ids) selectedIds.delete(id);
break;
case 'delete':
const restored = JSON.parse(JSON.stringify(action.deletedElements));
for (const el of restored) {
scene.push(el);
if (el.id >= nextId) nextId = el.id + 1;
}
break;
case 'modify':
const prevMap = new Map(action.prevData);
for (const [id, data] of prevMap) {
const idx = scene.findIndex(el => el.id === id);
if (idx >= 0) Object.assign(scene[idx], data);
}
break;
case 'textEdit':
if (action.prevData && action.id) {
const idx = scene.findIndex(el => el.id === action.id);
if (idx >= 0) scene[idx].text = action.prevText;
}
break;
}
}
function applyRedo(action) {
switch (action.type) {
case 'create':
const newEls = JSON.parse(JSON.stringify(action.createdElements));
for (const el of newEls) {
scene.push(el);
if (el.id >= nextId) nextId = el.id + 1;
}
break;
case 'delete':
scene = scene.filter(el => !action.ids.has(el.id));
for (const id of action.ids) selectedIds.delete(id);
break;
case 'modify':
const newMap = new Map(action.newData);
for (const [id, data] of newMap) {
const idx = scene.findIndex(el => el.id === id);
if (idx >= 0) Object.assign(scene[idx], data);
}
break;
case 'textEdit':
if (action.newText !== undefined && action.id) {
const idx = scene.findIndex(el => el.id === action.id);
if (idx >= 0) scene[idx].text = action.newText;
}
break;
}
}
function updateHistoryButtons() {
document.getElementById('undo-btn').classList.toggle('disabled', !undoStack.length);
document.getElementById('redo-btn').classList.toggle('disabled', !redoStack.length);
}
/* ============================================================
PERSISTENCE — localStorage
============================================================ */
function saveScene() {
try {
const data = JSON.stringify({ scene, nextId });
localStorage.setItem(C.STORAGE_KEY, data);
} catch (e) {}
}
function loadScene() {
try {
const raw = localStorage.getItem(C.STORAGE_KEY);
if (!raw) return false;
const data = JSON.parse(raw);
scene = data.scene || [];
nextId = data.nextId || 1;
selectedIds.clear();
return true;
} catch (e) { return false; }
}
/* ============================================================
ZOOM — toward cursor position
============================================================ */
function zoomAt(sx, sy, factor) {
const oldZoom = view.zoom;
const newZoom = clamp(oldZoom * factor, C.ZOOM_MIN, C.ZOOM_MAX);
if (newZoom === oldZoom) return;
// Keep world point under cursor stable during zoom
const wx = (sx - view.x) / oldZoom;
const wy = (sy - view.y) / oldZoom;
view.zoom = newZoom;
view.x = sx - wx * newZoom;
view.y = sy - wy * newZoom;
updateZoomDisplay();
}
function zoomBy(dir) {
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
if (dir > 0) zoomAt(cx, cy, C.ZOOM_STEP);
else zoomAt(cx, cy, 1 / C.ZOOM_STEP);
}
function resetView() {
view.x = 0; view.y = 0; view.zoom = 1;
updateZoomDisplay();
render();
}
function updateZoomDisplay() {
document.getElementById('zoom-level').textContent = Math.round(view.zoom * 100) + '%';
}
/* ============================================================
MOUSE / POINTER EVENTS — the main interaction loop
============================================================ */
let spaceDown = false;
canvas.addEventListener('mousedown', onMouseDown);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('wheel', onWheel, { passive: false });
canvas.addEventListener('contextmenu', e => e.preventDefault());
function getMousePos(e) {
return { x: e.clientX, y: e.clientY };
}
function onMouseDown(e) {
const s = getMousePos(e);
const w = screenToWorld(s.x, s.y);
// Middle mouse or space+left → pan
if (e.button === 1 || (e.button === 0 && spaceDown)) {
interaction = { type: 'panning', startS: s, viewStartX: view.x, viewStartY: view.y };
canvas.style.cursor = 'grabbing';
return;
}
if (e.button !== 0) return;
switch (currentTool) {
case 'select': handleSelectDown(s, w, e); break;
case 'freehand': handleFreehandDown(s, w); break;
case 'rectangle': handleShapeDown('rectangle', s, w); break;
case 'ellipse': handleShapeDown('ellipse', s, w); break;
case 'line': handleShapeDown('line', s, w); break;
case 'text': handleTextDown(s, w); break;
case 'eraser': handleEraserDown(s, w); break;
}
}
function onMouseMove(e) {
const s = getMousePos(e);
const w = screenToWorld(s.x, s.y);
if (!interaction) return;
switch (interaction.type) {
case 'panning': handlePanMove(s); break;
case 'selecting': /* no-op */ break;
case 'moving': handleMoveMove(w); break;
case 'resizing': handleResizeMove(w, e); break;
case 'rubberBand': handleRubberBandMove(s); break;
case 'drawing': handleFreehandMove(w); break;
case 'creating': handleShapeMove(w); break;
case 'erasing': handleEraserMove(w); break;
}
render();
}
function onMouseUp(e) {
const s = getMousePos(e);
const w = screenToWorld(s.x, s.y);
if (!interaction) return;
switch (interaction.type) {
case 'panning':
canvas.style.cursor = toolCursor(currentTool);
interaction = null;
break;
case 'selecting':
selectedIds.clear();
updatePropertiesBar();
interaction = null;
render();
break;
case 'moving':
finishMove();
interaction = null;
break;
case 'resizing':
finishResize();
interaction = null;
break;
case 'rubberBand':
finishRubberBand(s);
interaction = null;
break;
case 'drawing':
finishFreehand();
interaction = null;
break;
case 'creating':
finishShapeCreation(w);
interaction = null;
break;
case 'erasing':
finishEraser();
interaction = null;
break;
}
render();
}
function onWheel(e) {
e.preventDefault();
const factor = e.deltaY < 0 ? C.ZOOM_STEP : 1 / C.ZOOM_STEP;
zoomAt(e.clientX, e.clientY, factor);
render();
}
/* ---------- Tool cursors ---------- */
function toolCursor(tool) {
switch (tool) {
case 'select': return 'default';
case 'freehand': return 'crosshair';
case 'rectangle': return 'crosshair';
case 'ellipse': return 'crosshair';
case 'line': return 'crosshair';
case 'text': return 'text';
case 'eraser': return 'pointer';
}
return 'default';
}
/* ============================================================
SELECT TOOL HANDLERS
============================================================ */
function handleSelectDown(s, w, e) {
// Check resize handles first (only if something is selected)
if (selectedIds.size > 0) {
const handle = hitTestSelectionHandle(w.x, w.y);
if (handle) {
interaction = { type: 'resizing', handle, startW: w, boundsStart: getSelectionBounds(), elementsSnapshot: snapshotElements(getSelection()) };
return;
}
}
// Hit test elements (reverse order for top-most first)
const hitEl = findTopElementAt(w.x, w.y);
if (hitEl) {
if (e.shiftKey) {
// Toggle selection
if (selectedIds.has(hitEl.id)) selectedIds.delete(hitEl.id);
else selectedIds.add(hitEl.id);
} else if (!selectedIds.has(hitEl.id)) {
selectedIds.clear();
selectedIds.add(hitEl.id);
}
interaction = { type: 'moving', startW: w, elementsSnapshot: snapshotElements(getSelection()) };
} else {
// Start rubber-band selection (unless shift is held — then just deselect)
if (!e.shiftKey) {
selectedIds.clear();
updatePropertiesBar();
interaction = { type: 'rubberBand', startS: s, currentS: s };
} else {
selectedIds.clear();
updatePropertiesBar();
interaction = { type: 'selecting' };
}
}
updatePropertiesBar();
render();
}
function handleMoveMove(w) {
const dx = w.x - interaction.startW.x;
const dy = w.y - interaction.startW.y;
applyDeltaToSelection(dx, dy);
}
function finishMove() {
// Push history: modify action with previous positions
const prevData = new Map();
for (const id of selectedIds) {
const snap = interaction.elementsSnapshot.get(id);
if (snap) prevData.set(id, JSON.parse(JSON.stringify(snap)));
}
pushHistory({ type: 'modify', ids: selectedIds, prevData });
saveScene();
}
function handleResizeMove(w, e) {
const dx = w.x - interaction.startW.x;
const dy = w.y - interaction.startW.y;
const bs = interaction.boundsStart; // bounds at start of resize
const shiftHeld = e.shiftKey;
const handle = interaction.handle;
// Calculate new selection bounds based on which handle is dragged
let newX, newY, newW, newH;
if (shiftHeld) {
// Preserve aspect ratio — use the dominant axis
const aspect = bs.w / (bs.h || 1);
if (handle === 'nw') { newW = Math.max(5, bs.w - dx); newH = newW / aspect; newX = bs.x + bs.w - newW; newY = bs.y + bs.h - newH; }
else if (handle === 'ne') { newW = Math.max(5, bs.w + dx); newH = newW / aspect; newX = bs.x; newY = bs.y + bs.h - newH; }
else if (handle === 'sw') { newW = Math.max(5, bs.w - dx); newH = newW / aspect; newX = bs.x + bs.w - newW; newY = bs.y; }
else { newW = Math.max(5, bs.w + dx); newH = newW / aspect; newX = bs.x; newY = bs.y; }
} else {
if (handle === 'nw') { newW = Math.max(5, bs.w - dx); newH = Math.max(5, bs.h - dy); newX = bs.x + bs.w - newW; newY = bs.y + bs.h - newH; }
else if (handle === 'ne') { newW = Math.max(5, bs.w + dx); newH = Math.max(5, bs.h - dy); newX = bs.x; newY = bs.y + bs.h - newH; }
else if (handle === 'sw') { newW = Math.max(5, bs.w - dx); newH = Math.max(5, bs.h + dy); newX = bs.x + bs.w - newW; newY = bs.y; }
else { newW = Math.max(5, bs.w + dx); newH = Math.max(5, bs.h + dy); newX = bs.x; newY = bs.y; }
}
// Apply resize to each selected element proportionally within the selection bounds
for (const el of getSelection()) {
const eb = getElementBounds(el);
const ratioX = bs.w ? (eb.x - bs.x) / bs.w : 0;
const ratioY = bs.h ? (eb.y - bs.y) / bs.h : 0;
// New bounds for this element within the resized selection
const elNewX = newX + ratioX * newW;
const elNewY = newY + ratioY * newH;
const elNewW = eb.w * (newW / (bs.w || 1));
const elNewH = eb.h * (newH / (bs.h || 1));
applyResizeToElement(el, elNewX, elNewY, Math.max(2, elNewW), Math.max(2, elNewH), handle);
}
}
function finishResize() {
const prevData = new Map();
for (const id of selectedIds) {
const snap = interaction.elementsSnapshot.get(id);
if (snap) prevData.set(id, JSON.parse(JSON.stringify(snap)));
}
pushHistory({ type: 'modify', ids: selectedIds, prevData });
saveScene();
}
function applyResizeToElement(el, newX, newY, newW, newH, handle) {
switch (el.type) {
case 'rectangle':
el.x = newX;
el.y = newY;
el.width = newW * Math.sign(el.width || 1);
el.height = newH * Math.sign(el.height || 1);
break;
case 'ellipse':
el.cx = newX + newW / 2;
el.cy = newY + newH / 2;
el.rx = Math.max(1, newW / 2);
el.ry = Math.max(1, newH / 2);
break;
case 'line': {
const oldB = getElementBounds(el);
const scaleX = newW / (oldB.w || 0.01);
const scaleY = newH / (oldB.h || 0.01);
// Anchor at the opposite corner of the handle
const ax = handle.includes('w') ? newX + newW : newX;
const ay = handle.includes('s') ? newY + newH : newY;
el.x1 = ax + (el.x1 - (oldB.x + oldB.w)) * scaleX;
el.y1 = ay + (el.y1 - (oldB.y + oldB.h)) * scaleY;
el.x2 = ax + (el.x2 - (oldB.x + oldB.w)) * scaleX;
el.y2 = ay + (el.y2 - (oldB.y + oldB.h)) * scaleY;
break;
}
case 'freehand': {
const oldB = getElementBounds(el);
const scaleX = newW / (oldB.w || 0.01);
const scaleY = newH / (oldB.h || 0.01);
const ax = handle.includes('w') ? newX + newW : newX;
const ay = handle.includes('s') ? newY + newH : newY;
for (const p of el.points) {
p.x = ax + (p.x - (oldB.x + oldB.w)) * scaleX;
p.y = ay + (p.y - (oldB.y + oldB.h)) * scaleY;
}
break;
}
case 'text':
el.x = newX;
// text y is baseline, bounds.y is top of text
const fs = el.fontSize || 18;
el.y = newY + newH;
break;
}
}
function handleRubberBandMove(s) {
interaction.currentS = s;
}
function finishRubberBand(s) {
const x1 = Math.min(interaction.startS.x, s.x);
const y1 = Math.min(interaction.startS.y, s.y);
const x2 = Math.max(interaction.startS.x, s.x);
const y2 = Math.max(interaction.startS.y, s.y);
// Convert to world coords for hit testing
const tl = screenToWorld(x1, y1);
const br = screenToWorld(x2, y2);
for (const el of scene) {
const b = getElementBounds(el);
if (b.x < br.x && b.x + b.w > tl.x && b.y < br.y && b.y + b.h > tl.y) {
selectedIds.add(el.id);
}
}
updatePropertiesBar();
}
/* ============================================================
FREEHAND TOOL HANDLERS
============================================================ */
function handleFreehandDown(s, w) {
interaction = { type: 'drawing', points: [{ x: w.x, y: w.y }] };
}
function handleFreehandMove(w) {
const last = interaction.points[interaction.points.length - 1];
// Only add point if far enough from last (thinning for performance)
const minDist = 0.5 / view.zoom;
if (Math.hypot(w.x - last.x, w.y - last.y) > minDist || interaction.points.length < 2) {
interaction.points.push({ x: w.x, y: w.y });
}
}
function finishFreehand() {
const pts = interaction.points;
if (pts.length < 2) return; // Too short, discard
const el = createFreehand(pts);
scene.push(el);
selectedIds.clear();
selectedIds.add(el.id);
pushHistory({ type: 'create', ids: new Set([el.id]), createdElements: [JSON.parse(JSON.stringify(el))] });
updatePropertiesBar();
saveScene();
}
/* ============================================================
SHAPE CREATION HANDLERS (Rectangle, Ellipse, Line)
============================================================ */
function handleShapeDown(type, s, w) {
let previewEl;
if (type === 'rectangle') {
previewEl = createRect(w.x, w.y, 0, 0);
} else if (type === 'ellipse') {
previewEl = createEllipse(w.x, w.y, 0.1, 0.1);
} else if (type === 'line') {
previewEl = createLine(w.x, w.y, w.x, w.y);
}
interaction = { type: 'creating', shapeType: type, startW: w, previewEl };
}
function handleShapeMove(w) {
const s = interaction.startW;
if (interaction.shapeType === 'rectangle') {
let x = Math.min(s.x, w.x), y = Math.min(s.y, w.y);
let wd = Math.abs(w.x - s.x), ht = Math.abs(w.y - s.y);
interaction.previewEl.x = x;
interaction.previewEl.y = y;
interaction.previewEl.width = wd;
interaction.previewEl.height = ht;
} else if (interaction.shapeType === 'ellipse') {
const cx = (s.x + w.x) / 2, cy = (s.y + w.y) / 2;
interaction.previewEl.cx = cx;
interaction.previewEl.cy = cy;
interaction.previewEl.rx = Math.abs(w.x - s.x) / 2;
interaction.previewEl.ry = Math.abs(w.y - s.y) / 2;
} else if (interaction.shapeType === 'line') {
interaction.previewEl.x1 = s.x;
interaction.previewEl.y1 = s.y;
interaction.previewEl.x2 = w.x;
interaction.previewEl.y2 = w.y;
}
}
function finishShapeCreation(w) {
const el = JSON.parse(JSON.stringify(interaction.previewEl));
// Validate minimum size
if (el.type === 'rectangle' && el.width < 3 && el.height < 3) return;
if (el.type === 'ellipse' && el.rx < 2 && el.ry < 2) return;
if (el.type === 'line') {
const len = Math.hypot(el.x2 - el.x1, el.y2 - el.y1);
if (len < 3) return;
}
scene.push(el);
selectedIds.clear();
selectedIds.add(el.id);
pushHistory({ type: 'create', ids: new Set([el.id]), createdElements: [JSON.parse(JSON.stringify(el))] });
updatePropertiesBar();
saveScene();
}
/* ============================================================
TEXT TOOL HANDLERS
============================================================ */
function handleTextDown(s, w) {
const overlay = document.getElementById('text-input-overlay');
const textarea = document.getElementById('text-area');
// Position in screen coords
const sp = worldToScreen(w.x, w.y);
overlay.style.display = 'block';
overlay.style.left = sp.x + 'px';
overlay.style.top = (sp.y - 2) + 'px';
textarea.value = '';
textarea.style.fontSize = '18px';
textarea.style.color = currentStyle.strokeColor;
textarea.focus();
// Store world position for when text is committed
interaction = { type: 'textInput', worldPos: w };
textarea.onkeydown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
commitText();
}
if (e.key === 'Escape') {
cancelTextInput();
}
};
}
function commitText() {
const textarea = document.getElementById('text-area');
const overlay = document.getElementById('text-input-overlay');
overlay.style.display = 'none';
const text = textarea.value.trim();
if (!text) return;
const el = createText(interaction.worldPos.x, interaction.worldPos.y + 18, text);
scene.push(el);
selectedIds.clear();
selectedIds.add(el.id);
pushHistory({ type: 'create', ids: new Set([el.id]), createdElements: [JSON.parse(JSON.stringify(el))] });
updatePropertiesBar();
saveScene();
render();
}
function cancelTextInput() {
const overlay = document.getElementById('text-input-overlay');
overlay.style.display = 'none';
interaction = null;
}
/* ============================================================
ERASER TOOL HANDLERS
============================================================ */
let erasedIds = new Set();
function handleEraserDown(s, w) {
erasedIds.clear();
// Check if any element is under cursor
for (const el of [...scene].reverse()) {
if (hitTestElement(el, w.x, w.y)) {
erasedIds.add(el.id);
break; // only erase top-most at this point on mousedown
}
}
interaction = { type: 'erasing', currentW: w };
}
function handleEraserMove(w) {
interaction.currentW = w;
for (const el of [...scene].reverse()) {
if (!erasedIds.has(el.id) && hitTestElement(el, w.x, w.y)) {
erasedIds.add(el.id);
}
}
}
function finishEraser() {
if (!erasedIds.size) return;
const deletedElements = scene.filter(el => erasedIds.has(el.id));
scene = scene.filter(el => !erasedIds.has(el.id));
for (const id of erasedIds) selectedIds.delete(id);
pushHistory({ type: 'delete', ids: new Set(erasedIds), deletedElements });
updatePropertiesBar();
saveScene();
}
/* ============================================================
PAN HANDLER
============================================================ */
function handlePanMove(s) {
view.x = interaction.viewStartX + (s.x - interaction.startS.x);
view.y = interaction.viewStartY + (s.y - interaction.startS.y);
}
/* ============================================================
HELPERS — find element, snapshot, apply delta
============================================================ */
function findTopElementAt(wx, wy) {
for (let i = scene.length - 1; i >= 0; i--) {
if (hitTestElement(scene[i], wx, wy)) return scene[i];
}
return null;
}
function snapshotElements(elements) {
const map = new Map();
for (const el of elements) {
map.set(el.id, JSON.parse(JSON.stringify(el)));
}
return map;
}
function applyDeltaToSelection(dx, dy) {
for (const id of selectedIds) {
const idx = scene.findIndex(el => el.id === id);
if (idx < 0) continue;
const el = scene[idx];
switch (el.type) {
case 'rectangle':
el.x += dx; el.y += dy; break;
case 'ellipse':
el.cx += dx; el.cy += dy; break;
case 'line':
el.x1 += dx; el.y1 += dy; el.x2 += dx; el.y2 += dy; break;
case 'freehand':
for (const p of el.points) { p.x += dx; p.y += dy; } break;
case 'text':
el.x += dx; el.y += dy; break;
}
}
}
/* ============================================================
KEYBOARD HANDLERS
============================================================ */
window.addEventListener('keydown', (e) => {
// Don't handle if typing in text input
const overlay = document.getElementById('text-input-overlay');
if (overlay.style.display === 'block') return;
// Space for panning
if (e.code === 'Space' && !spaceDown) {
e.preventDefault();
spaceDown = true;
canvas.style.cursor = 'grab';
}
// Tool shortcuts
if (!e.ctrlKey && !e.metaKey) {
switch (e.key.toLowerCase()) {
case 'v': setTool('select'); break;
case 'p': setTool('freehand'); break;
case 'r': setTool('rectangle'); break;
case 'o': setTool('ellipse'); break;
case 'l': setTool('line'); break;
case 't': setTool('text'); break;
case 'e': setTool('eraser'); break;
}
}
// Undo/Redo
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
e.preventDefault();
if (e.shiftKey) redo(); else undo();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') {
e.preventDefault();
redo();
return;
}
// Delete/Backspace
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.size > 0) {
e.preventDefault();
deleteSelection();
return;
}
// Arrow keys — nudge selection
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) && selectedIds.size > 0) {
e.preventDefault();
const step = e.shiftKey ? 10 : 1;
let dx = 0, dy = 0;
if (e.key === 'ArrowUp') dy = -step;
if (e.key === 'ArrowDown') dy = step;
if (e.key === 'ArrowLeft') dx = -step;
if (e.key === 'ArrowRight') dx = step;
// Snapshot before nudge for undo
const prevData = new Map();
for (const id of selectedIds) {
const idx = scene.findIndex(el => el.id === id);
if (idx >= 0) prevData.set(id, JSON.parse(JSON.stringify(scene[idx])));
}
applyDeltaToSelection(dx, dy);
pushHistory({ type: 'modify', ids: selectedIds, prevData });
saveScene();
render();
}
// Escape — deselect
if (e.key === 'Escape') {
selectedIds.clear();
updatePropertiesBar();
render();
}
});
window.addEventListener('keyup', (e) => {
if (e.code === 'Space') {
spaceDown = false;
canvas.style.cursor = toolCursor(currentTool);
}
});
function deleteSelection() {
const ids = new Set(selectedIds);
const deletedElements = scene.filter(el => ids.has(el.id));
scene = scene.filter(el => !ids.has(el.id));
selectedIds.clear();
pushHistory({ type: 'delete', ids, deletedElements });
updatePropertiesBar();
saveScene();
render();
}
/* ============================================================
TOOL SWITCHING & UI
============================================================ */
function setTool(tool) {
currentTool = tool;
document.querySelectorAll('.tool-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tool === tool);
});
canvas.style.cursor = toolCursor(tool);
// Show/hide properties bar based on selection
updatePropertiesBar();
}
function updatePropertiesBar() {
const bar = document.getElementById('properties-bar');
if (selectedIds.size > 0) {
bar.classList.add('visible');
// Update swatches to reflect first selected element's style
const sel = getSelection()[0];
if (sel) {
updateSwatch('stroke-color-btn', sel.strokeColor || C.STROKE_DEFAULT);
updateSwatch('fill-color-btn', sel.fillColor || 'transparent');
document.getElementById('stroke-width-slider').value = sel.strokeWidth || C.STROKE_WIDTH_DEFAULT;
document.getElementById('stroke-width-val').textContent = sel.strokeWidth || C.STROKE_WIDTH_DEFAULT;
}
} else {
bar.classList.remove('visible');
}
}
function updateSwatch(id, color) {
const el = document.getElementById(id);
if (color === 'transparent') {
el.style.background = 'repeating-conic-gradient(#ddd 0% 25%, #fff 0% 50%) 50% / 10px 10px';
} else {
el.style.background = color;
}
}
/* ============================================================
PROPERTIES BAR — Style changes
============================================================ */
document.getElementById('stroke-width-slider').addEventListener('input', (e) => {
const val = parseInt(e.target.value);
document.getElementById('stroke-width-val').textContent = val;
currentStyle.strokeWidth = val;
if (selectedIds.size > 0) {
applyStyleToSelection({ strokeWidth: val });
}
});
function applyStyleToSelection(styleObj) {
const prevData = new Map();
for (const id of selectedIds) {
const idx = scene.findIndex(el => el.id === id);
if (idx >= 0) {
prevData.set(id, JSON.parse(JSON.stringify(scene[idx])));
Object.assign(scene[idx], styleObj);
}
}
pushHistory({ type: 'modify', ids: selectedIds, prevData });
saveScene();
render();
}
/* ============================================================
COLOR PICKER POPUP
============================================================ */
let activeColorPicker = null;
function toggleColorPicker(which) {
const pickerId = which + '-color-picker';
const btnId = which + '-color-btn';
const popup = document.getElementById(pickerId);
if (popup.classList.contains('visible')) {
popup.classList.remove('visible');
activeColorPicker = null;
return;
}
// Close any other picker
document.querySelectorAll('.color-picker-popup').forEach(p => p.classList.remove('visible'));
// Build color grid
popup.innerHTML = '<div class="color-grid">' + C.PALETTE.map(c =>
`<div class="color-swatch" style="background:${c}" onclick="pickColor('${which}','${c}')"></div>`
).join('') +
`<div class="color-swatch" style="background:repeating-conic-gradient(#ddd 0% 25%,#fff 0% 50%) 50%/10px 10px;position:relative;" onclick="pickColor('${which}','transparent')" title="No fill">
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%) rotate(45deg);width:16px;height:2.5px;background:#c0392b;"></div>
</div>` + '</div>';
popup.classList.add('visible');
activeColorPicker = which;
}
function pickColor(which, color) {
const btnId = which + '-color-btn';
updateSwatch(btnId, color);
if (which === 'stroke') currentStyle.strokeColor = color;
else currentStyle.fillColor = color;
if (selectedIds.size > 0) {
const styleObj = {};
if (which === 'stroke') styleObj.strokeColor = color;
else styleObj.fillColor = color;
applyStyleToSelection(styleObj);
}
document.getElementById(which + '-color-picker').classList.remove('visible');
activeColorPicker = null;
}
// Close pickers on outside click
document.addEventListener('click', (e) => {
if (!e.target.closest('.prop-group')) {
document.querySelectorAll('.color-picker-popup').forEach(p => p.classList.remove('visible'));
activeColorPicker = null;
}
});
/* ============================================================
TOOLBAR BUTTON CLICKS
============================================================ */
document.getElementById('toolbar').addEventListener('click', (e) => {
const btn = e.target.closest('.tool-btn');
if (!btn) return;
setTool(btn.dataset.tool);
});
// History buttons
document.getElementById('undo-btn').addEventListener('click', undo);
document.getElementById('redo-btn').addEventListener('click', redo);
/* ============================================================
INITIALIZATION
============================================================ */
function init() {
resizeCanvas();
window.addEventListener('resize', () => { resizeCanvas(); render(); });
// Load saved scene
loadScene();
// Set initial cursor
canvas.style.cursor = toolCursor(currentTool);
// Initialize swatches
updateSwatch('stroke-color-btn', currentStyle.strokeColor);
updateSwatch('fill-color-btn', currentStyle.fillColor);
render();
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"full","sessionId":"439116a9-7b44-4fbe-a61e-812ae7559468","tokensIn":1393284,"tokensOut":59934,"tokensTotal":1453218,"turns":29,"toolCalls":28,"failedToolCalls":0,"timestamp":"2026-07-30T00:08:26.010Z"} -->