Whiteboard
lfm2 24b a2b · basic

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 19,726 bytes · SHA-256 574f4b358105
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Collaborative Whiteboard Prototype</title>
<style>
:root{--bg:#f5f5f5;--grid-color:#eee;--grid-opacity:.15;--selected:rgba(0,120,255,.4);--hover:rgba(200,200,255,.1);--text:rgba(30,30,40,.9);--toolbar-bg:#fff;--toolbar-hover:#f0f0f0;}
*{box-sizing:border-box}
body{margin:0;font-family:sans-serif;background:#fafafa;display:flex;flex-direction:column;height:100vh;overflow:hidden}
#toolbar{position:absolute;top:0;left:0;right:0;background:#fff;border-bottom:1px solid #ccc;display:flex;align-items:center;padding:6px 12px;z-index:10;box-shadow:0 2px 8px rgba(0,0,0,.08)}
#toolbar button{background:#fff;border:none;padding:4px 8px;margin-right:6px;cursor:pointer;font-size:14px;color:#333;border-radius:4px;transition:.15s;}
#toolbar button.active{border:1px solid #007bff;background:#e9f0ff;}
#properties{position:absolute;top:38px;left:0;right:0;display:flex;align-items:center;gap:8px;background:#fff;padding:6px 12px;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.08);z-index:10;}
#properties span{color:#555;font-size:13px;margin-right:6px}
#grid{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:-1;}
#canvas{position:relative;flex:1;overflow:hidden;touch-action:none;background:#fff;border:1px solid #ddd;image-rendering:crisp-edges;}
#status{position:absolute;bottom:0;left:0;right:0;text-align:center;font-size:12px;color:#888;z-index:10;padding:4px 8px;background:#eee;border-top:1px solid #ccc;}
.selected{outline:2px dashed #007bff;}
.handle{background:#007bff;color:#fff;border-radius:50%;width:12px;height:12px;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:5;margin-top:-4px;}
.handle.resize{transform:rotate(-45deg);}
</style>
</head>
<body>
<div id="toolbar">
<button id="toolP" data-tool="pen">P</button>
<button id="toolR" data-tool="rect">R</button>
<button id="toolO" data-tool="ellipse">O</button>
<button id="toolL" data-tool="line">L</button>
<button id="toolT" data-tool="text">T</button>
<button id="toolE" data-tool="eraser">E</button>
<button id="toolU" title="Undo">⌘Z</button>
<button id="toolZ" title="Redo">⇧⌘Z</button>
<button id="toolC" title="Clear">⇧⇧C</button>
<button id="toolS" title="Save">⌘S</button>
<button id="toolR" title="Reset View">⇧⌘R</button>
</div>
<canvas id="canvas"></canvas>
<div id="status">Zoom: 100% | Views: 1/12</div>
<script>
/* === CONSTANTS ===
* All world coordinates; rendering applies viewport transform.
* Handles, selection, and hit-testing use these values directly.
*/
const CON = {
W: 2000, // canvas width in world units
H: 2000, // canvas height in world units
Z_MIN: 0.25, Z_MAX: 4.0,
Z_RANGE: CON.Z_MAX - CON.Z_MIN,
GRID_DENSITY: 40, // points per canvas width/height
HANDLE_SZ: 12,
SELECT_HINT: 10, // px hint for selection box
PROPERTIES_BG: 'rgba(255,255,255,.85)',
TOOLS: {P:'pen',R:'rect',O:'ellipse',L:'line',T:'text',E:'eraser'},
COLORS: {
bg: '#f5f5f5',
grid: 'rgba(200,200,200,.15)',
selected: 'rgba(0,120,255,.4)',
hover: 'rgba(200,200,255,.1)',
text: 'rgba(30,30,40,.9)',
toolbar: '#eee'
}
};
/* === STATE === */
let state = {
zoom: 1,
offsetX: 0, // world x offset
offsetY: 0, // world y offset
selected: null, // {id, type, ...}
selectionRect: null, // {x1,y1,x2,y2} in world coords
history: [],
redoStack: [],
currentTool: 'pen',
properties: {
strokeColor: '#000',
fillColor: '#fff',
strokeWidth: 4
},
shapes: [], // serializable objects
gridVisible: true,
gridColor: '#eee',
gridOpacity: 0.15,
lastSelection: null, // for rubber-band selection
dragState: {active:null, start:null, target:null}, // for multi-object move/select
isDragging: false,
lastPan: null,
lastZoom: null
};
/* === UTILS === */
function worldToScreen(wx, wy) {
return {
sx: (wx + state.offsetX) * state.zoom,
sy: (wy + state.offsetY) * state.zoom
};
}
function screenToWorld(scx, scy) {
return {
x: (scx / state.zoom) - state.offsetX,
y: (scy / state.zoom) - state.offsetY
};
}
function getCell(wx, wy) {
return {
ix: Math.floor(wx / CON.GRID_DENSITY),
iy: Math.floor(wy / CON.GRID_DENSITY)
};
}
function hitTestPoint(pt, shapes) {
// point-in-shape or point-on-border test
for (let s of shapes) {
if (s.type==='circle') {
let dx = pt.x - s.x, dy = pt.y - s.y;
if (Math.hypot(dx,dy) <= s.r) return true;
} else if (s.type==='rect' || s.type==='ellipse') {
let rx = s.x + s.w/2, ry = s.y + s.h/2;
let dx = pt.x - rx, dy = pt.y - ry;
// inside or on border
if (Math.abs(dx)<=s.w/2 && Math.abs(dy)<=s.h/2) return true;
} else if (s.type==='line') {
let l = s.l;
// line segment test
let dx = pt.x - l[0], dy = pt.y - l[1];
let dot = dx*l[0] + dy*l[1];
if (dot < 0) return false;
let len2 = l[0]*l[0]+l[1]*l[1];
if (dot > len2) return false;
return true;
} else if (s.type==='text') {
// bounding box test
let bx = s.x - s.w/2, by = s.y - s.h/2;
let dx = pt.x - bx, dy = pt.y - by;
let dist = Math.hypot(dx,dy);
return dist <= s.w/2 + 5;
}
}
return false;
}
function hitTestWorld(pt, shapes) {
// returns {type, id, info} or null
for (let s of shapes) {
if (s.type==='circle') {
let dx = pt.x - s.x, dy = pt.y - s.y;
if (Math.hypot(dx,dy) <= s.r) return {type:'circle', id:s.id, info:{x:s.x,y:s.y,r:s.r}};
} else if (s.type==='rect' || s.type==='ellipse') {
let rx = s.x + s.w/2, ry = s.y + s.h/2;
let dx = pt.x - rx, dy = pt.y - ry;
if (Math.abs(dx)<=s.w/2 && Math.abs(dy)<=s.h/2) return {type:'rect', id:s.id, info:{x:rx,y:ry,w:s.w,h:s.h}};
} else if (s.type==='line') {
let l = s.l;
// point-on-segment
let dx = pt.x - l[0], dy = pt.y - l[1];
let dot = dx*l[0] + dy*l[1];
if (dot < 0) continue;
let len2 = l[0]*l[0]+l[1]*l[1];
if (dot > len2) continue;
// inside segment
return {type:'line', id:s.id, info:{l:l, pt:pt}};
} else if (s.type==='text') {
let bx = s.x - s.w/2, by = s.y - s.h/2;
let dx = pt.x - bx, dy = pt.y - by;
let dist = Math.hypot(dx,dy);
if (dist <= s.w/2 + 5) return {type:'text', id:s.id, info:{x:bx,y:by,w:s.w,h:s.h}};
}
}
return null;
}
/* === OBJECT MODEL === */
function createShape(type, ...args) {
let id = Date.now()+Math.random().toString(36).substr(2,5);
let s = {
id,
type,
...args
};
state.shapes.push(s);
return s;
}
function getShape(id) { return state.shapes.find(s=>s.id===id); }
function removeShape(id) {
let idx = state.shapes.findIndex(s=>s.id===id);
if (idx!==-1) state.shapes.splice(idx,1);
}
function moveShape(id, tx, ty) {
let s = getShape(id);
if (s) { s.x = tx; s.y = ty; }
}
function resizeShape(id, w, h) {
let s = getShape(id);
if (s) { s.w=w; s.h=h; }
}
function selectShape(id) { return getShape(id); }
function clearSelection() { state.selectionRect=null; state.selected=null; }
function setSelection(rect) { state.selectionRect=rect; state.selected=rect; }
function clearSelectionRect() { state.selectionRect=null; state.selected=null; }
/* === HISTORY === */
function saveState() {
state.history.push(JSON.stringify(state.shapes));
state.redoStack = [];
}
function undo() {
if (state.history.length===0) return;
let prev = JSON.parse(state.history[state.history.length-1]);
state.redoStack.push(JSON.stringify(state.shapes));
state.shapes = prev;
render();
}
function redo() {
if (state.redoStack.length===0) return;
let next = JSON.parse(state.redoStack.pop());
state.history.push(JSON.stringify(state.shapes));
state.shapes = next;
render();
}
/* === TOOLS === */
function startTool(tool, pt) {
if (tool==='pen') { state.currentTool='pen'; }
else if (tool==='rect' || tool==='ellipse' || tool==='line' || tool==='text') { state.currentTool=tool; }
else if (tool==='eraser') { state.currentTool='eraser'; }
state.lastSelection=null;
state.selectionRect=null;
render();
}
function endTool() {
state.currentTool=null;
state.lastSelection=null;
state.selectionRect=null;
saveState();
}
/* === INTERACTION === */
let dragStart = null, dragTarget = null, dragObj = null;
function handleMouseDown(e) {
let pt = getMousePos(e);
if (e.button===1 || e.button===3) { // left or middle
// check hit
let hit = hitTestWorld(pt, state.shapes);
if (hit && hit.type==='text') {
// text edit
let t = hit.id;
let s = getSelection();
if (!s) { clearSelection(); return; }
let pre = s.content || '';
let newText = prompt('Edit text:', pre);
if (newText!==null) { setProperty('text',newText); clearSelection(); }
} else {
// selection or move
if (e.shiftKey || e.button===1) {
// rubber-band select
let sel = getSelection();
if (!sel) { clearSelection(); return; }
state.lastSelection = sel;
state.selectionRect = {
x1: Math.min(sel.x1, pt.x),
y1: Math.min(sel.y1, pt.y),
x2: Math.max(sel.x2, pt.x),
y2: Math.max(sel.y2, pt.y)
};
} else {
// single object select
let hitObj = hitTestWorld(pt, state.shapes);
if (hitObj) { clearSelection(); state.selectionRect=getRect(hitObj); }
}
}
startDrag(e, hit);
} else if (e.button===2) {
// middle drag: pan
dragStart = getMousePos(e);
dragTarget = null;
dragObj = null;
}
}
function handleMouseMove(e) {
let pt = getMousePos(e);
if (dragStart) {
// pan
state.offsetX += (dragStart.x - dragStart.y)*(e.dx/1000); // rough
state.offsetY += (dragStart.y - dragStart.y)*(e.dy/1000);
dragStart = {x:e.x,y:e.y};
} else if (dragTarget) {
// move selection box
let rect = getRect(dragObj);
rect.x1 = Math.max(0,Math.min(state.offsetX+pt.x-400,CON.W-rect.w));
rect.y1 = Math.max(0,Math.min(state.offsetY+pt.y-400,CON.H-rect.h));
dragTarget = null;
} else if (dragStart) {
// zoom
let oldZoom = state.zoom;
let delta = e.deltaY || e.wheelDeltaY || e.altKey? 1 : 0;
let newZoom = Math.max(CON.Z_MIN, Math.min(CON.Z_RANGE, oldZoom + delta*0.5));
// world point under cursor
let wc = getMousePos(e);
let world = worldToScreen(wc.x, wc.y);
// zoom toward that point
let dx = (world.x - CON.W/2)/oldZoom - (world.x - CON.W/2)/newZoom;
let dy = (world.y - CON.H/2)/oldZoom - (world.y - CON.H/2)/newZoom;
state.zoom = Math.max(CON.Z_MIN, Math.min(CON.Z_RANGE, newZoom));
state.offsetX += dx*(CON.W/oldZoom - CON.W/newZoom);
state.offsetY += dy*(CON.H/oldZoom - CON.H/newZoom);
// clamp pan so world point stays under cursor
let px = (world.x - CON.W/2)/state.zoom;
let py = (world.y - CON.H/2)/state.zoom;
state.offsetX += dx*(state.zoom-oldZoom);
state.offsetY += dy*(state.zoom-oldZOOM);
}
}
function handleMouseUp(e) {
if (dragStart) {
dragTarget = null;
dragObj = null;
dragStart = null;
} else if (dragTarget) {
dragTarget = null;
dragObj = null;
} else if (dragStart) {
// end drag
dragStart = null;
dragTarget = null;
dragObj = null;
}
endTool();
}
function startDrag(e, pt) {
if (e.button===1 || e.button===3) {
let hit = hitTestWorld(pt, state.shapes);
if (hit && hit.type==='circle'||hit.type==='rect'||hit.type==='ellipse'||hit.type==='line') {
dragTarget = hit;
dragObj = hit;
dragStart = pt;
} else if (e.shiftKey || e.button===1) {
// multi-select
let sel = getSelection();
if (!sel) { clearSelection(); return; }
state.lastSelection = sel;
state.selectionRect = {
x1: Math.min(sel.x1, pt.x), y1: Math.min(sel.y1, pt.y),
x2: Math.max(sel.x2, pt.x), y2: Math.max(sel.y2, pt.y)
};
} else {
clearSelection();
}
}
}
function endDrag() {
dragTarget = null;
dragObj = null;
dragStart = null;
}
/* === RENDER === */
function drawGrid() {
if (!state.gridVisible) return;
let ctx = canvas.getContext('2d');
ctx.save();
ctx.strokeStyle=state.gridColor;
ctx.lineWidth=1;
ctx.setTransform(1,0,0,1,0,0);
for (let i=0;i<CON.GRID_DENSITY;i++) {
let x = i*CON.GRID_DENSITY*state.zoom;
if (x<-100 || x>CON.W+100) continue;
ctx.beginPath();
ctx.moveTo(x,0); ctx.lineTo(x,CON.H);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0,x); ctx.lineTo(CON.W,x);
ctx.stroke();
}
ctx.restore();
}
function drawSelectionBox() {
if (!state.selectionRect) return;
let ctx = canvas.getContext('2d');
ctx.save();
ctx.strokeStyle=state.selectionHint;
ctx.lineWidth=2;
ctx.setTransform(1,0,0,1,0,0);
ctx.strokeRect(state.selectionRect.x1,state.selectionRect.y1,state.selectionRect.x2-state.selectionRect.x1,state.selectionRect.y2-state.selectionRect.y1);
ctx.restore();
}
function drawShapes() {
let ctx = canvas.getContext('2d');
ctx.save();
ctx.setTransform(1,0,0,1,0,0);
// shapes
for (let s of state.shapes) {
ctx.beginPath();
if (s.type==='circle') {
ctx.arc(s.x,s.y,s.r,0,2*Math.PI);
} else if (s.type==='rect') {
ctx.rect(s.x-s.w/2,s.y-s.h/2,s.w,s.h);
} else if (state.currentTool==='rect' || state.currentTool==='ellipse') {
ctx.rect(s.x-s.w/2,s.y-s.h/2,s.w,s.h);
} else if (state.currentTool==='ellipse') {
ctx.ellipse(s.x,s.y,s.r, s.h/2,0,0,2*Math.PI);
} else if (state.currentTool==='line') {
ctx.moveTo(s.x-s.w/2,s.y-s.h/2);
ctx.lineTo(s.x+s.w/2,s.y-s.h/2);
} else if (state.currentTool==='text') {
ctx.fillStyle=state.properties.fillColor;
ctx.strokeStyle=state.properties.strokeColor;
ctx.lineWidth=state.properties.strokeWidth;
ctx.fillText(s.content||'', s.x-s.w/2,s.y-s.h/2);
} else if (state.currentTool==='eraser') {
ctx.globalAlpha=0.5; ctx.fillStyle=#000; ctx.fillRect(s.x-s.w/2,s.y-s.h/2,s.w,s.h);
ctx.globalAlpha=1;
}
ctx.closePath();
ctx.fillStyle=s.type==='text'?state.properties.textColor:s.type==='circle'?state.properties.fillColor:s.type==='rect'?state.properties.fillColor:s.type==='ellipse'?state.properties.fillColor:s.type==='line'?state.properties.strokeColor:s.type==='eraser'?'#000':'black';
ctx.lineWidth=s.type==='text'?4:4;
ctx.fill();
if (s.type==='circle') ctx.beginPath(); ctx.arc(s.x,s.y,s.r,0,2*Math.PI); ctx.closePath(); ctx.fillStyle=state.properties.fillColor; ctx.fill();
else if (state.currentTool==='rect' || state.currentTool==='ellipse') {
ctx.beginPath(); ctx.arc(s.x,s.y,s.r,s.h/2,0,2*Math.PI); ctx.closePath(); ctx.fillStyle=state.properties.fillColor; ctx.fill();
}
else if (state.currentTool==='line') {
ctx.beginPath(); ctx.moveTo(s.x-s.w/2,s.y-s.h/2); ctx.lineTo(s.x+s.w/2,s.y-s.h/2); ctx.strokeStyle=state.properties.strokeColor; ctx.lineWidth=s.type==='line'?4:3; ctx.stroke();
}
}
ctx.restore();
}
function drawTextOverlay() {
let ctx = canvas.getContext('2d');
if (!state.selectionRect) return;
ctx.save();
ctx.font='14px Arial';
ctx.fillStyle=state.properties.textColor;
ctx.fillText(state.selections[0]?.content||'', state.selectionRect.x1, state.selectionRect.y1-16);
ctx.restore();
}
function drawStatus() {
let status = document.getElementById('status');
let wc = getMousePos(canvas);
let zoomPx = (wc.x / state.zoom) - state.offsetX;
let percent = Math.min(100, Math.max(0, ((zoomPx-CON.W/2)/state.zoom)*100));
status.innerHTML = `Zoom: ${Math.round(percent)}% | Views: ${state.history.length}`;
}
function draw() {
render();
drawStatus();
}
function render() {
let canvas = document.getElementById('canvas');
let ctx = canvas.getContext('2d');
ctx.clearRect(0,0,canvas.width,canvas.height);
drawGrid();
drawShapes();
drawSelectionBox();
drawTextOverlay();
// draw selection border
if (state.selectionRect) {
ctx.save();
ctx.strokeStyle=state.selectionHint;
ctx.lineWidth=2;
ctx.setTransform(1,0,0,1,0,0);
ctx.strokeRect(state.selectionRect.x1,state.selectionRect.y1,state.selectionRect.x2-state.selectionRect.x1,state.selectionRect.y2-state.selectionRect.y1);
ctx.restore();
}
}
/* === INIT === */
function getMousePos(e) {
let rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
function getRect(s) {
return {
x1: s.x1, y1: s.y1, x2: s.x2, y2: s.y2
};
}
/* === EVENTS === */
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseup', handleMouseUp);
window.addEventListener('wheel', e=>{
let delta = e.deltaY||e.altKey?1:-1;
let oldZoom = state.zoom;
let newZoom = Math.max(CON.Z_MIN, Math.min(CON.Z_RANGE, oldZoom + delta*0.5));
// zoom toward cursor
let pt = getMousePos(e);
let wc = worldToScreen(pt.x, pt.y);
let dx = (wc.x - CON.W/2)/oldZoom - (wc.x - CON.W/2)/newZoom;
let dy = (wc.y - CON.H/2)/oldZoom - (wc.y - CON.H/2)/newZoom;
state.zoom = Math.max(CON.Z_MIN, Math.min(CON.Z_RANGE, newZoom));
state.offsetX += dx*(CON.W/oldZoom - CON.W/newZoom);
state.offsetY += dy*(CON.H/oldZOOM - CON.H/newZOOM);
e.preventDefault();
});
window.addEventListener('keydown', e=>{
if (e.ctrlKey||e.metaKey) {
if (e.key==='z'||e.key==='Z') { undo(); }
else if ((e.key==='z'||e.key==='Z')&&e.shiftKey) { redo(); }
} else if (e.key==='e') { endTool(); }
if (e.key==='z'||e.key==='Z') { undo(); }
if (e.key==='s'||e.key==='S') { saveState(); }
if (e.key==='r'||e.key==='R') { clearSelection(); }
});
document.getElementById('toolP').addEventListener('click', e=>startTool('pen',null));
document.getElementById('toolR').addEventListener('click', e=>startTool('rect',null));
document.getElementById('toolO').addEventListener('click', e=>startTool('ellipse',null));
document.getElementById('toolL').addEventListener('click', e=>startTool('line',null));
document.getElementById('toolT').addEventListener('click', e=>startTool('text',null));
document.getElementById('toolE').addEventListener('click', e=>startTool('eraser',null));
document.getElementById('toolU').addEventListener('click', undo);
document.getElementById('toolZ').addEventListener('click', redo);
document.getElementById('toolC').addEventListener('click', ()=>{localStorage.setItem('board','"+JSON.stringify(state.shapes)+")';alert('Saved!');});
document.getElementById('toolS').addEventListener('click', e=>{
let s = state.selectionRect;
if (!s) return;
let txt = prompt('Enter text:', '');
if (txt!==null) {
clearSelection();
setProperty('text',txt);
addShape('text',s.x, s.y, s.w, s.h, {content: txt});
}
});
/* === START === */
window.addEventListener('load', ()=>{
// restore from localStorage if possible
let saved = localStorage.getItem('board');
if (saved) {
state.shapes=JSON.parse(saved);
} else {
// default shapes
addShape('circle',CON.W/2,CON.H/2,40);
addShape('rect',CON.W/2-80,CON.H/2-60,160,120);
addShape('ellipse',CON.W/2+30,CON.H/2-40,70,80);
}
state.zoom=1;
state.offsetX=0; state.offsetY=0;
state.selectionRect=null; state.selected=null; state.history.push(JSON.stringify(state.shapes));
render();
});
</script>
</body>
</html>
<!-- agent-meta {"model":"liquid/lfm2-24b-a2b","provider":"lmstudio","persona":"basic","sessionId":"5fad1287-4121-47da-8107-618505eb77aa","tokensIn":1148,"tokensOut":6745,"tokensTotal":7893,"cost":0,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-08-01T04:51:33.577Z"} -->