← Whiteboard results

Whiteboard

lfm2 24b a2b · basic

Initial view of Whiteboard

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"} -->