Whiteboard
ling 3.0 tiny:free · full

Browser check: Artifact runtime error. This is a technical check, not a quality review.
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 37,599 bytes · SHA-256 0ff70f66eb20
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Whiteboard — Collaborative Whiteboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1e1e2e;
color: #c8c8d0;
height: 100vh;
width: 100vw;
user-select: none;
-webkit-user-select: none;
}
#app { display: flex; height: 100vh; position: relative; }
#toolbar {
width: 64px;
background: #1a1a2e;
border-right: 1px solid #2a2a4e;
display: flex;
flex-direction: column;
padding: 8px 0;
z-index: 100;
}
#toolbar .logo {
font-size: 12px;
text-align: center;
font-weight: 700;
color: #6688cc;
letter-spacing: 1px;
margin-bottom: 4px;
}
#toolbar .tool-group { display: flex; flex-direction: column; gap: 2px; }
#toolbar .tool-btn {
display: flex; align-items: center; justify-content: center;
width: 48px; height: 48px;
border: none; background: transparent;
color: #8899bb; border-radius: 4px;
cursor: pointer; font-size: 18px;
transition: all 0.1s; margin: 1px;
}
#toolbar .tool-btn:hover { background: #2a2a4e; color: #ccc; }
#toolbar .tool-btn.active {
background: #2a3a5e; color: #fff;
box-shadow: inset 0 0 0 1px #4a6a9a;
}
#toolbar .tool-btn:active { background: #3a4a6e; }
#toolbar .tool-sep { height: 1px; background: #2a2a4e; margin: 4px 0; }
#canvas-wrap { flex: 1; position: relative; overflow: hidden; cursor: grab; }
#canvas-wrap.grabbing { cursor: grabbing; }
#canvas-wrap canvas { display: block; width: 100%; height: 100%; cursor: grab; }
#properties {
height: 72px;
background: #1a1a2e;
border-top: 1px solid #2a2a4e;
display: flex; align-items: center;
padding: 0 12px; gap: 12px; z-index: 100;
font-size: 12px;
}
#properties .prop-row { display: flex; align-items: center; gap: 6px; }
#properties label { color: #8899bb; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
#properties input[type="color"] {
width: 28px; height: 24px;
border: 1px solid #3a3a5e; border-radius: 3px;
background: transparent; cursor: pointer; padding: 0;
}
#properties input[type="range"] {
width: 80px; accent-color: #6688cc;
}
#properties .prop-value { color: #ccc; font-variant-numeric: tabular-nums; min-width: 36px; text-align: right; }
#zoom-info { margin-left: auto; color: #8899bb; font-size: 12px; font-variant-numeric: tabular-nums; }
#zoom-info span { color: #6688cc; font-weight: 600; }
#reset-btn {
background: none; border: 1px solid #3a3a5e;
color: #8899bb; padding: 3px 8px; border-radius: 3px;
cursor: pointer; font-size: 11px; transition: all 0.1s;
}
#reset-btn:hover { background: #2a2a4e; color: #ccc; }
#hint {
position: fixed; bottom: 12px; left: 50%;
transform: translateX(-50%);
background: rgba(26,26,46,0.9); padding: 4px 14px;
border-radius: 4px; font-size: 11px; color: #8899bb;
pointer-events: none; z-index: 200; white-space: nowrap;
}
#status {
position: fixed; top: 8px; right: 16px;
font-size: 11px; color: #6688cc; z-index: 200; pointer-events: none;
}
#text-overlay {
position: fixed; border: 2px solid #4a6a9a;
background: #fff; color: #1a1a2e; padding: 4px 8px;
font-size: 14px; border-radius: 3px; outline: none;
cursor: text; z-index: 300; display: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
#text-overlay:hover { background: #fafafa; }
#text-overlay:focus { border-color: #6688cc; }
</style>
</head>
<body>
<div id="app">
<div id="toolbar">
<div class="logo">WHITEBOARD</div>
<div class="tool-group">
<button class="tool-btn active" data-tool="select" title="Select (V)">✦</button>
<button class="tool-btn" data-tool="freehand" title="Freehand (P)">✿</button>
<button class="tool-btn" data-tool="rect" title="Rectangle (R)">□</button>
<button class="tool-btn" data-tool="ellipse" title="Ellipse (O)">⬭</button>
<button class="tool-btn" data-tool="line" title="Line/Arrow (L)">║</button>
<button class="tool-btn" data-tool="text" title="Text (T)">T</button>
<button class="tool-btn" data-tool="eraser" title="Eraser (E)">◌</button>
</div>
<div class="tool-sep"></div>
<button class="tool-btn" id="btn-undo" title="Undo (Ctrl+Z)">↩</button>
<button class="tool-btn" id="btn-redo" title="Redo (Ctrl+Y)">↪</button>
<div class="tool-sep"></div>
<button class="tool-btn" id="btn-clear" title="Clear All">✕</button>
<button class="tool-btn" id="btn-grid" title="Toggle Grid">▦</button>
</div>
<div id="canvas-wrap">
<canvas id="canvas"></canvas>
<div id="text-overlay"></div>
</div>
<div id="properties">
<div class="prop-row">
<label>Stroke</label>
<input type="color" id="prop-stroke" value="#333333">
</div>
<div class="prop-row">
<label>Fill</label>
<input type="color" id="prop-fill" value="#ffffff">
</div>
<div class="prop-row">
<label>Width</label>
<input type="range" id="prop-width" min="0.5" max="10" step="0.5" value="1">
<span class="prop-value" id="prop-width-val">1</span>
</div>
<div id="zoom-info">
<span id="zoom-level">100%</span>
<button id="reset-view">↺</button>
</div>
</div>
</div>
<div id="hint">Space+drag to pan · Middle-drag to pan · Scroll to zoom · Del to select & delete · Ctrl+Z to undo</div>
<div id="status"></div>
<script>
(function() {
// =============================================================================
// Constants & Tunables
// =============================================================================
const C = {
ZOOM_MIN: 0.25,
ZOOM_MAX: 4.0,
ZOOM_IN: 1.25,
ZOOM_OUT: 0.8,
ZOOM_TO: 1.2,
HANDLE_SIZE: 6,
HIT_TOLERANCE: 3,
GRID_SIZE: 20,
MIN_CANVAS_SIZE: 400,
STROKE_COLOR: '#333333',
FILL_COLOR: '#ffffff',
DEFAULT_STROKE: '#333333',
DEFAULT_FILL: '#ffffff',
DEFAULT_WIDTH: 1,
};
const COLORS = [
'#333333','#cc3333','#3366cc','#33cc33','#cc9933','#9933cc',
'#33cccc','#cc33cc','#33cc33','#cc6633','#66cc33','#3366cc',
'#6633cc','#cc3366','#6666cc','#cc6666','#999999','#666666',
];
// =============================================================================
// State
// =============================================================================
let canvas, ctx;
let canvasW = 0, canvasH = 0;
let zoom = 1.0, panX = 0, panY = 0;
let mouseDown = false, mouseButton = 0;
let mouseX = 0, mouseY = 0;
let prevMouseX = 0, prevMouseY = 0;
let hoveredObject = null;
let selectedIds = new Set();
let isMultiSelecting = false;
let pendingStart = null;
let currentTool = 'select';
let freehandPoints = [];
let freehandPath = null;
let erasing = false;
let erasingIds = new Set();
let lastStrokePoint = null;
let gridVisible = true;
let undoStack = [];
let redoStack = [];
let dirty = false;
let idCounter = 0;
let textBoxId = null;
// =============================================================================
// Object Model
// =============================================================================
function createObject(type, x, y, opts) {
opts = opts || {};
const id = 'obj_' + (idCounter++);
const obj = {
id: id,
type: type,
x: x,
y: y,
strokeColor: opts.strokeColor || C.STROKE_COLOR,
fillColor: opts.fillColor || C.FILL_COLOR,
strokeWidth: opts.strokeWidth !== undefined ? opts.strokeWidth : C.DEFAULT_WIDTH,
text: opts.text || '',
cx: 0,
cy: 0,
};
if (type === 'rect') {
obj.width = opts.width || 100;
obj.height = opts.height || 60;
} else if (type === 'ellipse') {
obj.rx = opts.rx || 60;
obj.ry = opts.ry || 40;
} else if (type === 'line') {
obj.x2 = opts.x2 || x;
obj.y2 = opts.y2 || y;
obj.strokeWidth = opts.strokeWidth || 2;
}
return obj;
}
// =============================================================================
// Scene
// =============================================================================
let scene = [];
// =============================================================================
// Viewport Transform
// =============================================================================
function screenToWorld(sx, sy) {
return { wx: (sx - panX) / zoom, wy: (sy - panY) / zoom };
}
function worldToScreen(wx, wy) {
return { sx: wx * zoom + panX, sy: wy * zoom + panY };
}
function getVisibleBounds() {
const tl = screenToWorld(0, 0);
const br = screenToWorld(canvasW, canvasH);
return {
minX: Math.min(tl.wx, br.wx), maxX: Math.max(tl.wx, br.wx),
minY: Math.min(tl.wy, br.wy), maxY: Math.max(tl.wy, br.wy)
};
}
// =============================================================================
// Canvas Setup
// =============================================================================
function initCanvas() {
canvas = document.getElementById('canvas');
ctx = canvas.getContext('2d');
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
}
function resizeCanvas() {
const wrap = document.getElementById('canvas-wrap');
const dpr = window.devicePixelRatio || 1;
canvasW = wrap.clientWidth;
canvasH = wrap.clientHeight;
canvas.width = canvasW * dpr;
canvas.height = canvasH * dpr;
canvas.style.width = canvasW + 'px';
canvas.style.height = canvasH + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
dirty = true;
}
// =============================================================================
// Rendering
// =============================================================================
function render() {
ctx.clearRect(0, 0, canvasW, canvasH);
if (gridVisible) drawGrid();
for (const obj of scene) {
drawObject(obj);
}
if (selectedIds.size > 0) drawSelection();
if (hoveredObject && hoveredObject.id) {
drawHoverHandle(hoveredObject);
}
dirty = false;
}
function drawGrid() {
const bounds = getVisibleBounds();
const sp = C.GRID_SIZE;
const startX = Math.floor(bounds.minX / sp) * sp;
const startY = Math.floor(bounds.minY / sp) * sp;
const endX = Math.ceil(bounds.maxX / sp) * sp;
const endY = Math.ceil(bounds.maxY / sp) * sp;
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 0.5;
ctx.beginPath();
for (let x = startX; x <= endX; x += sp) {
const s = worldToScreen(x, 0);
ctx.moveTo(s.sx, 0);
ctx.lineTo(s.sx, canvasH);
}
for (let y = startY; y <= endY; y += sp) {
const s = worldToScreen(0, y);
ctx.moveTo(0, s.sy);
ctx.lineTo(canvasW, s.sy);
}
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.12)';
for (let x = startX; x <= endX; x += sp) {
for (let y = startY; y <= endY; y += sp) {
const s = worldToScreen(x, y);
ctx.beginPath();
ctx.arc(s.sx, s.sy, 1.5, 0, Math.PI * 2);
ctx.fill();
}
}
}
function drawObject(obj) {
ctx.save();
if (obj.type === 'rect') {
drawRectangle(obj);
} else if (obj.type === 'ellipse') {
drawEllipse(obj);
} else if (obj.type === 'line') {
drawLine(obj);
} else if (obj.type === 'freehand') {
drawFreehand(obj);
} else if (obj.type === 'text') {
drawText(obj);
}
ctx.restore();
}
function drawRectangle(obj) {
const halfW = obj.width / 2;
const halfH = obj.height / 2;
const x = obj.x - halfW;
const y = obj.y - halfH;
ctx.strokeStyle = obj.strokeColor;
ctx.lineWidth = obj.strokeWidth;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.setLineDash([]);
ctx.strokeRect(x, y, obj.width, obj.height);
if (obj.fillColor && obj.fillColor !== '#ffffff') {
ctx.fillStyle = obj.fillColor;
ctx.fillRect(x, y, obj.width, obj.height);
}
const handleSize = C.HANDLE_SIZE;
ctx.fillStyle = '#cc3333';
ctx.strokeStyle = '#cc3333';
ctx.lineWidth = 1.5;
drawHandleAt(ctx, x, y, handleSize);
drawHandleAt(ctx, x + obj.width, y, handleSize);
drawHandleAt(ctx, x, y + obj.height, handleSize);
drawHandleAt(ctx, x + obj.width, y + obj.height, handleSize);
}
function drawEllipse(obj) {
const cx = obj.x;
const cy = obj.y;
const rx = obj.rx;
const ry = obj.ry;
ctx.strokeStyle = obj.strokeColor;
ctx.lineWidth = obj.strokeWidth;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.setLineDash([]);
ctx.strokeRect(cx - rx, cy - ry, rx * 2, ry * 2);
if (obj.fillColor && obj.fillColor !== '#ffffff') {
ctx.fillStyle = obj.fillColor;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.fill();
}
const handleSize = C.HANDLE_SIZE;
ctx.fillStyle = '#cc3333';
ctx.strokeStyle = '#cc3333';
ctx.lineWidth = 1.5;
drawHandleAt(ctx, cx - rx, cy - ry, handleSize);
drawHandleAt(ctx, cx + rx, cy - ry, handleSize);
drawHandleAt(ctx, cx - rx, cy + ry, handleSize);
drawHandleAt(ctx, cx + rx, cy + ry, handleSize);
}
function drawLine(obj) {
ctx.strokeStyle = obj.strokeColor;
ctx.lineWidth = obj.strokeWidth;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(obj.x, obj.y);
ctx.lineTo(obj.x2, obj.y2);
ctx.stroke();
const dx = obj.x2 - obj.x;
const dy = obj.y2 - obj.y;
const len = Math.sqrt(dx * dx + dy * dy);
if (len > 0) {
const nx = dx / len;
const ny = dy / len;
const arrowLen = 10;
const arrowX = obj.x2 - nx * (len - arrowLen * 0.3);
const arrowY = obj.y2 - ny * (len - arrowLen * 0.3);
ctx.beginPath();
ctx.setLineDash([]);
ctx.fillStyle = obj.strokeColor;
ctx.moveTo(arrowX, arrowY);
ctx.lineTo(arrowX - nx * arrowLen + ny * arrowLen * 0.5, arrowY - ny * arrowLen - nx * arrowLen * 0.5);
ctx.lineTo(arrowX - nx * arrowLen - ny * arrowLen * 0.5, arrowY - ny * arrowLen + nx * arrowLen * 0.5);
ctx.closePath();
ctx.fill();
}
ctx.fillStyle = '#cc3333';
ctx.strokeStyle = '#cc3333';
ctx.lineWidth = 1.5;
drawHandleAt(ctx, obj.x2, obj.y2, C.HANDLE_SIZE);
}
function drawFreehand(obj) {
if (!obj.points || obj.points.length === 0) return;
ctx.strokeStyle = obj.strokeColor;
ctx.lineWidth = obj.strokeWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(obj.points[0].x, obj.points[0].y);
for (let i = 1; i < obj.points.length; i++) {
ctx.lineTo(obj.points[i].x, obj.points[i].y);
}
ctx.stroke();
if (obj.fillColor && obj.fillColor !== '#ffffff') {
ctx.fillStyle = obj.fillColor;
ctx.beginPath();
ctx.moveTo(obj.points[0].x, obj.points[0].y);
for (let i = 1; i < obj.points.length; i++) {
ctx.lineTo(obj.points[i].x, obj.points[i].y);
}
ctx.closePath();
ctx.fill();
}
}
function drawText(obj) {
const cx = obj.x;
const cy = obj.y;
ctx.fillStyle = obj.fillColor || '#ffffff';
ctx.strokeStyle = obj.strokeColor;
ctx.lineWidth = obj.strokeWidth;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.setLineDash([]);
ctx.strokeRect(cx - 60, cy - 14, 120, 28);
ctx.fillRect(cx - 60, cy - 14, 120, 28);
ctx.fillStyle = '#1a1a2e';
ctx.font = '12px monospace, sans-serif';
ctx.fillText(obj.text, cx - 50, cy + 1);
ctx.fillStyle = '#cc3333';
ctx.strokeStyle = '#cc3333';
ctx.lineWidth = 1.5;
drawHandleAt(ctx, cx + 60, cy, C.HANDLE_SIZE);
}
function drawHandleAt(ctx, x, y, size) {
ctx.fillStyle = '#cc3333';
ctx.strokeStyle = '#cc3333';
ctx.lineWidth = 1.5;
const s = size / 2;
ctx.beginPath();
ctx.moveTo(x - s, y - s);
ctx.lineTo(x + s, y - s);
ctx.lineTo(x + s, y + s);
ctx.lineTo(x - s, y + s);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
function drawHoverHandle(obj) {
const s = C.HANDLE_SIZE / 2;
ctx.fillStyle = '#cc3333';
ctx.strokeStyle = '#cc3333';
ctx.lineWidth = 1.5;
if (obj.type === 'rect') {
const halfW = obj.width / 2;
const halfH = obj.height / 2;
drawHandleAt(ctx, obj.x - halfW, obj.y - halfH, s);
drawHandleAt(ctx, obj.x + halfW, obj.y - halfH, s);
drawHandleAt(ctx, obj.x - halfW, obj.y + halfH, s);
drawHandleAt(ctx, obj.x + halfW, obj.y + halfH, s);
} else if (obj.type === 'ellipse') {
drawHandleAt(ctx, obj.x - obj.rx, obj.y - obj.ry, s);
drawHandleAt(ctx, obj.x + obj.rx, obj.y - obj.ry, s);
drawHandleAt(ctx, obj.x - obj.rx, obj.y + obj.ry, s);
drawHandleAt(ctx, obj.x + obj.rx, obj.y + obj.ry, s);
} else if (obj.type === 'line') {
drawHandleAt(ctx, obj.x2, obj.y2, s);
}
}
// =============================================================================
// Hit Testing
// =============================================================================
function hitTest(wx, wy, tolerance) {
tolerance = tolerance || C.HIT_TOLERANCE;
for (let i = scene.length - 1; i >= 0; i--) {
const obj = scene[i];
if (obj.type === 'rect') {
const halfW = obj.width / 2;
const halfH = obj.height / 2;
const sx = obj.x - halfW;
const sy = obj.y - halfH;
const ex = obj.x + halfW;
const ey = obj.y + halfH;
if (wx >= sx - tolerance && wx <= ex + tolerance &&
wy >= sy - tolerance && wy <= ey + tolerance) {
return obj;
}
} else if (obj.type === 'ellipse') {
const dist = pointToEllipseDist(wx, wy, obj.x, obj.y, obj.rx, obj.ry);
if (dist <= tolerance) return obj;
} else if (obj.type === 'line') {
const dist = pointToLineDist(wx, wy, obj.x, obj.y, obj.x2, obj.y2);
if (dist <= tolerance) return obj;
} else if (obj.type === 'freehand') {
for (const p of obj.points) {
const dx = wx - p.x;
const dy = wy - p.y;
if (dx * dx + dy * dy <= tolerance * tolerance) return obj;
}
if (obj.points.length > 1) {
for (let i = 0; i < obj.points.length - 1; i++) {
const p1 = obj.points[i];
const p2 = obj.points[i + 1];
const d = pointToLineDist(wx, wy, p1.x, p1.y, p2.x, p2.y);
if (d <= tolerance) return obj;
}
}
} else if (obj.type === 'text') {
const dx = wx - obj.x;
const dy = wy - obj.y;
if (Math.abs(dx) < 60 + tolerance && Math.abs(dy) < 14 + tolerance) return obj;
}
}
return null;
}
function pointToEllipseDist(px, py, cx, cy, rx, ry) {
const dx = (px - cx) / rx;
const dy = (py - cy) / ry;
return Math.sqrt(dx * dx + dy * dy);
}
function pointToLineDist(px, py, x1, y1, x2, y2) {
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 0.001) return Math.sqrt((px - x1) * (px - x1) + (py - y1) * (py - y1));
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (len * len)));
const projX = x1 + t * dx;
const projY = y1 + t * dy;
return Math.sqrt((px - projX) * (px - projX) + (py - projY) * (py - projY));
}
// =============================================================================
// Undo / Redo
// =============================================================================
function saveUndo() {
const snapshot = JSON.parse(JSON.stringify(scene));
undoStack.push(snapshot);
redoStack = [];
dirty = true;
}
function undo() {
if (undoStack.length === 0) return;
redoStack.push(JSON.parse(JSON.stringify(scene)));
scene = undoStack.pop();
selectedIds.clear();
dirty = true;
updateStatus('Undo');
}
function redo() {
if (redoStack.length === 0) return;
undoStack.push(JSON.parse(JSON.stringify(scene)));
scene = redoStack.pop();
selectedIds.clear();
dirty = true;
updateStatus('Redo');
}
// =============================================================================
// Tool System
// =============================================================================
function setTool(tool) {
currentTool = tool;
document.querySelectorAll('.tool-btn[data-tool]').forEach(b => {
b.classList.toggle('active', b.dataset.tool === tool);
});
freehandPoints = [];
freehandPath = null;
erasing = false;
erasingIds.clear();
lastStrokePoint = null;
document.getElementById('text-overlay').style.display = 'none';
pendingStart = null;
}
// =============================================================================
// Pointer helpers
// =============================================================================
function getPointerPos(e) {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX !== undefined ? e.clientX : (e.touches ? e.touches[0].clientX : 0);
const sy = e.clientY !== undefined ? e.clientY : (e.touches ? e.touches[0].clientY : 0);
return { x: sx, y: sy };
}
function getEventPos(e) {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX !== undefined ? e.clientX : (e.touches ? e.touches[0].clientX : 0);
const sy = e.clientY !== undefined ? e.clientY : (e.touches ? e.touches[0].clientY : 0);
const dpr = window.devicePixelRatio || 1;
return {
sx: sx * dpr,
sy: sy * dpr,
wx: (sx - panX) / zoom,
wy: (sy - panY) / zoom,
};
}
// =============================================================================
// Mouse Events
// =============================================================================
function onPointerDown(e) {
e.preventDefault();
const pos = getPointerPos(e);
mouseX = pos.x;
mouseY = pos.y;
const wp = screenToWorld(mouseX, mouseY);
if (currentTool === 'select') {
handleSelectDown(wp.wx, wp.wy, e);
} else if (currentTool === 'freehand') {
handleFreehandDown(wp.wx, wp.wy, e);
} else if (currentTool === 'rect') {
handleRectDown(wp.wx, wp.wy, e);
} else if (currentTool === 'ellipse') {
handleEllipseDown(wp.wx, wp.wy, e);
} else if (currentTool === 'line') {
handleLineDown(wp.wx, wp.wy, e);
} else if (currentTool === 'text') {
handleTextDown(wp.wx, wp.wy, e);
} else if (currentTool === 'eraser') {
handleEraserDown(wp.wx, wp.wy, e);
}
mouseDown = true;
}
function onPointerMove(e) {
const pos = getPointerPos(e);
mouseX = pos.x;
mouseY = pos.y;
if (mouseDown) {
const wp = screenToWorld(mouseX, mouseY);
if (currentTool === 'select' || currentTool === 'eraser') {
handleSelectMove(wp.wx, wp.wy);
} else if (currentTool === 'freehand') {
handleFreehandMove(wp.wx, wp.wy);
} else if (currentTool === 'rect' || currentTool === 'ellipse' || currentTool === 'line') {
handlePreviewMove(wp.wx, wp.wy);
} else if (currentTool === 'text') {
handleTextMove(wp.wx, wp.wy);
}
}
}
function onPointerUp(e) {
e.preventDefault();
mouseDown = false;
const pos = getPointerPos(e);
mouseX = pos.x;
mouseY = pos.y;
const wp = screenToWorld(mouseX, mouseY);
if (currentTool === 'select') {
handleSelectUp(wp.wx, wp.wy);
} else if (currentTool === 'freehand') {
handleFreehandUp();
} else if (currentTool === 'rect' || currentTool === 'ellipse' || currentTool === 'line') {
handleShapeUp(wp.wx, wp.wy);
} else if (currentTool === 'text') {
handleTextUp();
} else if (currentTool === 'eraser') {
handleEraserUp();
}
}
// =============================================================================
// Select Tool
// =============================================================================
function handleSelectDown(wx, wy, e) {
const obj = hitTest(wx, wy);
if (obj) {
if (e.shiftKey) {
isMultiSelecting = true;
pendingStart = { wx, wy, obj };
return;
}
if (selectedIds.has(obj.id)) {
selectedIds.delete(obj.id);
} else {
selectedIds.clear();
selectedIds.add(obj.id);
}
} else {
selectedIds.clear();
pendingStart = { wx, wy };
isMultiSelecting = true;
}
}
function handleSelectMove(wx, wy) {
if (!pendingStart) return;
if (!isMultiSelecting) return;
if (selectedIds.size > 0) return;
const obj = hitTest(wx, wy);
if (obj) {
selectedIds.add(obj.id);
}
}
function handleSelectUp(wx, wy) {
if (pendingStart) {
const obj = hitTest(pendingStart.wx, pendingStart.wy);
selectedIds.clear();
if (obj) {
selectedIds.add(obj.id);
}
pendingStart = null;
isMultiSelecting = false;
}
}
// =============================================================================
// Freehand Tool
// =============================================================================
function handleFreehandDown(wx, wy, e) {
freehandPoints = [{ x: wx, y: wy, pressTime: Date.now() }];
lastStrokePoint = null;
}
function handleFreehandMove(wx, wy) {
if (freehandPoints.length === 0) return;
const lastPt = freehandPoints[freehandPoints.length - 1];
const dx = wx - lastPt.x;
const dy = wy - lastPt.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 5) {
freehandPoints.push({ x: wx, y: wy });
freehandPath = null;
}
}
function handleFreehandUp() {
if (freehandPoints.length > 1) {
const smoothed = smoothPath(freehandPoints);
const obj = createObject('freehand', smoothed[0].x, smoothed[0].y, {
strokeColor: C.STROKE_COLOR,
fillColor: C.FILL_COLOR,
strokeWidth: C.DEFAULT_WIDTH,
points: smoothed,
});
scene.push(obj);
saveUndo();
} else if (freehandPoints.length === 1) {
const p = freehandPoints[0];
const obj = createObject('freehand', p.x, p.y, {
strokeColor: C.STROKE_COLOR,
fillColor: C.FILL_COLOR,
strokeWidth: C.DEFAULT_WIDTH,
points: [p],
});
scene.push(obj);
saveUndo();
}
freehandPoints = [];
freehandPath = null;
}
function smoothPath(points) {
if (points.length < 2) return points;
const smooth = [points[0]];
for (let i = 1; i < points.length - 1; i++) {
smooth.push({
x: (points[i - 1].x + points[i].x + points[i + 1].x) / 3,
y: (points[i - 1].y + points[i].y + points[i + 1].y) / 3,
});
}
smooth.push(points[points.length - 1]);
return smooth;
}
// =============================================================================
// Shape Tools
// =============================================================================
function handleRectDown(wx, wy, e) {
pendingStart = { wx, wy, isRect: true, startX: wx, startY: wy };
}
function handleEllipseDown(wx, wy, e) {
pendingStart = { wx, wy, isEllipse: true, startX: wx, startY: wy };
}
function handleLineDown(wx, wy, e) {
pendingStart = { wx, wy, isLine: true, startX: wx, startY: wy };
}
function handlePreviewMove(wx, wy) {
if (!pendingStart) return;
const dx = wx - pendingStart.startX;
const dy = wy - pendingStart.startY;
const overlay = document.getElementById('text-overlay');
overlay.style.display = 'block';
overlay.style.left = (wx * zoom + panX - 60) + 'px';
overlay.style.top = (wy * zoom + panY - 14) + 'px';
}
function handleShapeUp(wx, wy) {
if (!pendingStart) return;
const w = wx - pendingStart.startX;
const h = wy - pendingStart.startY;
if (pendingStart.isRect) {
const obj = createObject('rect', pendingStart.startX, pendingStart.startY, {
width: Math.abs(w), height: Math.abs(h),
});
scene.push(obj);
saveUndo();
} else if (pendingStart.isEllipse) {
const obj = createObject('ellipse', pendingStart.startX, pendingStart.startY, {
rx: Math.abs(w) / 2, ry: Math.abs(h) / 2,
});
scene.push(obj);
saveUndo();
} else if (pendingStart.isLine) {
const obj = createObject('line', pendingStart.startX, pendingStart.startY, {
x2: wx, y2: wy,
});
scene.push(obj);
saveUndo();
}
pendingStart = null;
}
// =============================================================================
// Text Tool
// =============================================================================
function handleTextDown(wx, wy, e) {
const obj = createObject('text', wx, wy, { text: '' });
scene.push(obj);
textBoxId = obj.id;
saveUndo();
const overlay = document.getElementById('text-overlay');
overlay.style.display = 'block';
overlay.style.left = (wx * zoom + panX - 60) + 'px';
overlay.style.top = (wy * zoom + panY - 14) + 'px';
overlay.value = '';
overlay.focus();
}
function handleTextMove(wx, wy) {
const obj = scene.find(o => o.id === textBoxId);
if (obj) {
obj.x = wx;
obj.y = wy;
}
}
function handleTextUp() {
const obj = scene.find(o => o.id === textBoxId);
if (obj) {
const overlay = document.getElementById('text-overlay');
obj.text = overlay ? overlay.value : '';
saveUndo();
}
document.getElementById('text-overlay').style.display = 'none';
textBoxId = null;
}
// =============================================================================
// Eraser Tool
// =============================================================================
function handleEraserDown(wx, wy, e) {
erasing = true;
erasingIds = new Set();
const obj = hitTest(wx, wy);
if (obj) erasingIds.add(obj.id);
}
function handleEraserMove(wx, wy) {
const obj = hitTest(wx, wy);
if (obj) erasingIds.add(obj.id);
}
function handleEraserUp() {
if (erasingIds.size > 0) {
const toRemove = new Set();
for (const id of erasingIds) {
const idx = scene.findIndex(o => o.id === id);
if (idx >= 0) toRemove.add(id);
}
scene = scene.filter(o => !toRemove.has(o.id));
saveUndo();
}
erasing = false;
erasingIds.clear();
}
// =============================================================================
// Keyboard
// =============================================================================
document.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedIds.size > 0) {
e.preventDefault();
const toRemove = [...selectedIds];
scene = scene.filter(o => !toRemove.includes(o.id));
saveUndo();
selectedIds.clear();
}
}
if (e.key === 'Escape') {
setTool('select');
selectedIds.clear();
}
if (e.key === 'v' || e.key === 'V') setTool('select');
if (e.key === 'p' || e.key === 'P') setTool('freehand');
if (e.key === 'r' || e.key === 'R') setTool('rect');
if (e.key === 'o' || e.key === 'O') setTool('ellipse');
if (e.key === 'l' || e.key === 'L') setTool('line');
if (e.key === 't' || e.key === 'T') setTool('text');
if (e.key === 'e' || e.key === 'E') setTool('eraser');
if (e.ctrlKey && e.key === 'z') { e.preventDefault(); undo(); }
if (e.ctrlKey && e.key === 'y') { e.preventDefault(); redo(); }
});
// =============================================================================
// Canvas Mouse Events (only attached after init)
// =============================================================================
canvas.addEventListener('mousedown', onPointerDown);
canvas.addEventListener('mousemove', onPointerMove);
canvas.addEventListener('mouseup', onPointerUp);
canvas.addEventListener('mouseleave', () => {
if (mouseDown) onPointerUp({});
});
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const pos = getPointerPos(e);
const wx = (pos.x - panX) / zoom;
const wy = (pos.y - panY) / zoom;
const delta = e.deltaY > 0 ? C.ZOOM_OUT : C.ZOOM_IN;
const newZoom = Math.max(C.ZOOM_MIN, Math.min(C.ZOOM_MAX, zoom * delta));
panX = pos.x - wx * newZoom;
panY = pos.y - wy * newZoom;
zoom = newZoom;
dirty = true;
}, { passive: false });
canvas.addEventListener('mousedown', (e) => {
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
e.preventDefault();
}
});
canvas.addEventListener('mousemove', (e) => {
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
const dx = e.movementX;
const dy = e.movementY;
panX += dx;
panY += dy;
dirty = true;
}
});
// =============================================================================
// Touch Events
// =============================================================================
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
const touch = e.touches[0];
const pos = getPointerPos(touch);
mouseX = pos.x;
mouseY = pos.y;
onPointerDown({ clientX: pos.x, clientY: pos.y, button: 0, shiftKey: false, target: canvas });
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
const touch = e.touches[0];
const pos = getPointerPos(touch);
mouseX = pos.x;
mouseY = pos.y;
onPointerMove({ clientX: pos.x, clientY: pos.y });
}, { passive: false });
canvas.addEventListener('touchend', (e) => {
e.preventDefault();
onPointerUp({});
}, { passive: false });
// =============================================================================
// Toolbar
// =============================================================================
document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => {
btn.addEventListener('click', () => {
const tool = btn.dataset.tool;
setTool(tool);
pendingStart = null;
});
});
document.getElementById('btn-undo').addEventListener('click', undo);
document.getElementById('btn-redo').addEventListener('click', redo);
document.getElementById('btn-clear').addEventListener('click', () => {
if (scene.length > 0) {
saveUndo();
scene = [];
selectedIds.clear();
dirty = true;
}
});
document.getElementById('btn-grid').addEventListener('click', () => {
gridVisible = !gridVisible;
dirty = true;
});
document.getElementById('reset-view').addEventListener('click', () => {
zoom = 1.0;
panX = 0;
panY = 0;
dirty = true;
});
// =============================================================================
// Properties Bar
// =============================================================================
document.getElementById('prop-stroke').addEventListener('input', (e) => {
if (selectedIds.size > 0) {
for (const id of selectedIds) {
const obj = scene.find(o => o.id === id);
if (obj) {
obj.strokeColor = e.target.value;
saveUndo();
}
}
}
});
document.getElementById('prop-fill').addEventListener('input', (e) => {
if (selectedIds.size > 0) {
for (const id of selectedIds) {
const obj = scene.find(o => o.id === id);
if (obj) {
obj.fillColor = e.target.value;
saveUndo();
}
}
}
});
document.getElementById('prop-width').addEventListener('input', (e) => {
if (selectedIds.size > 0) {
for (const id of selectedIds) {
const obj = scene.find(o => o.id === id);
if (obj) {
obj.strokeWidth = parseFloat(e.target.value);
saveUndo();
}
}
}
});
// =============================================================================
// Persistence
// =============================================================================
function saveBoard() {
try {
localStorage.setItem('whiteboard_scene', JSON.stringify(scene));
} catch (e) { /* ignore */ }
}
function loadBoard() {
try {
const data = localStorage.getItem('whiteboard_scene');
if (data) {
scene = JSON.parse(data);
dirty = true;
}
} catch (e) { /* ignore */ }
}
setInterval(saveBoard, 10000);
// =============================================================================
// Main Loop
// =============================================================================
function tick() {
if (dirty) {
render();
}
requestAnimationFrame(tick);
}
// =============================================================================
// Initialization
// =============================================================================
function init() {
initCanvas();
loadBoard();
setTool('select');
requestAnimationFrame(tick);
window.addEventListener('resize', () => {
resizeCanvas();
dirty = true;
});
}
init();
})();
</script>
</body>
</html>