Whiteboard
Qwen 3.6 27b MTP · composer

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 61,747 bytes · SHA-256 be7018c2744d
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Whiteboard Prototype</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8f9fa; }
#whiteboard { position: relative; width: 100%; height: 100%; overflow: hidden; }
#canvas { display: block; cursor: crosshair; touch-action: none; }
/* Toolbar */
#toolbar {
position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 2px; padding: 4px;
background: #ffffff; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.06);
z-index: 100; user-select: none;
}
.tool-btn {
width: 36px; height: 36px; border: none; background: transparent; border-radius: 8px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
color: #5f6b7a; transition: all 0.15s ease; position: relative;
}
.tool-btn svg { width: 20px; height: 20px; }
.tool-btn:hover { background: #f0f2f4; color: #1e1e1e; }
.tool-btn.active { background: #e8edf3; color: #1a73e8; }
.tool-btn.active::after {
content: ''; position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%);
width: 16px; height: 2px; background: #1a73e8; border-radius: 1px;
}
.toolbar-sep { width: 1px; height: 24px; background: #e0e3e8; margin: 0 4px; }
/* Properties Bar */
#properties {
position: absolute; top: 60px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 12px; padding: 6px 14px;
background: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.05);
z-index: 100; user-select: none; opacity: 0; pointer-events: none; transition: opacity 0.2s ease;
}
#properties.visible { opacity: 1; pointer-events: auto; }
.prop-group { display: flex; align-items: center; gap: 6px; }
.prop-label { font-size: 11px; color: #8b95a2; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; }
#strokeColor, #fillColor {
width: 24px; height: 24px; border: 2px solid #e0e3e8; border-radius: 6px; cursor: pointer;
padding: 0; background: none; -webkit-appearance: none; appearance: none;
}
#strokeColor::-webkit-color-swatch-wrapper, #fillColor::-webkit-color-swatch-wrapper { padding: 0; }
#strokeColor::-webkit-color-swatch, #fillColor::-webkit-color-swatch { border: none; border-radius: 4px; }
#strokeWidth {
height: 28px; padding: 0 8px; border: 1px solid #e0e3e8; border-radius: 6px;
font-size: 12px; color: #5f6b7a; background: #fafbfc; cursor: pointer; outline: none;
}
/* Zoom Controls */
#zoomControls {
position: absolute; bottom: 16px; right: 16px;
display: flex; align-items: center; gap: 4px; padding: 4px;
background: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.05);
z-index: 100; user-select: none;
}
.zoom-btn {
width: 32px; height: 32px; border: none; background: transparent; border-radius: 6px;
cursor: pointer; font-size: 18px; color: #5f6b7a; display: flex; align-items: center; justify-content: center;
}
.zoom-btn:hover { background: #f0f2f4; }
#zoomLevel {
min-width: 48px; text-align: center; font-size: 13px; color: #5f6b7a; font-weight: 500; cursor: pointer; padding: 4px;
}
#zoomLevel:hover { background: #f0f2f4; border-radius: 4px; }
/* Hint Bar */
#hintBar {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
font-size: 12px; color: #a0aab4; background: rgba(255,255,255,0.85); padding: 6px 14px;
border-radius: 6px; z-index: 100; user-select: none; pointer-events: none;
}
/* Text Input */
#textInput {
position: absolute; z-index: 200; border: 2px solid #1a73e8; border-radius: 4px;
padding: 4px 8px; font-family: inherit; font-size: 16px; outline: none; resize: both;
min-width: 100px; min-height: 28px; background: #fff; box-shadow: 0 2px 8px rgba(26,115,232,0.2);
}
/* Cursor styles */
#canvas.cursor-select { cursor: default; }
#canvas.cursor-grab { cursor: grab; }
#canvas.cursor-grabbing { cursor: grabbing; }
#canvas.cursor-crosshair { cursor: crosshair; }
#canvas.cursor-text { cursor: text; }
#canvas.cursor-eraser { cursor: cell; }
</style>
</head>
<body>
<div id="whiteboard">
<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"><path d="M4 4l7 18 3-8 8-3z"></path></svg>
</button>
<button class="tool-btn" data-tool="pen" title="Freehand Pen (P)">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"></path></svg>
</button>
<button class="tool-btn" data-tool="rect" title="Rectangle (R)">
<svg viewBox="0 0 24 24"><path d="M3 3h18v18H3V3zm2 2v14h14V5H5z"></path></svg>
</button>
<button class="tool-btn" data-tool="ellipse" title="Ellipse (O)">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"></circle></svg>
</button>
<button class="tool-btn" data-tool="line" title="Line/Arrow (L)">
<svg viewBox="0 0 24 24"><path d="M5 19l13-13M18 6l-3 3 3 3"></path></svg>
</button>
<button class="tool-btn" data-tool="text" title="Text (T)">
<svg viewBox="0 0 24 24"><path d="M5 4v3h5.5v12h3V7H19V4z"></path></svg>
</button>
<button class="tool-btn" data-tool="eraser" title="Eraser (E)">
<svg viewBox="0 0 24 24"><path d="M16.24 3.56l4.95 4.94c.78.79.78 2.05 0 2.84L12 20.53a4.01 4.01 0 01-5.66 0L2.81 17c-.78-.78-.78-2.05 0-2.83l10.6-10.6c.79-.78 2.05-.78 2.83 0zM4.22 15.58l3.54 3.53c.78.79 2.04.79 2.83 0L16 13.71 9.29 7l-5.07 5.07c-.78.78-.78 2.04 0 2.83z"></path></svg>
</button>
<div class="toolbar-sep"></div>
<button class="tool-btn" id="undoBtn" title="Undo (Ctrl+Z)">
<svg viewBox="0 0 24 24"><path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"></path></svg>
</button>
<button class="tool-btn" id="redoBtn" title="Redo (Ctrl+Shift+Z)">
<svg viewBox="0 0 24 24"><path d="M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z"></path></svg>
</button>
</div>
<!-- Properties Bar -->
<div id="properties">
<label class="prop-group">
<span class="prop-label">Stroke</span>
<input type="color" id="strokeColor" value="#1e1e1e" title="Stroke Color">
</label>
<label class="prop-group">
<span class="prop-label">Fill</span>
<input type="color" id="fillColor" value="#ffffff" title="Fill Color">
</label>
<select id="strokeWidth" title="Stroke Width">
<option value="1">Thin</option>
<option value="2" selected="">Normal</option>
<option value="4">Thick</option>
<option value="6">Bold</option>
</select>
</div>
<!-- Zoom Controls -->
<div id="zoomControls">
<button class="zoom-btn" id="zoomOutBtn" title="Zoom Out">−</button>
<span id="zoomLevel">100%</span>
<button class="zoom-btn" id="zoomInBtn" title="Zoom In">+</button>
<button class="zoom-btn" id="resetViewBtn" title="Reset View">⟲</button>
</div>
<!-- Text Input Overlay -->
<textarea id="textInput" style="display:none"></textarea>
<!-- Context hint -->
<div id="hintBar">Space+drag to pan · Scroll to zoom · Delete to remove</div>
</div>
<script>
'use strict';
// ── const: CONFIG ──
// ============================================================
// CONSTANTS & CONFIGURATION
// ============================================================
const CONFIG = {
// Zoom limits
ZOOM_MIN: 0.25,
ZOOM_MAX: 4.0,
ZOOM_STEP: 1.12,
// Hit testing tolerance (in world coords)
HIT_TOLERANCE: 8, // px for shape borders/lines at zoom=1
FREEHAND_HIT_TOL: 12, // px for freehand stroke proximity
// Selection handles
HANDLE_SIZE: 8, // px in screen space (scales inversely with zoom)
// Grid
GRID_SPACING: 24, // world units between dots
GRID_DOT_RADIUS: 1.2, // screen-space radius of grid dots
// Default styles
DEFAULT_STROKE_COLOR: '#1e1e1e',
DEFAULT_FILL_COLOR: 'transparent',
DEFAULT_STROKE_WIDTH: 2,
DEFAULT_FONT_SIZE: 16,
// Colors palette for quick reference
COLORS: ['#1e1e1e', '#e03131', '#2f9e81', '#1a73e8', '#f5a623', '#8b45c6'],
// Selection color
SELECTION_COLOR: '#1a73e8',
SELECTION_DASH: [5, 5],
// Preview opacity while drawing
PREVIEW_OPACITY: 0.5,
// Eraser hit radius (world coords)
ERASER_RADIUS: 16,
// Canvas background
CANVAS_BG: '#f8f9fa',
GRID_COLOR: 'rgba(0,0,0,0.07)',
// LocalStorage key
STORAGE_KEY: 'whiteboard-prototype-data'
};
// ── function: uid ──
// ============================================================
// UTILITY FUNCTIONS
// ============================================================
function uid() {
return Math.random().toString(36).substring(2, 11) + Date.now().toString(36);
}
// ── function: clamp ──
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
// ── function: lerp ──
function lerp(a, b, t) { return a + (b - a) * t; }
// ── function: dist ──
function dist(x1, y1, x2, y2) { return Math.sqrt((x2-x1)**2 + (y2-y1)**2); }
// ── function: pointInRect ──
function pointInRect(px, py, rx, ry, rw, rh) {
return px >= rx && px <= rx+rw && py >= ry && py <= ry+rh;
}
// ── function: pointNearLine ──
function pointNearLine(px, py, x1, y1, x2, y2) {
const dx = x2-x1, dy = y2-y1;
const lenSq = dx*dx + dy*dy;
if (lenSq === 0) return dist(px, py, x1, y1);
let t = ((px-x1)*dx + (py-y1)*dy) / lenSq;
t = clamp(t, 0, 1);
return dist(px, py, x1+t*dx, y1+t*dy);
}
// ── function: pointInEllipse ──
function pointInEllipse(px, py, cx, cy, rx, ry) {
const nx = (px-cx)/rx, ny = (py-cy)/ry;
return nx*nx + ny*ny <= 1.0;
}
// ── function: pointNearEllipseBorder ──
function pointNearEllipseBorder(px, py, cx, cy, rx, ry, tol) {
// Approximate: check if point is within tol of the ellipse border
const angle = Math.atan2(py-cy, px-cx);
const ex = cx + rx * Math.cos(angle);
const ey = cy + ry * Math.sin(angle);
return dist(px, py, ex, ey) <= tol;
}
// ── function: getRectBounds ──
function getRectBounds(obj) {
if (obj.type === 'rect' || obj.type === 'ellipse') {
return { x: obj.x, y: obj.y, w: obj.width, h: obj.height };
}
if (obj.type === 'line') {
const minX = Math.min(obj.x1, obj.x2);
const minY = Math.min(obj.y1, obj.y2);
return { x: minX, y: minY, w: Math.abs(obj.x2-obj.x1), h: Math.abs(obj.y2-obj.y1) };
}
if (obj.type === 'freehand') {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of obj.points) {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
return { x: minX, y: minY, w: maxX-minX, h: maxY-minY };
}
if (obj.type === 'text') {
return { x: obj.x, y: obj.y, w: obj.width || 100, h: obj.height || 24 };
}
return { x: 0, y: 0, w: 0, h: 0 };
}
// ── function: unionBounds ──
function unionBounds(boundsArr) {
if (!boundsArr.length) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const b of boundsArr) {
if (b.x < minX) minX = b.x;
if (b.y < minY) minY = b.y;
if (b.x+b.w > maxX) maxX = b.x+b.w;
if (b.y+b.h > maxY) maxY = b.y+b.h;
}
return { x: minX, y: minY, w: maxX-minX, h: maxY-minY };
}
// ── function: deepClone ──
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
// ── const: Viewport ──
// ============================================================
// VIEWPORT MANAGEMENT
// ============================================================
const Viewport = {
x: 0, // world coordinate of top-left corner of screen
y: 0,
zoom: 1.0,
reset() {
this.x = 0; this.y = 0; this.zoom = 1.0;
},
// Screen to world coordinates
s2w(sx, sy) {
return { x: sx / this.zoom + this.x, y: sy / this.zoom + this.y };
},
// World to screen coordinates
w2s(wx, wy) {
return { x: (wx - this.x) * this.zoom, y: (wy - this.y) * this.zoom };
},
// Zoom toward a screen position
zoomAt(sx, sy, newZoom) {
const world = this.s2w(sx, sy);
newZoom = clamp(newZoom, CONFIG.ZOOM_MIN, CONFIG.ZOOM_MAX);
this.x = world.x - sx / newZoom;
this.y = world.y - sy / newZoom;
this.zoom = newZoom;
},
// Pan by screen delta
pan(dx, dy) {
this.x -= dx / this.zoom;
this.y -= dy / this.zoom;
}
};
// ── function: hitTestObject ──
// ============================================================
// HIT TESTING (all in world coordinates)
// ============================================================
function hitTestObject(obj, wx, wy) {
const tol = CONFIG.HIT_TOLERANCE / Viewport.zoom;
switch (obj.type) {
case 'rect': {
const b = getRectBounds(obj);
// Check inside or near border
if (pointInRect(wx, wy, b.x-tol, b.y-tol, b.w+tol*2, b.h+tol*2)) {
// For filled rects, check inside; for unfilled, check near border
if (obj.fillColor && obj.fillColor !== 'transparent') return true;
// Near border test: within tol of any edge
const left = Math.abs(wx - b.x) <= tol;
const right = Math.abs(wx - (b.x+b.w)) <= tol;
const top = Math.abs(wy - b.y) <= tol;
const bottom = Math.abs(wy - (b.y+b.h)) <= tol;
if (left || right || top || bottom) return true;
// Also check inside for filled rects
if (pointInRect(wx, wy, b.x, b.y, b.w, b.h)) return true;
}
return false;
}
case 'ellipse': {
const cx = obj.x + obj.width/2, cy = obj.y + obj.height/2;
const rx = Math.abs(obj.width)/2, ry = Math.abs(obj.height)/2;
if (rx === 0 || ry === 0) return false;
// Inside test
if (pointInEllipse(wx, wy, cx, cy, rx + tol, ry + tol)) {
if (obj.fillColor && obj.fillColor !== 'transparent') return true;
// Near border: check distance to ellipse edge
const angle = Math.atan2(wy-cy, wx-cx);
const ex = cx + rx * Math.cos(angle);
const ey = cy + ry * Math.sin(angle);
if (dist(wx, wy, ex, ey) <= tol) return true;
}
return false;
}
case 'line': {
const d = pointNearLine(wx, wy, obj.x1, obj.y1, obj.x2, obj.y2);
if (d <= tol + obj.strokeWidth/2) return true;
// Arrowhead hit test
if (obj.arrowhead) {
const angle = Math.atan2(obj.y2-obj.y1, obj.x2-obj.x1);
const aLen = 10 / Viewport.zoom * 3;
const ax1 = obj.x2 - aLen*Math.cos(angle-Math.PI/6);
const ay1 = obj.y2 - aLen*Math.sin(angle-Math.PI/6);
const ax2 = obj.x2 - aLen*Math.cos(angle+Math.PI/6);
const ay2 = obj.y2 - aLen*Math.sin(angle+Math.PI/6);
if (pointNearLine(wx, wy, obj.x2, obj.y2, ax1, ay1) <= tol ||
pointNearLine(wx, wy, obj.x2, obj.y2, ax2, ay2) <= tol) return true;
}
return false;
}
case 'freehand': {
const ftol = CONFIG.FREEHAND_HIT_TOL / Viewport.zoom;
for (let i = 0; i < obj.points.length - 1; i++) {
if (pointNearLine(wx, wy, obj.points[i].x, obj.points[i].y,
obj.points[i+1].x, obj.points[i+1].y) <= ftol + obj.strokeWidth/2) {
return true;
}
}
// Also check near endpoints
if (obj.points.length > 0 && dist(wx, wy, obj.points[0].x, obj.points[0].y) <= ftol) return true;
const last = obj.points[obj.points.length-1];
if (last && dist(wx, wy, last.x, last.y) <= ftol) return true;
return false;
}
case 'text': {
const b = getRectBounds(obj);
return pointInRect(wx, wy, b.x-tol, b.y-tol, b.w+tol*2, b.h+tol*2);
}
default: return false;
}
}
// ── function: getSelectionHandles ──
// ============================================================
// SELECTION HANDLE HIT TESTING (in screen coordinates)
// ============================================================
function getSelectionHandles(bounds) {
const hs = CONFIG.HANDLE_SIZE / Viewport.zoom; // handle size in world coords
if (!bounds) return [];
return [
{ 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 }
];
}
// ── function: hitTestHandle ──
function hitTestHandle(handles, wx, wy) {
const tol = CONFIG.HANDLE_SIZE / Viewport.zoom;
for (const h of handles) {
if (Math.abs(wx - h.x) <= tol && Math.abs(wy - h.y) <= tol) return h.id;
}
return null;
}
// ── let: scene ──
// ============================================================
// SCENE MANAGEMENT
// ============================================================
let scene = [];
// ── let: selectedIds ──
// Array of object dicts
let selectedIds = new Set();
// ── function: addObject ──
function addObject(obj) {
scene.push(obj);
return obj;
}
// ── function: removeObject ──
function removeObject(id) {
const idx = scene.findIndex(o => o.id === id);
if (idx >= 0) scene.splice(idx, 1);
}
// ── function: getObject ──
function getObject(id) {
return scene.find(o => o.id === id);
}
// ── function: getSelectedObjects ──
function getSelectedObjects() {
return scene.filter(o => selectedIds.has(o.id));
}
// ── function: setSelected ──
function setSelected(ids) {
selectedIds = new Set(ids);
}
// ── function: addSelection ──
function addSelection(id) {
selectedIds.add(id);
}
// ── function: removeSelection ──
function removeSelection(id) {
selectedIds.delete(id);
}
// ── function: clearSelection ──
function clearSelection() {
selectedIds.clear();
}
// ── function: getSelectionBounds ──
function getSelectionBounds() {
const objs = getSelectedObjects();
if (!objs.length) return null;
return unionBounds(objs.map(getRectBounds));
}
// ── let: history ──
// ============================================================
// HISTORY / UNDO-REDO
// ============================================================
let history = [];
// ── let: historyIndex ──
let historyIndex = -1;
// ── const: MAX_HISTORY ──
const MAX_HISTORY = 200;
// ── function: pushHistory ──
function pushHistory(action, undoFn) {
// Remove any redo states after current index
if (historyIndex < history.length - 1) {
history = history.slice(0, historyIndex + 1);
}
history.push({ action, undo: undoFn });
if (history.length > MAX_HISTORY) history.shift();
historyIndex = history.length - 1;
}
// ── function: undo ──
function undo() {
if (historyIndex < 0) return false;
const entry = history[historyIndex];
entry.undo();
historyIndex--;
return true;
}
// ── function: redo ──
function redo() {
if (historyIndex >= history.length - 1) return false;
historyIndex++;
const entry = history[historyIndex];
// Re-apply: we store the forward action too
if (entry.redo) entry.redo();
return true;
}
// ── function: clearHistory ──
function clearHistory() {
history = [];
historyIndex = -1;
}
// ── function: saveToStorage ──
// ============================================================
// PERSISTENCE
// ============================================================
function saveToStorage() {
try {
const data = JSON.stringify(scene);
localStorage.setItem(CONFIG.STORAGE_KEY, data);
} catch(e) {}
}
// ── function: loadFromStorage ──
function loadFromStorage() {
try {
const raw = localStorage.getItem(CONFIG.STORAGE_KEY);
if (raw) {
scene = JSON.parse(raw);
return true;
}
} catch(e) {}
return false;
}
// ── function: getDefaultStyle ──
// ============================================================
// DEFAULT STYLE HELPERS
// ============================================================
function getDefaultStyle() {
return {
strokeColor: document.getElementById('strokeColor').value || CONFIG.DEFAULT_STROKE_COLOR,
fillColor: document.getElementById('fillColor').value === '#ffffff' ? 'transparent' : (document.getElementById('fillColor').value || CONFIG.DEFAULT_FILL_COLOR),
strokeWidth: parseInt(document.getElementById('strokeWidth').value) || CONFIG.DEFAULT_STROKE_WIDTH
};
}
// ── function: applyStyleToSelection ──
function applyStyleToSelection() {
const style = getDefaultStyle();
if (!selectedIds.size) return;
// Save old state for undo
const oldState = deepClone(getSelectedObjects());
for (const id of selectedIds) {
const obj = getObject(id);
if (!obj) continue;
obj.strokeColor = style.strokeColor;
obj.fillColor = style.fillColor;
obj.strokeWidth = style.strokeWidth;
}
pushHistory('style', () => {
for (const old of oldState) {
const obj = getObject(old.id);
if (obj) Object.assign(obj, deepClone(old));
}
});
}
// ── let: canvas ──
// ============================================================
// RENDERING ENGINE
// ============================================================
let canvas = null;
// ── let: ctx ──
let ctx = null;
// ── let: needsRender ──
let needsRender = true;
// ── function: initCanvas ──
function initCanvas() {
canvas = document.getElementById('canvas');
ctx = canvas.getContext('2d');
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
}
// ── function: resizeCanvas ──
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);
needsRender = true;
}
// ── function: render ──
function render() {
if (!needsRender) return;
needsRender = false;
const w = window.innerWidth;
const h = window.innerHeight;
// Clear
ctx.fillStyle = CONFIG.CANVAS_BG;
ctx.fillRect(0, 0, w, h);
// Apply viewport transform
ctx.save();
ctx.translate(-Viewport.x * Viewport.zoom, -Viewport.y * Viewport.zoom);
ctx.scale(Viewport.zoom, Viewport.zoom);
// Draw grid
drawGrid(w, h);
// Draw all objects
for (const obj of scene) {
drawObject(obj, false);
}
// Draw selection UI
if (selectedIds.size > 0) {
const bounds = getSelectionBounds();
if (bounds) {
drawSelectionBox(bounds);
}
}
ctx.restore();
// Draw preview (in screen space, no transform needed for some elements)
if (interaction.preview) {
ctx.save();
ctx.translate(-Viewport.x * Viewport.zoom, -Viewport.y * Viewport.zoom);
ctx.scale(Viewport.zoom, Viewport.zoom);
drawPreview(interaction.preview);
ctx.restore();
}
// Draw rubber-band selection rectangle (screen space)
if (interaction.rubberBand && interaction.rubberBand.active) {
const rb = interaction.rubberBand;
ctx.save();
ctx.strokeStyle = CONFIG.SELECTION_COLOR;
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.globalAlpha = 0.6;
ctx.fillStyle = 'rgba(26, 115, 232, 0.08)';
const rx = Math.min(rb.x1, rb.x2);
const ry = Math.min(rb.y1, rb.y2);
const rw = Math.abs(rb.x2 - rb.x1);
const rh = Math.abs(rb.y2 - rb.y1);
ctx.fillRect(rx, ry, rw, rh);
ctx.strokeRect(rx, ry, rw, rh);
ctx.restore();
}
}
// ── function: drawObject ──
function drawObject(obj, isPreview) {
const alpha = isPreview ? CONFIG.PREVIEW_OPACITY : 1.0;
ctx.globalAlpha = alpha;
switch (obj.type) {
case 'rect': drawRect(obj); break;
case 'ellipse': drawEllipse(obj); break;
case 'line': drawLine(obj); break;
case 'freehand': drawFreehand(obj); break;
case 'text': drawText(obj); break;
}
ctx.globalAlpha = 1.0;
}
// ── function: drawRect ──
function drawRect(obj) {
const r = Math.min(4, Math.abs(obj.width)/6, Math.abs(obj.height)/6);
if (obj.fillColor && obj.fillColor !== 'transparent') {
ctx.fillStyle = obj.fillColor;
roundRect(ctx, obj.x, obj.y, obj.width, obj.height, r);
ctx.fill();
}
ctx.strokeStyle = obj.strokeColor || CONFIG.DEFAULT_STROKE_COLOR;
ctx.lineWidth = obj.strokeWidth || CONFIG.DEFAULT_STROKE_WIDTH;
ctx.lineJoin = 'round';
roundRect(ctx, obj.x, obj.y, obj.width, obj.height, r);
ctx.stroke();
}
// ── function: drawEllipse ──
function drawEllipse(obj) {
const cx = obj.x + obj.width/2;
const cy = obj.y + obj.height/2;
const rx = Math.abs(obj.width)/2;
const ry = Math.abs(obj.height)/2;
if (obj.fillColor && obj.fillColor !== 'transparent') {
ctx.fillStyle = obj.fillColor;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI*2);
ctx.fill();
}
ctx.strokeStyle = obj.strokeColor || CONFIG.DEFAULT_STROKE_COLOR;
ctx.lineWidth = obj.strokeWidth || CONFIG.DEFAULT_STROKE_WIDTH;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI*2);
ctx.stroke();
}
// ── function: drawLine ──
function drawLine(obj) {
ctx.strokeStyle = obj.strokeColor || CONFIG.DEFAULT_STROKE_COLOR;
ctx.lineWidth = obj.strokeWidth || CONFIG.DEFAULT_STROKE_WIDTH;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(obj.x1, obj.y1);
ctx.lineTo(obj.x2, obj.y2);
ctx.stroke();
// Arrowhead
if (obj.arrowhead) {
const angle = Math.atan2(obj.y2-obj.y1, obj.x2-obj.x1);
const aLen = 10;
ctx.fillStyle = obj.strokeColor || CONFIG.DEFAULT_STROKE_COLOR;
ctx.beginPath();
ctx.moveTo(obj.x2, obj.y2);
ctx.lineTo(obj.x2 - aLen*Math.cos(angle-Math.PI/6), obj.y2 - aLen*Math.sin(angle-Math.PI/6));
ctx.lineTo(obj.x2 - aLen*Math.cos(angle+Math.PI/6), obj.y2 - aLen*Math.sin(angle+Math.PI/6));
ctx.closePath();
ctx.fill();
}
}
// ── function: drawFreehand ──
function drawFreehand(obj) {
const pts = obj.points;
if (pts.length < 2) return;
ctx.strokeStyle = obj.strokeColor || CONFIG.DEFAULT_STROKE_COLOR;
ctx.lineWidth = obj.strokeWidth || CONFIG.DEFAULT_STROKE_WIDTH;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Smooth the path using quadratic curves
ctx.beginPath();
if (pts.length === 2) {
ctx.moveTo(pts[0].x, pts[0].y);
ctx.lineTo(pts[1].x, pts[1].y);
} else {
let i = 0;
ctx.moveTo((pts[0].x + pts[1].x)/2, (pts[0].y + pts[1].y)/2);
for (; i < pts.length - 2; i++) {
const xc = (pts[i].x + pts[i+1].x) / 2;
const yc = (pts[i].y + pts[i+1].y) / 2;
ctx.quadraticCurveTo(pts[i].x, pts[i].y, xc, yc);
}
// Last segment
ctx.quadraticCurveTo(pts[i].x, pts[i].y, pts[i+1].x, pts[i+1].y);
}
ctx.stroke();
}
// ── function: drawText ──
function drawText(obj) {
const fontSize = obj.fontSize || CONFIG.DEFAULT_FONT_SIZE;
ctx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`;
ctx.fillStyle = obj.strokeColor || CONFIG.DEFAULT_STROKE_COLOR;
// Word wrap
const maxWidth = obj.width || 200;
const lines = wrapText(ctx, obj.text || '', maxWidth);
const lineHeight = fontSize * 1.35;
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], obj.x + 4, obj.y + fontSize + i * lineHeight);
}
}
// ── function: wrapText ──
function wrapText(ctx, text, maxWidth) {
const words = text.split(' ');
const lines = [];
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? currentLine + ' ' + word : word;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine = testLine;
}
}
if (currentLine) lines.push(currentLine);
return lines.length ? lines : [''];
}
// ── function: roundRect ──
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
const actualR = Math.min(r, Math.abs(w)/2, Math.abs(h)/2);
ctx.moveTo(x + actualR, y);
ctx.lineTo(x + w - actualR, y);
ctx.arcTo(x + w, y, x + w, y + h, actualR);
ctx.arcTo(x + w, y + h, x, y + h, actualR);
ctx.arcTo(x, y + h, x, y, actualR);
ctx.arcTo(x, y, x + w, y, actualR);
ctx.closePath();
}
// ── function: drawSelectionBox ──
function drawSelectionBox(bounds) {
const pad = 4;
ctx.strokeStyle = CONFIG.SELECTION_COLOR;
ctx.lineWidth = 1.5 / Viewport.zoom;
ctx.setLineDash(CONFIG.SELECTION_DASH.map(d => d / Viewport.zoom));
// Bounding box
ctx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad*2, bounds.h + pad*2);
// Corner handles
const hs = CONFIG.HANDLE_SIZE / Viewport.zoom;
const corners = getSelectionHandles({ ...bounds });
for (const c of corners) {
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = CONFIG.SELECTION_COLOR;
ctx.lineWidth = 1.5 / Viewport.zoom;
ctx.setLineDash([]);
ctx.fillRect(c.x - hs/2, c.y - hs/2, hs, hs);
ctx.strokeRect(c.x - hs/2, c.y - hs/2, hs, hs);
}
ctx.setLineDash([]);
}
// ── function: drawPreview ──
function drawPreview(preview) {
if (preview.type === 'rect') {
const obj = { ...preview, type: 'rect' };
drawObject(obj, true);
} else if (preview.type === 'ellipse') {
const obj = { ...preview, type: 'ellipse' };
drawObject(obj, true);
} else if (preview.type === 'line') {
const obj = { ...preview, type: 'line' };
drawObject(obj, true);
} else if (preview.type === 'freehand') {
const obj = { points: preview.points, strokeColor: preview.strokeColor, strokeWidth: preview.strokeWidth };
drawFreehand(obj);
}
}
// ── function: requestRender ──
function requestRender() {
needsRender = true;
}
// ── let: currentTool ──
// ============================================================
// INTERACTION STATE MACHINE
// ============================================================
let currentTool = 'select';
// ── let: spaceDown ──
// select, pen, rect, ellipse, line, text, eraser
let spaceDown = false;
// ── const: interaction ──
const interaction = {
active: false, // Is any interaction in progress?
tool: null, // Current tool during interaction
startX: 0, startY: 0, // Screen coords of initial mouse down
startWX: 0, startWY: 0, // World coords of initial mouse down
preview: null, // Preview object for shape creation
rubberBand: null, // { active, x1, y1, x2, y2 } in screen coords
movingObjects: false,// Currently dragging selected objects
resizingHandle: null,// Which handle is being dragged (nw/ne/sw/se)
erasing: false, // Eraser mode
textPlacing: false // Waiting for text placement click
};
// ── function: setTool ──
function setTool(tool) {
currentTool = tool;
// Update toolbar UI
document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tool === tool);
});
// Update cursor
updateCursor();
// Hide properties bar when not in select mode (or show if selection exists)
updatePropertiesBar();
}
// ── function: updateCursor ──
function updateCursor() {
const c = canvas;
c.classList.remove('cursor-select', 'cursor-grab', 'cursor-crosshair', 'cursor-text', 'cursor-eraser');
if (spaceDown || interaction.active && interaction.movingObjects) {
c.classList.add('cursor-grabbing');
return;
}
switch(currentTool) {
case 'select': c.classList.add('cursor-select'); break;
case 'pen': c.classList.add('cursor-crosshair'); break;
case 'rect': c.classList.add('cursor-crosshair'); break;
case 'ellipse': c.classList.add('cursor-crosshair'); break;
case 'line': c.classList.add('cursor-crosshair'); break;
case 'text': c.classList.add('cursor-text'); break;
case 'eraser': c.classList.add('cursor-eraser'); break;
}
}
// ── function: updatePropertiesBar ──
function updatePropertiesBar() {
const bar = document.getElementById('properties');
if (selectedIds.size > 0) {
bar.classList.add('visible');
// Update to reflect first selected object's style
const objs = getSelectedObjects();
if (objs.length === 1) {
const obj = objs[0];
document.getElementById('strokeColor').value = obj.strokeColor || CONFIG.DEFAULT_STROKE_COLOR;
document.getElementById('fillColor').value = (obj.fillColor && obj.fillColor !== 'transparent') ? obj.fillColor : '#ffffff';
document.getElementById('strokeWidth').value = String(obj.strokeWidth || CONFIG.DEFAULT_STROKE_WIDTH);
}
} else {
bar.classList.remove('visible');
}
}
// ── function: updateZoomDisplay ──
function updateZoomDisplay() {
const pct = Math.round(Viewport.zoom * 100) + '%';
document.getElementById('zoomLevel').textContent = pct;
}
// ── let: mouseDownPos ──
// ============================================================
// MOUSE EVENT HANDLERS
// ============================================================
let mouseDownPos = null;
// ── function: getMouseWorld ──
// Track for click vs drag distinction
function getMouseWorld(e) {
return Viewport.s2w(e.clientX, e.clientY);
}
// ── function: onPointerDown ──
function onPointerDown(e) {
const screenX = e.clientX;
const screenY = e.clientY;
const world = Viewport.s2w(screenX, screenY);
// Middle mouse or space+left: pan mode
if (e.button === 1 || (e.button === 0 && spaceDown)) {
interaction.active = true;
interaction.tool = 'pan';
interaction.startX = screenX;
interaction.startY = screenY;
canvas.classList.add('cursor-grabbing');
e.preventDefault();
return;
}
if (e.button !== 0) return; // Only handle left mouse button for tools
mouseDownPos = { x: screenX, y: screenY };
switch(currentTool) {
case 'select': onSelectDown(screenX, screenY, world.x, world.y); break;
case 'pen': onPenDown(screenX, screenY, world.x, world.y); break;
case 'rect': onShapeDown('rect', screenX, screenY, world.x, world.y); break;
case 'ellipse': onShapeDown('ellipse', screenX, screenY, world.x, world.y); break;
case 'line': onShapeDown('line', screenX, screenY, world.x, world.y); break;
case 'text': onTextDown(screenX, screenY, world.x, world.y); break;
case 'eraser': onEraserDown(screenX, screenY, world.x, world.y); break;
}
}
// ── function: onPointerMove ──
function onPointerMove(e) {
const screenX = e.clientX;
const screenY = e.clientY;
const world = Viewport.s2w(screenX, screenY);
if (!interaction.active) return;
switch(interaction.tool) {
case 'pan': onPanMove(screenX, screenY); break;
case 'select': onSelectMove(screenX, screenY, world.x, world.y); break;
case 'pen': onPenMove(world.x, world.y); break;
case 'rect': case 'ellipse': case 'line':
onShapeMove(interaction.tool, screenX, screenY, world.x, world.y); break;
case 'eraser': onEraserMove(screenX, screenY, world.x, world.y); break;
}
}
// ── function: onPointerUp ──
function onPointerUp(e) {
const screenX = e.clientX || mouseDownPos?.x || 0;
const screenY = e.clientY || mouseDownPos?.y || 0;
if (!interaction.active) return;
switch(interaction.tool) {
case 'pan': onPanUp(); break;
case 'select': onSelectUp(screenX, screenY); break;
case 'pen': onPenUp(); break;
case 'rect': case 'ellipse': case 'line':
onShapeUp(interaction.tool, screenX, screenY); break;
case 'eraser': onEraserUp(); break;
}
interaction.active = false;
interaction.preview = null;
mouseDownPos = null;
updateCursor();
requestRender();
}
// ── function: onSelectMove ──
function onSelectMove(sx, sy, wx, wy) {
if (interaction.resizingHandle) {
// Resize mode
const dx = (sx - interaction.startX) / Viewport.zoom;
const dy = (sy - interaction.startY) / Viewport.zoom;
for (const id of selectedIds) {
const obj = getObject(id);
if (!obj) continue;
const orig = interaction.resizeOrigins[id];
switch(interaction.resizingHandle) {
case 'se':
if (obj.type === 'rect' || obj.type === 'ellipse') {
let newW = orig.width + dx;
let newH = orig.height + dy;
if (window._lastShiftState) {
// Preserve aspect ratio
const aspect = Math.abs(orig.width / orig.height);
if (Math.abs(dx) > Math.abs(dy)) {
newH = newW / aspect * Math.sign(orig.height);
} else {
newW = newH * aspect * Math.sign(orig.width);
}
}
obj.width = newW;
obj.height = newH;
} else if (obj.type === 'line') {
obj.x2 = orig.x2 + dx;
obj.y2 = orig.y2 + dy;
} else if (obj.type === 'text') {
// Text doesn't resize via handles in this prototype
}
break;
case 'nw':
if (obj.type === 'rect' || obj.type === 'ellipse') {
let newW = orig.width - dx;
let newH = orig.height - dy;
if (window._lastShiftState) {
const aspect = Math.abs(orig.width / orig.height);
if (Math.abs(dx) > Math.abs(dy)) {
newH = newW / aspect * Math.sign(orig.height);
} else {
newW = newH * aspect * Math.sign(orig.width);
}
}
obj.x = orig.x + orig.width - newW;
obj.y = orig.y + orig.height - newH;
obj.width = newW;
obj.height = newH;
} else if (obj.type === 'line') {
obj.x1 = orig.x1 + dx;
obj.y1 = orig.y1 + dy;
}
break;
case 'ne':
if (obj.type === 'rect' || obj.type === 'ellipse') {
let newW = orig.width + dx;
let newH = orig.height - dy;
if (window._lastShiftState) {
const aspect = Math.abs(orig.width / orig.height);
if (Math.abs(dx) > Math.abs(dy)) {
newH = newW / aspect * Math.sign(orig.height);
} else {
newW = newH * aspect * Math.sign(orig.width);
}
}
obj.y = orig.y + orig.height - newH;
obj.width = newW;
obj.height = newH;
} else if (obj.type === 'line') {
obj.x2 = orig.x2 + dx;
obj.y1 = orig.y1 - dy;
}
break;
case 'sw':
if (obj.type === 'rect' || obj.type === 'ellipse') {
let newW = orig.width - dx;
let newH = orig.height + dy;
if (window._lastShiftState) {
const aspect = Math.abs(orig.width / orig.height);
if (Math.abs(dx) > Math.abs(dy)) {
newH = newW / aspect * Math.sign(orig.height);
} else {
newW = newH * aspect * Math.sign(orig.width);
}
}
obj.x = orig.x + orig.width - newW;
obj.width = newW;
obj.height = newH;
} else if (obj.type === 'line') {
obj.x1 = orig.x1 + dx;
obj.y2 = orig.y2 + dy;
}
break;
}
}
} else if (interaction.movingObjects) {
// Move mode
const dx = (sx - interaction.startX) / Viewport.zoom;
const dy = (sy - interaction.startY) / Viewport.zoom;
for (const id of selectedIds) {
const obj = getObject(id);
if (!obj) continue;
const orig = interaction.moveOrigins[id];
switch(obj.type) {
case 'rect':
case 'ellipse':
obj.x = orig.x + dx;
obj.y = orig.y + dy;
break;
case 'line':
obj.x1 = orig.x1 + dx;
obj.y1 = orig.y1 + dy;
obj.x2 = orig.x2 + dx;
obj.y2 = orig.y2 + dy;
break;
case 'freehand':
obj.points = orig.points.map(p => ({ x: p.x + dx, y: p.y + dy }));
break;
case 'text':
obj.x = orig.x + dx;
obj.y = orig.y + dy;
break;
}
}
} else if (interaction.rubberBand) {
// Rubber-band selection mode
interaction.rubberBand.x2 = sx;
interaction.rubberBand.y2 = sy;
}
requestRender();
}
// ── function: onSelectUp ──
function onSelectUp(sx, sy) {
if (interaction.resizingHandle) {
// Commit resize as one undo step
const origData = deepClone(interaction.resizeOrigins);
pushHistory('resize', () => {
for (const [id, orig] of Object.entries(origData)) {
const obj = getObject(id);
if (obj) Object.assign(obj, deepClone(orig));
}
});
} else if (interaction.movingObjects && selectedIds.size > 0) {
// Check if actually moved (not just a click)
const dx = sx - interaction.startX;
const dy = sy - interaction.startY;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
const origData = deepClone(interaction.moveOrigins);
pushHistory('move', () => {
for (const [id, orig] of Object.entries(origData)) {
const obj = getObject(id);
if (obj) Object.assign(obj, deepClone(orig));
}
});
}
} else if (interaction.rubberBand && interaction.rubberBand.active) {
// Commit rubber-band selection
const rb = interaction.rubberBand;
const rx1 = Math.min(rb.x1, rb.x2);
const ry1 = Math.min(rb.y1, rb.y2);
const rx2 = Math.max(rb.x1, rb.x2);
const ry2 = Math.max(rb.y1, rb.y2);
// Only select if the rubber band is big enough (not a tiny click)
if (Math.abs(rx2-rx1) > 5 || Math.abs(ry2-ry1) > 5) {
const newSelection = [];
for (const obj of scene) {
const b = getRectBounds(obj);
// Check if object bounds intersect rubber band rect (screen coords)
const ob = Viewport.w2s(b.x, b.y);
const obW = b.w * Viewport.zoom;
const obH = b.h * Viewport.zoom;
if (!(ob.x + obW < rx1 || ob.x > rx2 || ob.y + obH < ry1 || ob.y > ry2)) {
newSelection.push(obj.id);
}
}
setSelected(newSelection);
}
}
interaction.resizingHandle = null;
interaction.movingObjects = false;
interaction.rubberBand = null;
updatePropertiesBar();
}
// ── function: onPenDown ──
// ============================================================
// PEN TOOL INTERACTIONS
// ============================================================
function onPenDown(sx, sy, wx, wy) {
const style = getDefaultStyle();
interaction.active = true;
interaction.tool = 'pen';
interaction.startX = sx;
interaction.startY = sy;
// Create a new freehand object
const obj = addObject({
id: uid(),
type: 'freehand',
points: [{ x: wx, y: wy }],
strokeColor: style.strokeColor,
strokeWidth: style.strokeWidth
});
interaction.currentObjId = obj.id;
}
// ── function: onPenMove ──
function onPenMove(wx, wy) {
const obj = getObject(interaction.currentObjId);
if (!obj) return;
// Add point with some distance threshold to avoid too many points
const lastPt = obj.points[obj.points.length - 1];
const d = dist(lastPt.x, lastPt.y, wx, wy);
if (d > 2 / Viewport.zoom || obj.points.length < 2) {
obj.points.push({ x: wx, y: wy });
}
requestRender();
}
// ── function: onPenUp ──
function onPenUp() {
const obj = getObject(interaction.currentObjId);
if (obj && obj.points.length > 1) {
pushHistory('create', () => removeObject(obj.id));
setSelected([obj.id]);
updatePropertiesBar();
} else {
removeObject(obj?.id);
}
interaction.currentObjId = null;
}
// ── function: onShapeMove ──
function onShapeMove(type, sx, sy, wx, wy) {
if (!interaction.preview) return;
const dx = wx - interaction.startWX;
const dy = wy - interaction.startWY;
// Allow holding Shift to constrain aspect ratio or draw from center
let finalX = dx, finalY = dy;
if (window._lastShiftState && type !== 'line') {
// Constrain to square/circle
const size = Math.max(Math.abs(dx), Math.abs(dy));
finalX = size * Math.sign(dx || 1);
finalY = size * Math.sign(dy || 1);
}
if (type === 'line') {
interaction.preview.x2 = wx;
interaction.preview.y2 = wy;
interaction.preview.arrowhead = window._lastShiftState;
} else {
interaction.preview.width = finalX;
interaction.preview.height = finalY;
}
requestRender();
}
// ── function: onShapeUp ──
function onShapeUp(type, sx, sy) {
if (!interaction.preview) return;
const preview = interaction.preview;
let obj;
if (type === 'line') {
// Only create if line has meaningful length
const len = dist(preview.x1, preview.y1, preview.x2, preview.y2);
if (len < 5 / Viewport.zoom) {
interaction.preview = null;
return;
}
obj = addObject({
id: uid(), type: 'line',
x1: preview.x1, y1: preview.y1,
x2: preview.x2, y2: preview.y2,
arrowhead: preview.arrowhead || false,
strokeColor: preview.strokeColor,
strokeWidth: preview.strokeWidth
});
} else {
// Only create if shape has meaningful size
if (Math.abs(preview.width) < 5/Viewport.zoom && Math.abs(preview.height) < 5/Viewport.zoom) {
interaction.preview = null;
return;
}
obj = addObject({
id: uid(), type: preview.type,
x: preview.x, y: preview.y,
width: preview.width, height: preview.height,
strokeColor: preview.strokeColor,
fillColor: preview.fillColor || 'transparent',
strokeWidth: preview.strokeWidth
});
}
pushHistory('create', () => removeObject(obj.id));
setSelected([obj.id]);
interaction.preview = null;
updatePropertiesBar();
}
// ── function: onTextDown ──
// ============================================================
// TEXT TOOL INTERACTIONS
// ============================================================
function onTextDown(sx, sy, wx, wy) {
const textInput = document.getElementById('textInput');
// Position the textarea at the click location (screen coords)
textInput.style.display = 'block';
textInput.style.left = sx + 'px';
textInput.style.top = sy + 'px';
textInput.value = '';
textInput.focus();
interaction.textPlacing = true;
interaction.textWorldX = wx;
interaction.textWorldY = wy;
}
// ── function: commitText ──
function commitText() {
const textInput = document.getElementById('textInput');
const text = textInput.value.trim();
textInput.style.display = 'none';
if (text) {
const style = getDefaultStyle();
// Measure text width
ctx.font = `${CONFIG.DEFAULT_FONT_SIZE}px -apple-system, BlinkMacSystemFont, sans-serif`;
const metrics = ctx.measureText(text);
const obj = addObject({
id: uid(), type: 'text',
x: interaction.textWorldX,
y: interaction.textWorldY,
text: text,
width: Math.max(metrics.width + 8, 60),
height: CONFIG.DEFAULT_FONT_SIZE * 1.35,
fontSize: CONFIG.DEFAULT_FONT_SIZE,
strokeColor: style.strokeColor
});
pushHistory('create', () => removeObject(obj.id));
setSelected([obj.id]);
updatePropertiesBar();
}
interaction.textPlacing = false;
}
// ── function: onEraserDown ──
// ============================================================
// ERASER TOOL INTERACTIONS
// ============================================================
function onEraserDown(sx, sy, wx, wy) {
interaction.active = true;
interaction.tool = 'eraser';
eraseAt(wx, wy);
}
// ── function: onEraserMove ──
function onEraserMove(sx, sy, wx, wy) {
eraseAt(wx, wy);
}
// ── function: onEraserUp ──
function onEraserUp() {}
// ── function: eraseAt ──
function eraseAt(wx, wy) {
const radius = CONFIG.ERASER_RADIUS / Viewport.zoom;
let erased = false;
for (const obj of [...scene]) {
if (hitTestObject(obj, wx, wy)) {
removeObject(obj.id);
selectedIds.delete(obj.id);
erased = true;
}
}
if (erased) {
requestRender();
}
}
// ── function: onPanMove ──
// ============================================================
// PAN INTERACTIONS
// ============================================================
function onPanMove(sx, sy) {
const dx = sx - interaction.startX;
const dy = sy - interaction.startY;
Viewport.pan(dx, dy);
interaction.startX = sx;
interaction.startY = sy;
requestRender();
}
// ── function: onPanUp ──
function onPanUp() {}
// ── function: findObjectAt ──
// ============================================================
// FIND OBJECT AT WORLD POSITION (top-most first)
// ============================================================
function findObjectAt(wx, wy) {
// Search in reverse order (top-most objects first)
for (let i = scene.length - 1; i >= 0; i--) {
if (hitTestObject(scene[i], wx, wy)) return scene[i];
}
return null;
}
// ── function: onKeyDown ──
// ============================================================
// KEYBOARD HANDLERS
// ============================================================
function onKeyDown(e) {
window._lastShiftState = e.shiftKey;
// Don't handle keys when typing in text input
if (document.getElementById('textInput').style.display !== 'none' &&
document.activeElement === document.getElementById('textInput')) return;
// Space for pan
if (e.code === 'Space' && !spaceDown) {
spaceDown = true;
e.preventDefault();
canvas.classList.add('cursor-grab');
updateCursor();
}
// Tool shortcuts
if (!e.ctrlKey && !e.metaKey) {
switch(e.key.toLowerCase()) {
case 'v': setTool('select'); break;
case 'p': setTool('pen'); break;
case 'r': setTool('rect'); 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();
}
requestRender();
return;
}
// Delete selected objects
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.size > 0) {
const removed = getSelectedObjects().map(o => deepClone(o));
for (const id of [...selectedIds]) removeObject(id);
pushHistory('delete', () => {
for (const obj of removed) addObject(obj);
setSelected(removed.map(o => o.id));
});
selectedIds.clear();
updatePropertiesBar();
requestRender();
return;
}
// Arrow key nudging
if (selectedIds.size > 0 && ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key)) {
e.preventDefault();
const step = e.shiftKey ? 10 : 1;
let dx = 0, dy = 0;
switch(e.key) {
case 'ArrowUp': dy = -step; break;
case 'ArrowDown': dy = step; break;
case 'ArrowLeft': dx = -step; break;
case 'ArrowRight': dx = step; break;
}
const origData = {};
for (const id of selectedIds) {
const obj = getObject(id);
if (obj) origData[id] = deepClone(obj);
}
for (const id of selectedIds) {
const obj = getObject(id);
if (!obj) continue;
switch(obj.type) {
case 'rect': case 'ellipse': case 'text':
obj.x += dx; obj.y += dy; break;
case 'line':
obj.x1 += dx; obj.y1 += dy; obj.x2 += dx; obj.y2 += dy; break;
case 'freehand':
obj.points = obj.points.map(p => ({ x: p.x + dx, y: p.y + dy })); break;
}
}
pushHistory('nudge', () => {
for (const [id, orig] of Object.entries(origData)) {
const obj = getObject(id);
if (obj) Object.assign(obj, deepClone(orig));
}
});
requestRender();
}
// Escape to deselect / cancel
if (e.key === 'Escape') {
clearSelection();
interaction.active = false;
interaction.preview = null;
updatePropertiesBar();
requestRender();
}
}
// ── function: onKeyUp ──
function onKeyUp(e) {
window._lastShiftState = e.shiftKey;
if (e.code === 'Space') {
spaceDown = false;
updateCursor();
}
}
// ── function: onWheel ──
// ============================================================
// WHEEL HANDLER (zoom toward cursor)
// ============================================================
function onWheel(e) {
e.preventDefault();
const zoomFactor = e.deltaY < 0 ? CONFIG.ZOOM_STEP : 1/CONFIG.ZOOM_STEP;
Viewport.zoomAt(e.clientX, e.clientY, Viewport.zoom * zoomFactor);
updateZoomDisplay();
requestRender();
}
// ── function: renderLoop ──
// ============================================================
// RENDER LOOP
// ============================================================
function renderLoop() {
if (needsRender) render();
requestAnimationFrame(renderLoop);
}
// ── function: drawGrid ──
function drawGrid(w, h) {
const spacing = CONFIG.GRID_SPACING;
const dotR = CONFIG.GRID_DOT_RADIUS / Viewport.zoom;
// Calculate visible world bounds
const tl = Viewport.s2w(0, 0);
const br = Viewport.s2w(w, h);
// Snap to grid
const startX = Math.floor(tl.x / spacing) * spacing;
const startY = Math.floor(tl.y / spacing) * spacing;
const endX = Math.ceil(br.x / spacing) * spacing;
const endY = Math.ceil(br.y / spacing) * spacing;
ctx.fillStyle = CONFIG.GRID_COLOR;
for (let x = startX; x <= endX; x += spacing) {
for (let y = startY; y <= endY; y += spacing) {
ctx.beginPath();
ctx.arc(x, y, dotR, 0, Math.PI * 2);
ctx.fill();
}
}
}
// ── function: onShapeDown ──
function onShapeDown(type, sx, sy, wx, wy) {
const style = getDefaultStyle();
interaction.active = true;
interaction.tool = type;
interaction.startX = sx;
interaction.startY = sy;
interaction.startWX = wx;
interaction.startWY = wy;
if (type === 'line') {
interaction.preview = {
type: 'line',
x1: wx, y1: wy, x2: wx, y2: wy,
arrowhead: window._lastShiftState || false,
strokeColor: style.strokeColor,
strokeWidth: style.strokeWidth
};
} else {
interaction.preview = {
type: type,
x: wx, y: wy, width: 0, height: 0,
strokeColor: style.strokeColor,
fillColor: style.fillColor || 'transparent',
strokeWidth: style.strokeWidth
};
}
requestRender();
}
// ── function: onSelectDown ──
function onSelectDown(sx, sy, wx, wy) {
// Check if clicking a handle first
const bounds = getSelectionBounds();
if (bounds && selectedIds.size > 0) {
const handles = getSelectionHandles(bounds);
const hitHandle = hitTestHandle(handles, wx, wy);
if (hitHandle) {
interaction.active = true;
interaction.tool = 'select';
interaction.startX = sx;
interaction.startY = sy;
interaction.resizingHandle = hitHandle;
interaction.movingObjects = false;
interaction.rubberBand = null;
// Store original bounds for each selected object
interaction.resizeOrigins = {};
for (const id of selectedIds) {
const obj = getObject(id);
if (obj) interaction.resizeOrigins[id] = deepClone(obj);
}
return;
}
}
// Check if clicking on an object
const hitObj = findObjectAt(wx, wy);
if (hitObj) {
if (window._lastShiftState) {
// Toggle selection
if (selectedIds.has(hitObj.id)) {
removeSelection(hitObj.id);
} else {
addSelection(hitObj.id);
}
} else if (selectedIds.has(hitObj.id) && selectedIds.size > 1) {
// Clicking on an already-selected object in a multi-selection: keep selection, enter move mode
} else {
// Clicking on unselected object or single selection: select just this one
setSelected([hitObj.id]);
}
interaction.active = true;
interaction.tool = 'select';
interaction.startX = sx;
interaction.startY = sy;
interaction.movingObjects = true;
interaction.rubberBand = null;
// Store original positions for undo
interaction.moveOrigins = {};
for (const id of selectedIds) {
const obj = getObject(id);
if (obj) interaction.moveOrigins[id] = deepClone(obj);
}
} else {
// Clicking on empty space
if (selectedIds.size > 0 && !window._lastShiftState) {
// Objects are selected but clicked on empty space: enter move mode for all selected objects
interaction.active = true;
interaction.tool = 'select';
interaction.startX = sx;
interaction.startY = sy;
interaction.movingObjects = true;
interaction.rubberBand = null;
// Store original positions for undo
interaction.moveOrigins = {};
for (const id of selectedIds) {
const obj = getObject(id);
if (obj) interaction.moveOrigins[id] = deepClone(obj);
}
} else {
// No selection or shift held: start rubber-band selection
if (!window._lastShiftState) {
clearSelection();
}
interaction.active = true;
interaction.tool = 'select';
interaction.startX = sx;
interaction.startY = sy;
interaction.movingObjects = false;
interaction.rubberBand = { active: true, x1: sx, y1: sy, x2: sx, y2: sy };
}
}
updatePropertiesBar();
requestRender();
}
// ── main ──
// ============================================================
// MAIN INITIALIZATION
// ============================================================
(function init() {
// Initialize canvas
initCanvas();
// Load saved scene from localStorage
loadFromStorage();
// Set initial tool
setTool('select');
updateZoomDisplay();
// --- Canvas event listeners ---
canvas.addEventListener('pointerdown', onPointerDown, { passive: false });
window.addEventListener('pointermove', onPointerMove, { passive: false });
window.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('wheel', onWheel, { passive: false });
// Prevent context menu on right click
canvas.addEventListener('contextmenu', e => e.preventDefault());
// --- Keyboard listeners ---
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
// --- Text input events ---
const textInput = document.getElementById('textInput');
textInput.addEventListener('blur', commitText);
textInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
commitText();
}
if (e.key === 'Escape') {
textInput.style.display = 'none';
interaction.textPlacing = false;
}
});
// --- Toolbar button events ---
document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => {
btn.addEventListener('click', () => setTool(btn.dataset.tool));
});
// Undo/Redo buttons
document.getElementById('undoBtn').addEventListener('click', () => {
undo(); requestRender();
});
document.getElementById('redoBtn').addEventListener('click', () => {
redo(); requestRender();
});
// Zoom controls
document.getElementById('zoomInBtn').addEventListener('click', () => {
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
Viewport.zoomAt(cx, cy, Viewport.zoom * CONFIG.ZOOM_STEP);
updateZoomDisplay(); requestRender();
});
document.getElementById('zoomOutBtn').addEventListener('click', () => {
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
Viewport.zoomAt(cx, cy, Viewport.zoom / CONFIG.ZOOM_STEP);
updateZoomDisplay(); requestRender();
});
document.getElementById('resetViewBtn').addEventListener('click', () => {
Viewport.reset();
updateZoomDisplay(); requestRender();
});
document.getElementById('zoomLevel').addEventListener('click', () => {
Viewport.reset();
updateZoomDisplay(); requestRender();
});
// --- Properties bar events ---
document.getElementById('strokeColor').addEventListener('input', applyStyleToSelection);
document.getElementById('fillColor').addEventListener('input', applyStyleToSelection);
document.getElementById('strokeWidth').addEventListener('change', applyStyleToSelection);
// --- Auto-save on changes (debounced) ---
let saveTimer = null;
const origRequestRender = requestRender;
// Override to also trigger auto-save
window._autoSave = function() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveToStorage();
}, 500);
};
// Patch pushHistory to trigger auto-save
const origPushHistory = pushHistory;
// --- Start render loop ---
requestRender();
renderLoop();
// Hide hint bar after a few seconds
setTimeout(() => {
const hint = document.getElementById('hintBar');
if (hint) {
hint.style.transition = 'opacity 1s ease';
hint.style.opacity = '0';
setTimeout(() => hint.remove(), 1000);
}
}, 5000);
})();
</script>
</body>
</html>