Whiteboard
GPT 6 Luna · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 29,093 bytes · SHA-256 31748d1f3196
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Canvas — Collaborative whiteboard</title>
<style>
/* ── Product shell ── */
:root{--chrome:#f4f5f7;--line:#e1e4e9;--ink:#20242c;--muted:#747c89;--accent:#5369e8;--shadow:0 8px 28px #1f29371a,0 2px 7px #1f29370c}
*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden;font:13px Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--chrome)}
#app{height:100%;display:flex;flex-direction:column}.topbar{height:58px;display:flex;align-items:center;padding:0 20px;gap:14px;background:#fff;border-bottom:1px solid var(--line);z-index:2}.brand{display:flex;align-items:center;gap:10px;font-weight:700;font-size:15px;letter-spacing:-.3px}.logo{width:28px;height:28px;border-radius:8px;background:#5369e8;color:white;display:grid;place-items:center;font-size:17px;box-shadow:0 3px 8px #5369e84a}.crumb{color:#adb3bd;font-weight:400}.boardname{border:0;background:transparent;color:#303641;font:600 13px inherit;outline:0;width:145px}.saved{color:#9299a5;font-size:11px;margin-left:1px}.spacer{flex:1}.presence{display:flex;align-items:center;margin-right:4px}.avatar{width:27px;height:27px;border:2px solid white;border-radius:50%;background:#f1c9aa;color:#754a39;display:grid;place-items:center;font-size:10px;font-weight:700;margin-left:-6px}.avatar:nth-child(2){background:#c9d6f7;color:#3e518a}.avatar:nth-child(3){background:#d8edd9;color:#426646}.share{border:0;border-radius:7px;padding:9px 15px;background:#5369e8;color:white;font-weight:650;cursor:pointer;box-shadow:0 2px 5px #5369e830}.share:hover{background:#4359d5}.more{font-size:20px;color:#69717e;padding:4px 7px;cursor:pointer}
.workspace{position:relative;flex:1;overflow:hidden;background:#eef0f3}.canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:default;touch-action:none}.toolbox{position:absolute;left:20px;top:20px;display:flex;flex-direction:column;padding:7px;background:#fff;border:1px solid #e5e7eb;border-radius:11px;box-shadow:var(--shadow);gap:3px;z-index:1}.tool{width:39px;height:38px;border:0;background:transparent;border-radius:7px;display:grid;place-items:center;color:#626b79;cursor:pointer;position:relative}.tool:hover{background:#f3f4f7;color:#252a34}.tool.active{background:#edf0ff;color:#4d62dc}.tool svg{width:19px;height:19px;stroke:currentColor;stroke-width:1.7;fill:none;stroke-linecap:round;stroke-linejoin:round}.divider{height:1px;background:#e9ebef;margin:4px 3px}.tip{position:absolute;left:47px;white-space:nowrap;padding:5px 8px;background:#252a34;color:white;font-size:11px;border-radius:5px;opacity:0;pointer-events:none;transition:opacity .12s}.tool:hover .tip{opacity:1}.props{position:absolute;left:50%;top:20px;transform:translateX(-50%);height:44px;display:flex;align-items:center;gap:10px;padding:0 12px;background:white;border:1px solid #e5e7eb;border-radius:9px;box-shadow:var(--shadow);z-index:1}.propgroup{display:flex;align-items:center;gap:7px}.prop-label{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:#9097a3;font-weight:700}.props input[type=color]{width:24px;height:24px;padding:2px;border:1px solid #e3e6eb;border-radius:6px;background:white;cursor:pointer}.props input[type=number]{width:43px;height:27px;border:1px solid #e3e6eb;border-radius:5px;padding:0 5px;color:#444;font:12px inherit}.prop-sep{height:23px;width:1px;background:#e9ebef}.history{position:absolute;left:50%;top:77px;transform:translateX(-50%);display:flex;gap:3px;background:white;padding:4px;border:1px solid #e8eaee;border-radius:7px;box-shadow:0 3px 12px #1f293710;z-index:1}.histbtn{border:0;background:white;color:#76808d;width:28px;height:27px;border-radius:5px;font-size:17px;cursor:pointer}.histbtn:hover:not(:disabled){background:#f2f4f7;color:#222}.histbtn:disabled{opacity:.3;cursor:default}.zoom{position:absolute;right:20px;bottom:18px;background:white;border:1px solid #e1e4e9;border-radius:8px;height:37px;display:flex;align-items:center;box-shadow:var(--shadow);z-index:1;overflow:hidden}.zoom button{border:0;background:white;width:34px;height:35px;color:#69717d;cursor:pointer;font-size:18px}.zoom button:hover{background:#f4f5f7}.zoom-value{width:53px;text-align:center;font-size:12px;font-weight:650;color:#535b68;font-variant-numeric:tabular-nums}.zoom .reset{font-size:11px;width:auto;padding:0 11px;border-left:1px solid #eceef1}.status{position:absolute;left:78px;bottom:24px;color:#9299a5;font-size:11px;pointer-events:none}.hint{position:absolute;left:50%;bottom:22px;transform:translateX(-50%);font-size:11px;color:#a1a7b1;pointer-events:none}.text-editor{position:absolute;display:none;min-width:100px;min-height:30px;padding:2px 4px;border:1px dashed #6478ed;outline:none;background:#ffffffd9;color:#252a34;white-space:pre-wrap;z-index:3;font:20px/1.35 "Segoe UI",sans-serif;resize:none}.toast{position:absolute;top:80px;left:50%;transform:translateX(-50%) translateY(-5px);padding:8px 13px;background:#242a34;color:white;border-radius:7px;font-size:12px;opacity:0;transition:.2s;pointer-events:none;z-index:5}.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
@media(max-width:700px){.topbar{height:52px;padding:0 12px}.presence,.saved,.crumb{display:none}.toolbox{left:10px;top:12px}.props{top:auto;bottom:65px;left:50%;gap:7px}.prop-label{display:none}.hint{display:none}.status{left:62px;bottom:18px}.zoom{right:10px;bottom:12px}}
</style></head><body><div id="app">
<header class="topbar"><div class="brand"><div class="logo">✳</div>canvas <span class="crumb">/</span><input class="boardname" value="Untitled board" aria-label="Board name"></div><span class="saved" id="saved">All changes saved</span><div class="spacer"></div><div class="presence" title="You're working solo"><div class="avatar">A</div><div class="avatar">M</div><div class="avatar">J</div></div><button class="share" id="share">Share</button><div class="more" title="More options">···</div></header>
<main class="workspace" id="workspace"><canvas class="canvas" id="canvas"></canvas>
<nav class="toolbox" aria-label="Drawing tools">
<button class="tool active" data-tool="select" aria-label="Select (V)"><svg viewBox="0 0 24 24"><path d="m5 3 14 10-7 1-3 6z"/></svg><span class="tip">Select <kbd>V</kbd></span></button>
<button class="tool" data-tool="hand" aria-label="Pan (H)"><svg viewBox="0 0 24 24"><path d="M8 12V6a1.5 1.5 0 0 1 3 0v5-7a1.5 1.5 0 0 1 3 0v7-5a1.5 1.5 0 0 1 3 0v7-3a1.5 1.5 0 0 1 3 0v6c0 4-2.5 7-7 7h-1c-2 0-3.4-.8-4.7-2.3L4 15a1.8 1.8 0 0 1 2.6-2.5L8 14z"/></svg><span class="tip">Hand <kbd>H</kbd></span></button>
<div class="divider"></div>
<button class="tool" data-tool="pen" aria-label="Pen (P)"><svg viewBox="0 0 24 24"><path d="m4 20 4.5-1 11-11a2.1 2.1 0 0 0-3-3l-11 11L4 20zM14.5 6.5l3 3"/></svg><span class="tip">Pen <kbd>P</kbd></span></button>
<button class="tool" data-tool="rect" aria-label="Rectangle (R)"><svg viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="14" rx="1"/></svg><span class="tip">Rectangle <kbd>R</kbd></span></button>
<button class="tool" data-tool="ellipse" aria-label="Ellipse (O)"><svg viewBox="0 0 24 24"><ellipse cx="12" cy="12" rx="8" ry="7"/></svg><span class="tip">Ellipse <kbd>O</kbd></span></button>
<button class="tool" data-tool="arrow" aria-label="Line / Arrow (L)"><svg viewBox="0 0 24 24"><path d="M4 18 19 5M10 5h9v9"/></svg><span class="tip">Line / Arrow <kbd>L</kbd></span></button>
<button class="tool" data-tool="text" aria-label="Text (T)"><svg viewBox="0 0 24 24"><path d="M4 6V4h16v2M12 4v16M8 20h8"/></svg><span class="tip">Text <kbd>T</kbd></span></button>
<button class="tool" data-tool="eraser" aria-label="Eraser (E)"><svg viewBox="0 0 24 24"><path d="m7 16 9-10a2 2 0 0 1 3 0l2 2a2 2 0 0 1 0 3l-8 9H9l-5-5a2 2 0 0 1 0-3l7-8"/><path d="m9 20 7-8"/></svg><span class="tip">Eraser <kbd>E</kbd></span></button>
</nav>
<div class="props"><div class="propgroup"><span class="prop-label">Stroke</span><input id="stroke" type="color" value="#303846" title="Stroke color"></div><div class="prop-sep"></div><div class="propgroup"><span class="prop-label">Fill</span><input id="fill" type="color" value="#ffffff" title="Fill color"></div><div class="prop-sep"></div><div class="propgroup"><span class="prop-label">Width</span><input id="width" type="number" value="2" min="1" max="24" title="Stroke width"></div></div>
<div class="history"><button class="histbtn" id="undo" title="Undo (Ctrl+Z)" aria-label="Undo">↶</button><button class="histbtn" id="redo" title="Redo (Ctrl+Shift+Z)" aria-label="Redo">↷</button></div>
<div class="zoom"><button id="zoomout" aria-label="Zoom out">−</button><span class="zoom-value" id="zoomval">100%</span><button id="zoomin" aria-label="Zoom in">+</button><button class="reset" id="reset">Reset view</button></div>
<div class="status" id="status">Select an object to edit</div><div class="hint">Space + drag to pan · Scroll to zoom</div><div class="toast" id="toast"></div><textarea class="text-editor" id="textEditor" spellcheck="false"></textarea>
</main></div>
<script>
'use strict';
/* ── Tunable constants ── */
const CONFIG={minZoom:.25,maxZoom:4,zoomStep:1.12,hitTolerance:8,handleSize:8,grid:24,storageKey:'canvas-whiteboard-v1',defaultStroke:'#303846',defaultFill:'#ffffff',defaultWidth:2};
/* ── Scene, viewport, and history ── */
const canvas=document.getElementById('canvas'),ctx=canvas.getContext('2d'),workspace=document.getElementById('workspace');
let scene=[],selected=new Set(),tool='select',view={x:0,y:0,zoom:1},dpr=1,interaction=null,spaceDown=false,history=[],historyIndex=-1,suppressClick=false,saveTimer;
const uid=()=>`o${Date.now().toString(36)}${Math.random().toString(36).slice(2,7)}`;
const clone=o=>JSON.parse(JSON.stringify(o));
function load(){try{const raw=localStorage.getItem(CONFIG.storageKey);if(raw){const data=JSON.parse(raw);if(Array.isArray(data.scene)){scene=data.scene.filter(o=>o&&typeof o==='object'&&o.id&&o.type);if(data.view&&Number.isFinite(data.view.zoom))view={x:data.view.x||0,y:data.view.y||0,zoom:clamp(data.view.zoom,CONFIG.minZoom,CONFIG.maxZoom)};}}}catch(e){console.warn('Could not restore board',e)}history=[clone(scene)];historyIndex=0;}
function persist(){clearTimeout(saveTimer);document.getElementById('saved').textContent='Saving…';saveTimer=setTimeout(()=>{try{localStorage.setItem(CONFIG.storageKey,JSON.stringify({scene,view}));document.getElementById('saved').textContent='All changes saved'}catch(e){document.getElementById('saved').textContent='Could not save board'}},180)}
function commit(){const snap=clone(scene);if(historyIndex>=0&&JSON.stringify(history[historyIndex])===JSON.stringify(snap))return;history=history.slice(0,historyIndex+1);history.push(snap);if(history.length>101)history.shift();historyIndex=history.length-1;updateHistory();persist()}
function restoreHistory(i){if(i<0||i>=history.length)return;finishText(true);historyIndex=i;scene=clone(history[i]);selected.clear();updateHistory();persist();draw()}
function updateHistory(){document.getElementById('undo').disabled=historyIndex<=0;document.getElementById('redo').disabled=historyIndex>=history.length-1}
function clamp(v,a,b){return Math.max(a,Math.min(b,v))}
function resize(){dpr=Math.max(1,window.devicePixelRatio||1);const r=workspace.getBoundingClientRect();canvas.width=Math.round(r.width*dpr);canvas.height=Math.round(r.height*dpr);canvas.style.width=r.width+'px';canvas.style.height=r.height+'px';draw()}
function screenToWorld(p){return{x:(p.x-view.x)/view.zoom,y:(p.y-view.y)/view.zoom}}
function worldToScreen(p){return{x:p.x*view.zoom+view.x,y:p.y*view.zoom+view.y}}
function getPointer(e){const r=canvas.getBoundingClientRect();return{x:e.clientX-r.left,y:e.clientY-r.top}}
function setZoom(z,anchor){z=clamp(z,CONFIG.minZoom,CONFIG.maxZoom);const w=screenToWorld(anchor);view.zoom=z;view.x=anchor.x-w.x*z;view.y=anchor.y-w.y*z;document.getElementById('zoomval').textContent=Math.round(z*100)+'%';persist();draw()}
/* ── Geometry and drawing ── */
function norm(o){const x=Math.min(o.x1,o.x2),y=Math.min(o.y1,o.y2);return{x,y,w:Math.abs(o.x2-o.x1),h:Math.abs(o.y2-o.y1)}}
function draw(){if(!ctx)return;const w=canvas.width/dpr,h=canvas.height/dpr;ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,w,h);ctx.fillStyle='#fbfcfd';ctx.fillRect(0,0,w,h);
// Subtle world grid, anchored to the viewport transform.
const step=CONFIG.grid*view.zoom;if(step>=7){let sx=((view.x%step)+step)%step,sy=((view.y%step)+step)%step;ctx.fillStyle='#dfe3e9';for(let x=sx;x<w;x+=step)for(let y=sy;y<h;y+=step){ctx.beginPath();ctx.arc(x,y,.85,0,Math.PI*2);ctx.fill()}}
ctx.save();ctx.translate(view.x,view.y);ctx.scale(view.zoom,view.zoom);for(const o of scene)renderObject(o);if(interaction&&interaction.preview)renderObject(interaction.preview,true);ctx.restore();
if(selected.size){const b=selectionBounds();if(b){const a=worldToScreen({x:b.x,y:b.y}),z=worldToScreen({x:b.x+b.w,y:b.y+b.h});ctx.save();ctx.strokeStyle='#6377e9';ctx.lineWidth=1;ctx.setLineDash([4,3]);ctx.strokeRect(a.x,a.y,z.x-a.x,z.y-a.y);ctx.setLineDash([]);ctx.fillStyle='#fff';for(const p of handlePoints(b)){const s=worldToScreen(p);ctx.fillRect(s.x-CONFIG.handleSize/2,s.y-CONFIG.handleSize/2,CONFIG.handleSize,CONFIG.handleSize);ctx.strokeRect(s.x-CONFIG.handleSize/2,s.y-CONFIG.handleSize/2,CONFIG.handleSize,CONFIG.handleSize)}ctx.restore()}
}
if(interaction&&interaction.rubber){ctx.save();ctx.fillStyle='#6175e919';ctx.strokeStyle='#6075e9';ctx.lineWidth=1;ctx.setLineDash([4,3]);const a=worldToScreen(interaction.rubber.a),b=worldToScreen(interaction.rubber.b);ctx.fillRect(a.x,a.y,b.x-a.x,b.y-a.y);ctx.strokeRect(a.x,a.y,b.x-a.x,b.y-a.y);ctx.restore()}
}
function renderObject(o,preview=false){ctx.save();ctx.strokeStyle=o.stroke||CONFIG.defaultStroke;ctx.fillStyle=o.fill||'transparent';ctx.lineWidth=Math.max(.5,o.width||2);ctx.lineCap='round';ctx.lineJoin='round';if(preview)ctx.globalAlpha=.72;
if(o.type==='rect'||o.type==='ellipse'){const b=norm(o);if(o.type==='rect'){ctx.beginPath();ctx.rect(b.x,b.y,b.w,b.h);if(o.fill&&o.fill!=='transparent')ctx.fill();ctx.stroke()}else{ctx.beginPath();ctx.ellipse(o.x1+(o.x2-o.x1)/2,o.y1+(o.y2-o.y1)/2,Math.max(.01,Math.abs(o.x2-o.x1)/2),Math.max(.01,Math.abs(o.y2-o.y1)/2),0,0,Math.PI*2);if(o.fill&&o.fill!=='transparent')ctx.fill();ctx.stroke()}}
else if(o.type==='arrow'){ctx.beginPath();ctx.moveTo(o.x1,o.y1);ctx.lineTo(o.x2,o.y2);ctx.stroke();const ang=Math.atan2(o.y2-o.y1,o.x2-o.x1),len=11;ctx.beginPath();ctx.moveTo(o.x2,o.y2);ctx.lineTo(o.x2-len*Math.cos(ang-.48),o.y2-len*Math.sin(ang-.48));ctx.moveTo(o.x2,o.y2);ctx.lineTo(o.x2-len*Math.cos(ang+.48),o.y2-len*Math.sin(ang+.48));ctx.stroke()}
else if(o.type==='pen'){if(o.points&&o.points.length){ctx.beginPath();ctx.moveTo(o.points[0].x,o.points[0].y);if(o.points.length===1){ctx.lineTo(o.points[0].x+.01,o.points[0].y+.01)}else for(let i=1;i<o.points.length-1;i++){const p=o.points[i],n=o.points[i+1];ctx.quadraticCurveTo(p.x,p.y,(p.x+n.x)/2,(p.y+n.y)/2)}if(o.points.length>1){const p=o.points[o.points.length-1];ctx.lineTo(p.x,p.y)}ctx.stroke()}}
else if(o.type==='text'){ctx.fillStyle=o.stroke||CONFIG.defaultStroke;ctx.font=`${o.fontSize||20}px "Segoe UI",sans-serif`;ctx.textBaseline='top';const lines=(o.text||'').split('\n');lines.forEach((line,i)=>ctx.fillText(line,o.x,o.y+i*(o.fontSize||20)*1.35));}
ctx.restore()}
function objectBounds(o){if(o.type==='pen'){if(!o.points?.length)return{x:0,y:0,w:0,h:0};const xs=o.points.map(p=>p.x),ys=o.points.map(p=>p.y),x=Math.min(...xs),y=Math.min(...ys);return{x,y,w:Math.max(...xs)-x,h:Math.max(...ys)-y}}if(o.type==='text'){ctx.save();ctx.font=`${o.fontSize||20}px "Segoe UI",sans-serif`;const lines=(o.text||'').split('\n');const w=Math.max(8,...lines.map(l=>ctx.measureText(l).width));ctx.restore();return{x:o.x,y:o.y,w,h:lines.length*(o.fontSize||20)*1.35}}return norm(o)}
function selectionBounds(){const objs=scene.filter(o=>selected.has(o.id));if(!objs.length)return null;const bs=objs.map(objectBounds),x=Math.min(...bs.map(b=>b.x)),y=Math.min(...bs.map(b=>b.y)),r=Math.max(...bs.map(b=>b.x+b.w)),bot=Math.max(...bs.map(b=>b.y+b.h));return{x,y,w:r-x,h:bot-y}}
function handlePoints(b){return[{x:b.x,y:b.y,handle:'nw'},{x:b.x+b.w,y:b.y,handle:'ne'},{x:b.x,y:b.y+b.h,handle:'sw'},{x:b.x+b.w,y:b.y+b.h,handle:'se'}]}
function pointSegDist(p,a,b){const dx=b.x-a.x,dy=b.y-a.y,t=clamp(((p.x-a.x)*dx+(p.y-a.y)*dy)/(dx*dx+dy*dy||1),0,1);return Math.hypot(p.x-(a.x+t*dx),p.y-(a.y+t*dy))}
function hit(o,p,tol){const b=objectBounds(o),half=(o.width||2)/2;if(o.type==='rect'){const x=p.x,y=p.y;if(o.fill&&o.fill!=='transparent'&&x>=b.x&&x<=b.x+b.w&&y>=b.y&&y<=b.y+b.h)return true;return x>=b.x-tol-half&&x<=b.x+b.w+tol+half&&y>=b.y-tol-half&&y<=b.y+b.h+tol+half&&(Math.min(Math.abs(x-b.x),Math.abs(x-b.x-b.w))<=tol+half||Math.min(Math.abs(y-b.y),Math.abs(y-b.y-b.h))<=tol+half)}
if(o.type==='ellipse'){const rx=Math.abs(o.x2-o.x1)/2,ry=Math.abs(o.y2-o.y1)/2,cx=(o.x1+o.x2)/2,cy=(o.y1+o.y2)/2;if(rx<.01||ry<.01)return Math.hypot(p.x-cx,p.y-cy)<=tol+half;const q=((p.x-cx)/rx)**2+((p.y-cy)/ry)**2;if(o.fill&&o.fill!=='transparent'&&q<=1)return true;const radial=Math.sqrt(q),approx=Math.abs(radial-1)*Math.min(rx,ry);return approx<=tol+half}
if(o.type==='arrow')return pointSegDist(p,{x:o.x1,y:o.y1},{x:o.x2,y:o.y2})<=tol+half+3;
if(o.type==='pen'){const pts=o.points||[];if(pts.length===1)return Math.hypot(p.x-pts[0].x,p.y-pts[0].y)<=tol+half;for(let i=1;i<pts.length;i++)if(pointSegDist(p,pts[i-1],pts[i])<=tol+half)return true;return false}
if(o.type==='text')return p.x>=b.x-tol&&p.x<=b.x+b.w+tol&&p.y>=b.y-tol&&p.y<=b.y+b.h+tol;return false}
function topHit(p){for(let i=scene.length-1;i>=0;i--)if(hit(scene[i],p,CONFIG.hitTolerance/view.zoom))return scene[i];return null}
function within(o,r){const b=objectBounds(o);return b.x>=r.x&&b.y>=r.y&&b.x+b.w<=r.x+r.w&&b.y+b.h<=r.y+r.h}
function handleAt(p){const b=selectionBounds();if(!b)return null;const threshold=(CONFIG.handleSize/2+2)/view.zoom;for(const h of handlePoints(b))if(Math.hypot(p.x-h.x,p.y-h.y)<=threshold)return h.handle;return null}
/* ── Selection, drawing, and pointer state machine ── */
function setTool(t){tool=t;document.querySelectorAll('.tool').forEach(el=>el.classList.toggle('active',el.dataset.tool===t));canvas.style.cursor=t==='select'?'default':t==='hand'?'grab':t==='text'?'text':t==='eraser'?'cell':'crosshair';document.getElementById('status').textContent=({select:'Select an object to edit',hand:'Drag to pan the canvas',pen:'Draw a freehand stroke',rect:'Drag to draw a rectangle',ellipse:'Drag to draw an ellipse',arrow:'Drag to draw an arrow',text:'Click anywhere to add text',eraser:'Drag over objects to erase'})[t]||''}
function removeIds(ids){if(!ids.size)return;scene=scene.filter(o=>!ids.has(o.id));for(const id of ids)selected.delete(id)}
function beginText(world,existing){const el=document.getElementById('textEditor');let obj=existing||{id:uid(),type:'text',x:world.x,y:world.y,text:'',stroke:document.getElementById('stroke').value,fill:'transparent',width:1,fontSize:20};const s=worldToScreen({x:obj.x,y:obj.y});el.style.left=s.x+'px';el.style.top=s.y+'px';el.style.display='block';el.style.fontSize=(obj.fontSize*view.zoom)+'px';el.value=obj.text||'';el.dataset.id=obj.id;el.dataset.new=existing?'0':'1';el.dataset.old=existing?JSON.stringify(existing):'';el.focus();interaction={kind:'text',obj};}
function finishText(commitIt){const el=document.getElementById('textEditor');if(el.style.display==='none')return;const id=el.dataset.id,wasNew=el.dataset.new==='1',old=el.dataset.old?JSON.parse(el.dataset.old):null,text=el.value;el.style.display='none';el.value='';interaction=null;if(!commitIt)return;if(wasNew){if(text.trim()){scene.push({id,type:'text',x:parseFloat(el.dataset.x),y:parseFloat(el.dataset.y),text,stroke:document.getElementById('stroke').value,fill:'transparent',width:1,fontSize:20});selected=new Set([id]);commit()}}else{const obj=scene.find(o=>o.id===id);if(obj&&text!==old.text){obj.text=text;commit()}selected=new Set([id])}draw()}
canvas.addEventListener('pointerdown',e=>{if(e.button===2)return;canvas.setPointerCapture(e.pointerId);const screen=getPointer(e),world=screenToWorld(screen),pan=e.button===1||spaceDown||tool==='hand';if(pan){interaction={kind:'pan',start:screen,view:{...view}};canvas.style.cursor='grabbing';return}
if(tool==='text'){const hitObj=topHit(world);if(hitObj?.type==='text'){finishText(true);beginText(world,hitObj)}else{finishText(true);const el=document.getElementById('textEditor');el.dataset.x=world.x;el.dataset.y=world.y;beginText(world)}return}
if(tool==='eraser'){interaction={kind:'erase',removed:new Set()};const h=topHit(world);if(h){interaction.removed.add(h.id);removeIds(interaction.removed);draw()}return}
if(tool==='select'){const handle=handleAt(world);if(handle&&selected.size){interaction={kind:'resize',handle,start:world,before:clone(scene),bounds:selectionBounds()};return}const h=topHit(world);if(h){if(e.shiftKey){if(selected.has(h.id))selected.delete(h.id);else selected.add(h.id);draw();return}if(!selected.has(h.id))selected=new Set([h.id]);interaction={kind:'move',start:world,before:clone(scene),ids:new Set(selected)};draw();return}if(!e.shiftKey)selected.clear();interaction={kind:'rubber',start:world,base:new Set(selected)};draw();return}
if(['pen','rect','ellipse','arrow'].includes(tool)){const obj={id:uid(),type:tool==='pen'?'pen':tool,x1:world.x,y1:world.y,x2:world.x,y2:world.y,stroke:document.getElementById('stroke').value,fill:tool==='rect'||tool==='ellipse'?document.getElementById('fill').value:'transparent',width:clamp(+document.getElementById('width').value||2,1,24)};if(tool==='pen')obj.points=[{...world,p: e.pressure||.5}];interaction={kind:'draw',obj};draw()}
});
canvas.addEventListener('pointermove',e=>{const screen=getPointer(e),world=screenToWorld(screen);if(!interaction){if(tool==='select'&&handleAt(world))canvas.style.cursor='nwse-resize';return}const it=interaction;if(it.kind==='pan'){view.x=it.view.x+screen.x-it.start.x;view.y=it.view.y+screen.y-it.start.y;draw();return}if(it.kind==='draw'){if(it.obj.type==='pen'){const pts=it.obj.points,last=pts[pts.length-1];if(Math.hypot(world.x-last.x,world.y-last.y)>.6/view.zoom)pts.push({...world,p:e.pressure||.5})}else{it.obj.x2=world.x;it.obj.y2=world.y}it.preview=it.obj;draw();return}if(it.kind==='move'){const dx=world.x-it.start.x,dy=world.y-it.start.y;for(const o of scene){const before=it.before.find(x=>x.id===o.id);if(!before||!it.ids.has(o.id))continue;moveObject(o,before,dx,dy)}draw();return}if(it.kind==='resize'){resizeSelection(it,world,e.shiftKey);draw();return}if(it.kind==='rubber'){it.rubber={a:it.start,b:world};draw();return}if(it.kind==='erase'){const h=topHit(world);if(h&&!it.removed.has(h.id)){it.removed.add(h.id);removeIds(new Set([h.id]));draw()}return}});
function moveObject(o,b,dx,dy){if(o.type==='pen')o.points=b.points.map(p=>({...p,x:p.x+dx,y:p.y+dy}));else if(o.type==='text'){o.x=b.x+dx;o.y=b.y+dy}else{o.x1=b.x1+dx;o.y1=b.y1+dy;o.x2=b.x2+dx;o.y2=b.y2+dy}}
function resizeSelection(it,p,keep){const b=it.bounds,handle=it.handle;let ax=handle.includes('w')?b.x+b.w:b.x,ay=handle.includes('n')?b.y+b.h:b.y;let sx=(p.x-ax)/(handle.includes('w')?-b.w:b.w||1),sy=(p.y-ay)/(handle.includes('n')?-b.h:b.h||1);if(keep){const s=Math.max(Math.abs(sx),Math.abs(sy));sx=Math.sign(sx||1)*s;sy=Math.sign(sy||1)*s}const ids=selected;for(const o of scene){if(!ids.has(o.id))continue;const orig=it.before.find(x=>x.id===o.id),ob=objectBounds(orig);const nx=ax+(ob.x-ax)*sx,ny=ay+(ob.y-ay)*sy,nw=ob.w*sx,nh=ob.h*sy;if(orig.type==='pen'){o.points=orig.points.map(pt=>({ ...pt,x:ax+(pt.x-ax)*sx,y:ay+(pt.y-ay)*sy}))}else if(orig.type==='text'){o.x=nx;o.y=ny;o.fontSize=Math.max(7,orig.fontSize*Math.sqrt(Math.abs(sx*sy)))}else{o.x1=nx;o.y1=ny;o.x2=nx+nw;o.y2=ny+nh}}}
canvas.addEventListener('pointerup',e=>{if(!interaction)return;const it=interaction,world=screenToWorld(getPointer(e));if(it.kind==='draw'){const o=it.obj;if(o.type==='pen'&&o.points.length===1)o.points.push({...o.points[0],x:o.points[0].x+.01});scene.push(o);selected=new Set([o.id]);commit()}else if(it.kind==='move'||it.kind==='resize'){if(JSON.stringify(it.before)!==JSON.stringify(scene))commit()}else if(it.kind==='rubber'){const r={x:Math.min(it.start.x,world.x),y:Math.min(it.start.y,world.y),w:Math.abs(world.x-it.start.x),h:Math.abs(world.y-it.start.y)};for(const o of scene)if(within(o,r))selected.add(o.id)}else if(it.kind==='erase'){if(it.removed.size)commit()}else if(it.kind==='pan')persist();interaction=null;canvas.style.cursor=tool==='select'?'default':tool==='hand'?'grab':tool==='text'?'text':tool==='eraser'?'cell':'crosshair';draw()});
canvas.addEventListener('pointercancel',()=>{if(interaction?.before)scene=clone(interaction.before);interaction=null;draw()});canvas.addEventListener('wheel',e=>{e.preventDefault();const p=getPointer(e);setZoom(view.zoom*Math.exp(-e.deltaY*.0015),p)},{passive:false});canvas.addEventListener('dblclick',e=>{const h=topHit(screenToWorld(getPointer(e)));if(h?.type==='text')beginText(screenToWorld(getPointer(e)),h)});canvas.addEventListener('contextmenu',e=>e.preventDefault());
/* ── Keyboard, properties, and controls ── */
document.getElementById('textEditor').addEventListener('blur',()=>finishText(true));document.getElementById('textEditor').addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();finishText(false)}if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();finishText(true)}});
function deleteSelection(){if(!selected.size)return;removeIds(new Set(selected));selected.clear();commit();draw()}
function updateStyle(key,value){if(!selected.size)return;let changed=false;for(const o of scene)if(selected.has(o.id)&&o[key]!==value){o[key]=value;changed=true}if(changed){commit();draw()}}
document.getElementById('stroke').addEventListener('input',e=>updateStyle('stroke',e.target.value));document.getElementById('fill').addEventListener('input',e=>updateStyle('fill',e.target.value));document.getElementById('width').addEventListener('change',e=>updateStyle('width',clamp(+e.target.value||2,1,24)));
document.querySelectorAll('.tool').forEach(b=>b.addEventListener('click',()=>setTool(b.dataset.tool)));
document.getElementById('undo').onclick=()=>restoreHistory(historyIndex-1);document.getElementById('redo').onclick=()=>restoreHistory(historyIndex+1);document.getElementById('zoomin').onclick=()=>setZoom(view.zoom*CONFIG.zoomStep,{x:workspace.clientWidth/2,y:workspace.clientHeight/2});document.getElementById('zoomout').onclick=()=>setZoom(view.zoom/CONFIG.zoomStep,{x:workspace.clientWidth/2,y:workspace.clientHeight/2});document.getElementById('reset').onclick=()=>{view={x:0,y:0,zoom:1};document.getElementById('zoomval').textContent='100%';persist();draw()};document.getElementById('share').onclick=()=>showToast('Share links are coming soon');
function showToast(s){const t=document.getElementById('toast');t.textContent=s;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),1800)}
window.addEventListener('keydown',e=>{const editor=document.getElementById('textEditor'),typing=editor.style.display!=='none'||['INPUT','TEXTAREA'].includes(document.activeElement.tagName);if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'&&!typing){e.preventDefault();restoreHistory(historyIndex+(e.shiftKey?1:-1));return}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='y'&&!typing){e.preventDefault();restoreHistory(historyIndex+1);return}if(typing)return;if(e.code==='Space'){spaceDown=true;canvas.style.cursor='grab';e.preventDefault();return}if((e.key==='Delete'||e.key==='Backspace')&&selected.size){e.preventDefault();deleteSelection();return}if(selected.size&&['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key)){e.preventDefault();const before=clone(scene),step=e.shiftKey?10:1;for(const o of scene)if(selected.has(o.id))moveObject(o,o,e.key==='ArrowLeft'?-step:e.key==='ArrowRight'?step:0,e.key==='ArrowUp'?-step:e.key==='ArrowDown'?step:0);commit();draw();return}if(e.ctrlKey||e.metaKey||e.altKey)return;const key=e.key.toLowerCase(),map={v:'select',h:'hand',p:'pen',r:'rect',o:'ellipse',l:'arrow',t:'text',e:'eraser'};if(map[key])setTool(map[key])});window.addEventListener('keyup',e=>{if(e.code==='Space'){spaceDown=false;if(!interaction)canvas.style.cursor=tool==='select'?'default':'crosshair'}});window.addEventListener('blur',()=>{spaceDown=false});window.addEventListener('resize',resize);
/* ── Boot ── */
load();document.getElementById('zoomval').textContent=Math.round(view.zoom*100)+'%';setTool('select');resize();updateHistory();
</script></body></html>
<!-- agent-meta {"model":"openai/gpt-6-luna","provider":"openrouter","persona":"full","sessionId":"c498f1fa-8ac8-48e2-8c4c-ab70475fc84f","tokensIn":189132,"tokensOut":20056,"tokensTotal":209188,"cost":0.013991180000000004,"turns":12,"toolCalls":11,"failedToolCalls":0,"timestamp":"2026-09-23T00:25:40.516Z"} -->