Whiteboard
muse spark 1.2 contributor · 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 55,894 bytes · SHA-256 7e4bd6cf5c1b
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Board — Whiteboard Prototype</title>
<style>
:root{
--bg:#e8e9eb;
--canvas:#ffffff;
--toolbar-bg:#1a1a1e;
--toolbar-border:#2a2a30;
--accent:#7c5cff;
--accent2:#ff6b4a;
--text:#0f0f12;
--muted:#8a8a93;
--panel:#ffffff;
--shadow:0 8px 30px rgba(0,0,0,.12), 0 1px 3px rgba(0,0,0,.08);
--radius:14px;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;font-family: ui-sans-system, -apple-system, BlinkMacSystemFont, "Inter", Segoe UI, Roboto, Helvetica, Arial, sans-serif;background:var(--bg);color:var(--text)}
#app{position:relative;width:100%;height:100%;overflow:hidden}
canvas{display:block;position:absolute;inset:0;width:100%;height:100%;cursor:crosshair}
/* Top bar */
.topbar{
position:absolute;top:14px;left:50%;transform:translateX(-50%);
display:flex;align-items:center;gap:6px;
background:var(--toolbar-bg);border:1px solid var(--toolbar-border);
padding:6px;border-radius:999px;box-shadow:var(--shadow);z-index:20;
}
.tool-btn{
width:38px;height:38px;border-radius:999px;border:none;
background:transparent;color:#c9c9d1;display:grid;place-items:center;
cursor:pointer;transition:.15s;font-size:15px;position:relative;
}
.tool-btn:hover{background:#2a2a30;color:#fff}
.tool-btn.active{background:#fff;color:#111;box-shadow:0 2px 8px rgba(0,0,0,.2)}
.tool-btn kbd{
position:absolute;bottom:-18px;left:50%;transform:translateX(-50%);
font-size:9px;letter-spacing:.06em;background:#1a1a1e;color:#8a8a93;
padding:1px 4px;border-radius:4px;opacity:0;transition:.15s;pointer-events:none;
}
.tool-btn:hover kbd{opacity:1}
.sep{width:1px;height:22px;background:#2e2e36;margin:0 4px}
.icon{width:18px;height:18px;display:block}
/* Properties bar */
.props{
position:absolute;top:68px;left:14px;
background:var(--panel);border:1px solid #e6e6ea;border-radius:var(--radius);
padding:10px 12px;box-shadow:var(--shadow);z-index:19;
display:flex;align-items:center;gap:12px;min-width:260px;
}
.props.hidden{opacity:.45;pointer-events:none}
.props label{font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--muted);text-transform:uppercase}
.swatches{display:flex;gap:6px;align-items:center}
.swatch{width:22px;height:22px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px #ddd,c0 1px 3px rgba(0,0,0,.1);cursor:pointer;position:relative}
.swatch.active{box-shadow:0 0 0 2px var(--accent), 0 1px 4px rgba(0,0,0,.15)}
.swatch.transparent{background:repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 0 0 / 8px 8px}
.swatch.transparent::after{content:"";position:absolute;inset:0;border-radius:50%;background:linear-gradient(45deg, transparent 45%, #ff4d4d 45%, #ff4d4d 55%, transparent 55%)}
.width-row{display:flex;align-items:center;gap:8px}
.width-row input[type=range]{width:84px;accent-color:var(--accent)}
.width-val{font-size:12px;font-weight:600;min-width:28px;text-align:center;background:#f2f2f5;border-radius:6px;padding:2px 6px}
/* Zoom indicator */
.zoombar{
position:absolute;bottom:16px;right:16px;
background:var(--panel);border:1px solid #e6e6ea;border-radius:999px;
display:flex;align-items:center;gap:2px;padding:4px;box-shadow:var(--shadow);z-index:20;
}
.zoombar button{border:none;background:transparent;width:32px;height:32px;border-radius:999px;cursor:pointer;display:grid;place-items:center;color:#333}
.zoombar button:hover{background:#f2f2f5}
.zoom-label{font-size:12px;font-weight:600;min-width:52px;text-align:center;padding:0 6px}
/* Bottom hint */
.hint{
position:absolute;bottom:16px;left:16px;
background:rgba(26,26,30,.92);color:#c9c9d1;font-size:11px;
padding:8px 12px;border-radius:999px;z-index:20;display:flex;gap:14px;align-items:center;
backdrop-filter:blur(6px)
}
.hint b{color:#fff}
.hint span{opacity:.9}
/* Text overlay */
#textOverlay{
position:absolute;display:none;z-index:30;
background:#fff;border:2px solid var(--accent);border-radius:8px;
box-shadow:var(--shadow);padding:8px;min-width:160px;
}
#textOverlay textarea{
width:220px;min-height:60px;border:none;outline:none;resize:none;
font:16px/1.4 ui-sans-system, Inter, sans-serif;color:#111;
}
#textOverlay .tip{font-size:10px;color:#8a8a93;margin-top:4px}
/* Undo toast */
.toast{
position:absolute;top:76px;left:50%;transform:translateX(-50%);
background:#1a1a1e;color:#fff;font-size:12px;padding:8px 14px;border-radius:999px;
opacity:0;pointer-events:none;transition:.2s;z-index:25
}
.toast.show{opacity:1}
</style>
</head>
<body>
<div id="app">
<canvas id="c"></canvas>
<!-- grid is drawn on canvas -->
<div class="props" id="propsBar">
<div>
<label>Stroke</label>
<div class="swatches" id="strokeSwatches"></div>
</div>
<div>
<label>Fill</label>
<div class="swatches" id="fillSwatches"></div>
</div>
<div>
<label>Width</label>
<div class="width-row">
<input id="widthRange" type="range" min="1" max="12" step="1" value="2">
<span class="width-val" id="widthVal">2</span>
</div>
</div>
</div>
<div class="topbar" id="toolbar">
<button class="tool-btn active" data-tool="select" title="Select (V)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 3l7.5 13L13 13l3-2.5L3 3z"/><circle cx="13.5" cy="13.5" r="1" fill="currentColor" stroke="none"/></svg><kbd>V</kbd></button>
<button class="tool-btn" data-tool="pen" title="Pen (P)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 19l7-7a2 2 0 0 0-2.8-2.8l-7 7v2.8H12z"/><path d="M5 19h4"/></svg><kbd>P</kbd></button>
<button class="tool-btn" data-tool="rect" title="Rectangle (R)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="6" width="16" height="12" rx="1.5"/></svg><kbd>R</kbd></button>
<button class="tool-btn" data-tool="ellipse" title="Ellipse (O)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><ellipse cx="12" cy="12" rx="8" ry="6"/></svg><kbd>O</kbd></button>
<button class="tool-btn" data-tool="arrow" title="Arrow (L)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 12h14"/><path d="M13 6l6 6-6 6"/></svg><kbd>L</kbd></button>
<button class="tool-btn" data-tool="text" title="Text (T)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 7h12M12 7v10M8 17h8"/></svg><kbd>T</kbd></button>
<button class="tool-btn" data-tool="eraser" title="Eraser (E)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 13l-6 6H6l6-6 6-6h0l-0 0z"/><path d="M6 19h12"/></svg><kbd>E</kbd></button>
<div class="sep"></div>
<button class="tool-btn" id="undoBtn" title="Undo (Ctrl+Z)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9 8H5V4"/><path d="M5 8a7 7 0 0 1 12.2-4.2"/><path d="M15 16h4v4"/><path d="M19 16a7 7 0 0 1-12.2 4.2"/></svg></button>
<button class="tool-btn" id="redoBtn" title="Redo (Ctrl+Shift+Z)"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M15 8h4V4"/><path d="M19 8A7 7 0 0 0 6.8 3.8"/><path d="M9 16H5v4"/><path d="M5 16a7 7 0 0 0 12.2 4.2"/></svg></button>
<div class="sep"></div>
<button class="tool-btn" id="clearBtn" title="Clear board"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 7h16"/><path d="M9 7V5h6v2"/><path d="M7 7l1 12h8l1-12"/><path d="M10 11v6M14 11v6"/></svg></button>
</div>
<div class="zoombar">
<button id="zoomOut" title="Zoom out">−</button>
<span class="zoom-label" id="zoomLabel">100%</span>
<button id="zoomIn" title="Zoom in">+</button>
<div style="width:1px;height:18px;background:#e6e6ea;margin:0 2px"></div>
<button id="resetView" title="Reset view">⌖</button>
</div>
<div class="hint">
<span><b>Space</b> + drag to pan</span>
<span><b>Wheel</b> to zoom</span>
<span><b>Del</b> to delete</span>
<span style="opacity:.5">|</span>
<span id="objCount">0 objects</span>
</div>
<div id="textOverlay">
<textarea id="textInput" placeholder="Type something…"></textarea>
<div class="tip">Enter to save • Esc to cancel</div>
</div>
<div class="toast" id="toast"></div>
</div>
<script>
/* ============================================================
TUNABLE CONSTANTS
============================================================ */
const CONFIG = {
ZOOM_MIN: 0.25,
ZOOM_MAX: 4,
ZOOM_STEP: 0.12,
GRID_SIZE: 24,
GRID_DOT_RADIUS: 1.1,
HIT_TOLERANCE: 8, // world px tolerance (divided by zoom for screen)
HANDLE_SIZE: 8, // screen px
HANDLE_HIT: 10,
PEN_SMOOTH: 0.35,
PEN_MIN_DIST: 2,
STROKE_DEFAULT: '#1a1a1e',
FILL_DEFAULT: 'transparent',
FILL_PALETTE: ['transparent','#ffffff','#fef3c7','#fecaca','#bfdbfe','#bbf7d0','#e9d5ff','#fed7aa'],
STROKE_PALETTE: ['#1a1a1e','#ef4444','#f59e0b','#10b981','#3b82f6','#8b5cf6','#ec4899','#6b7280'],
STROKE_WIDTH_DEFAULT: 2,
STORAGE_KEY: 'whiteboard-proto-v2',
BG: '#ffffff',
APP_BG: '#e8e9eb',
};
/* ============================================================
STATE
============================================================ */
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let DPR = Math.max(1, window.devicePixelRatio || 1);
let cam = { x: 0, y: 0, zoom: 1 };
let scene = []; // array of objects {id,type,...}
let selectedIds = new Set();
let tool = 'select';
let clipboard = null;
// style defaults for new objects
let defaults = {
stroke: CONFIG.STROKE_DEFAULT,
fill: CONFIG.FILL_DEFAULT,
strokeWidth: CONFIG.STROKE_WIDTH_DEFAULT,
opacity: 1
};
// interaction state
let isPanning = false;
let isDragging = false;
let isResizing = false;
let isDrawing = false;
let isRubber = false;
let dragStart = null; // {sx,sy, wx,wy}
let dragOffset = {x:0,y:0};
let rubberRect = null; // {x,y,w,h} in world
let currentShape = null; // shape being created
let penPoints = [];
let resizeHandle = null; // 'nw','ne','sw','se'
let resizeStart = null; // {box, mouseWorld}
let moveStartPositions = new Map(); // id -> {x,y,x1,y1,x2,y2, points}
let eraserPath = [];
let spaceHeld = false;
let mousePos = {x:0,y:0}; // screen
// history
let history = [];
let historyIndex = -1;
let historyBatch = null; // for grouping
/* ============================================================
UTILS
============================================================ */
const uid = () => Math.random().toString(36).slice(2,9);
const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
function deepClone(o){ return JSON.parse(JSON.stringify(o)); }
function screenToWorld(sx,sy){
return { x: cam.x + sx / cam.zoom, y: cam.y + sy / cam.zoom };
}
function worldToScreen(wx,wy){
return { x: (wx - cam.x)*cam.zoom, y: (wy - cam.y)*cam.zoom };
}
function getCanvasRect(){ return canvas.getBoundingClientRect(); }
function eventToScreen(e){
const r=getCanvasRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function worldDist(a,b){ const dx=a.x-b.x, dy=a.y-b.y; return Math.hypot(dx,dy); }
function showToast(msg){
const t=document.getElementById('toast');
t.textContent=msg; t.classList.add('show');
clearTimeout(t._tm); t._tm=setTimeout(()=>t.classList.remove('show'),1400);
}
/* ============================================================
OBJECT MODEL
============================================================ */
function makeRect(x,y,w,h, opts={}){
return { id:uid(), type:'rect', x, y, w, h,
stroke: opts.stroke||defaults.stroke, fill: opts.fill||defaults.fill,
strokeWidth: opts.strokeWidth||defaults.strokeWidth, rotation:0, opacity:1 };
}
function makeEllipse(x,y,w,h, opts={}){
return { id:uid(), type:'ellipse', x, y, w, h,
stroke: opts.stroke||defaults.stroke, fill: opts.fill||defaults.fill,
strokeWidth: opts.strokeWidth||defaults.strokeWidth, opacity:1 };
}
function makeLine(x1,y1,x2,y2, opts={}){
return { id:uid(), type:'line', x1,y1,x2,y2,
stroke: opts.stroke||defaults.stroke, strokeWidth: opts.strokeWidth||defaults.strokeWidth, opacity:1 };
}
function makeArrow(x1,y1,x2,y2, opts={}){
return { id:uid(), type:'arrow', x1,y1,x2,y2,
stroke: opts.stroke||defaults.stroke, strokeWidth: opts.strokeWidth||defaults.strokeWidth, opacity:1 };
}
function makePen(points, opts={}){
return { id:uid(), type:'pen', points: points.map(p=>({...p})),
stroke: opts.stroke||defaults.stroke, strokeWidth: opts.strokeWidth||defaults.strokeWidth, fill:'transparent', opacity:1 };
}
function makeText(x,y,text, opts={}){
return { id:uid(), type:'text', x, y, text: text||'Text',
stroke: 'transparent', fill:'transparent', strokeWidth:1,
fontSize: opts.fontSize||20, color: opts.stroke||defaults.stroke, opacity:1 };
}
function getBounds(obj){
if(obj.type==='rect' || obj.type==='ellipse'){
return { x: Math.min(obj.x, obj.x+obj.w), y: Math.min(obj.y, obj.y+obj.h),
w: Math.abs(obj.w), h: Math.abs(obj.h) };
}
if(obj.type==='line' || obj.type==='arrow'){
return { x: Math.min(obj.x1,obj.x2), y: Math.min(obj.y1,obj.y2),
w: Math.abs(obj.x2-obj.x1)||1, h: Math.abs(obj.y2-obj.y1)||1 };
}
if(obj.type==='pen'){
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
obj.points.forEach(p=>{ minX=Math.min(minX,p.x); minY=Math.min(minY,p.y); maxX=Math.max(maxX,p.x); maxY=Math.max(maxY,p.y); });
if(!isFinite(minX)) return {x:0,y:0,w:0,h:0};
const pad = (obj.strokeWidth||2)/2 + 2;
return { x:minX-pad, y:minY-pad, w:(maxX-minX)+pad*2, h:(maxY-minY)+pad*2 };
}
if(obj.type==='text'){
// estimate
const w = obj.text.length * obj.fontSize * 0.6 + 12;
const h = obj.fontSize * 1.4 + 8;
return { x: obj.x, y: obj.y - h + 8, w, h };
}
return {x:0,y:0,w:0,h:0};
}
function getSelectionBounds(){
if(selectedIds.size===0) return null;
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
scene.forEach(o=>{
if(!selectedIds.has(o.id)) return;
const b=getBounds(o);
minX=Math.min(minX,b.x); minY=Math.min(minY,b.y);
maxX=Math.max(maxX,b.x+b.w); maxY=Math.max(maxY,b.y+b.h);
});
return { x:minX, y:minY, w:maxX-minX, h:maxY-minY };
}
/* ============================================================
HIT TESTING (world coordinates)
============================================================ */
function hitTest(worldPt){
// return topmost object id or null; tolerance in world units
const tol = CONFIG.HIT_TOLERANCE / cam.zoom;
for(let i=scene.length-1;i>=0;i--){
const o=scene[i];
if(hitTestObj(o, worldPt, tol)) return o.id;
}
return null;
}
function hitTestObj(o, p, tol){
if(o.type==='rect'){
const b=getBounds(o);
// if has fill, inside counts
const hasFill = o.fill && o.fill!=='transparent';
if(hasFill){
if(p.x>=b.x && p.x<=b.x+b.w && p.y>=b.y && p.y<=b.y+b.h) return true;
}
// stroke hit: near border
const onLeft = Math.abs(p.x - b.x) <= tol && p.y>=b.y-tol && p.y<=b.y+b.h+tol;
const onRight= Math.abs(p.x - (b.x+b.w)) <= tol && p.y>=b.y-tol && p.y<=b.y+b.h+tol;
const onTop = Math.abs(p.y - b.y) <= tol && p.x>=b.x-tol && p.x<=b.x+b.w+tol;
const onBot = Math.abs(p.y - (b.y+b.h)) <= tol && p.x>=b.x-tol && p.x<=b.x+b.w+tol;
if(!hasFill) return onLeft||onRight||onTop||onBot;
// if filled, border already inside, but we also want border tolerance outside
// check expanded bounds
if(p.x>=b.x-tol && p.x<=b.x+b.w+tol && p.y>=b.y-tol && p.y<=b.y+b.h+tol){
// if inside but not near border and hasFill, already true. If outside by tol but near border, true.
// For filled, any point within expanded border counts? No, should be inside expanded rect.
// But to keep corner outside ellipse logic separate, rect expanded is okay.
// Actually for rect, corner outside but within tol of border should still select.
// So if we are in expanded rect, check if near border or inside.
if(p.x>=b.x && p.x<=b.x+b.w && p.y>=b.y && p.y<=b.y+b.h) return true;
return onLeft||onRight||onTop||onBot;
}
return false;
}
if(o.type==='ellipse'){
const b=getBounds(o);
const cx=b.x+b.w/2, cy=b.y+b.h/2, rx=b.w/2, ry=b.h/2;
if(rx<=0||ry<=0) return false;
const dx=(p.x-cx)/rx, dy=(p.y-cy)/ry;
const d2=dx*dx+dy*dy;
const hasFill = o.fill && o.fill!=='transparent';
if(hasFill && d2<=1) return true;
// stroke: distance to ellipse perimeter
// approximate: |1 - d| * avg radius < tol
// more accurate: compute normalized distance
const d=Math.sqrt(d2);
if(d===0) return false;
// point on ellipse along ray
// distance in world units approx |d-1| * (rx*ry / sqrt((ry*cos)^2+(rx*sin)^2)) ??? simplify
// use tolerance in normalized space: |d-1| < tol / min(rx,ry) ??? but need world tol
// compute closest point approx by scaling
// simple: compute world distance: |d-1| * (rx*ry)/hypot(rx*sin, ry*cos) ??? fallback to approx
// Instead compute distance to ellipse by iterative? Use simple radial distance * avg radius
const avgR = (rx+ry)/2;
const worldDistApprox = Math.abs(d-1)*avgR;
// refine: for hit we allow tol + strokeWidth/2
const effTol = tol + (o.strokeWidth||2)/2;
if(worldDistApprox <= effTol) return true;
return false;
}
if(o.type==='line' || o.type==='arrow'){
const d = distToSegment(p, {x:o.x1,y:o.y1}, {x:o.x2,y:o.y2});
return d <= tol + (o.strokeWidth||2)/2;
}
if(o.type==='pen'){
const eff = tol + (o.strokeWidth||2)/2 + 2;
for(let i=0;i<o.points.length-1;i++){
if(distToSegment(p, o.points[i], o.points[i+1]) <= eff) return true;
}
// also check points
for(const pt of o.points) if(Math.hypot(p.x-pt.x,p.y-pt.y) <= eff) return true;
return false;
}
if(o.type==='text'){
const b=getBounds(o);
return p.x>=b.x && p.x<=b.x+b.w && p.y>=b.y && p.y<=b.y+b.h;
}
return false;
}
function distToSegment(p,a,b){
const l2=(b.x-a.x)**2+(b.y-a.y)**2;
if(l2===0) return Math.hypot(p.x-a.x,p.y-a.y);
let t=((p.x-a.x)*(b.x-a.x)+(p.y-a.y)*(b.y-a.y))/l2;
t=Math.max(0,Math.min(1,t));
const proj={x:a.x+t*(b.x-a.x), y:a.y+t*(b.y-a.y)};
return Math.hypot(p.x-proj.x,p.y-proj.y);
}
/* ============================================================
RENDERING
============================================================ */
function resizeCanvas(){
const rect=getCanvasRect();
DPR = Math.max(1, window.devicePixelRatio||1);
canvas.width = Math.round(rect.width * DPR);
canvas.height = Math.round(rect.height * DPR);
canvas.style.width = rect.width+'px';
canvas.style.height = rect.height+'px';
ctx.setTransform(DPR,0,0,DPR,0,0);
render();
}
function clearCanvas(){
ctx.save();
ctx.setTransform(DPR,0,0,DPR,0,0);
ctx.fillStyle = CONFIG.APP_BG;
ctx.fillRect(0,0,canvas.width/DPR, canvas.height/DPR);
// canvas world area
const w=canvas.width/DPR, h=canvas.height/DPR;
// draw white canvas infinite? We'll just fill viewport with white where world is, but simpler: fill viewport white then grid
// Actually draw white rect covering world viewport
ctx.fillStyle = CONFIG.BG;
// world viewport rect in screen
// Instead just fill whole with white and then overlay app bg outside? Keep simple: white background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0,0,w,h);
ctx.restore();
}
function drawGrid(){
const size = CONFIG.GRID_SIZE;
const dot = CONFIG.GRID_DOT_RADIUS;
// find world bounds visible
const tl = screenToWorld(0,0);
const br = screenToWorld(canvas.width/DPR, canvas.height/DPR);
const startX = Math.floor(tl.x/size)*size;
const endX = Math.ceil(br.x/size)*size;
const startY = Math.floor(tl.y/size)*size;
const endY = Math.ceil(br.y/size)*size;
ctx.save();
// transform to world
ctx.scale(cam.zoom, cam.zoom);
ctx.translate(-cam.x, -cam.y);
ctx.fillStyle = 'rgba(0,0,0,.10)';
const r = dot / cam.zoom; // keep dot size consistent? Actually want dot scales with zoom but keep visible
// adjust radius to be screen 1px
const sr = 1.1 / cam.zoom;
for(let x=startX; x<=endX; x+=size){
for(let y=startY; y<=endY; y+=size){
ctx.beginPath();
ctx.arc(x,y, sr, 0, Math.PI*2);
ctx.fill();
}
}
ctx.restore();
}
function renderShape(o){
ctx.save();
ctx.globalAlpha = o.opacity ?? 1;
ctx.strokeStyle = o.stroke || '#111';
ctx.fillStyle = o.fill || 'transparent';
ctx.lineWidth = (o.strokeWidth||2);
ctx.lineCap='round'; ctx.lineJoin='round';
if(o.type==='rect'){
const b=getBounds(o);
if(o.fill && o.fill!=='transparent'){ ctx.fillStyle=o.fill; ctx.fillRect(b.x,b.y,b.w,b.h); }
if(o.stroke && o.stroke!=='transparent'){ ctx.strokeStyle=o.stroke; ctx.strokeRect(b.x,b.y,b.w,b.h); }
} else if(o.type==='ellipse'){
const b=getBounds(o);
ctx.beginPath(); ctx.ellipse(b.x+b.w/2, b.y+b.h/2, b.w/2, b.h/2, 0,0,Math.PI*2);
if(o.fill && o.fill!=='transparent'){ ctx.fillStyle=o.fill; ctx.fill(); }
if(o.stroke && o.stroke!=='transparent'){ ctx.strokeStyle=o.stroke; ctx.stroke(); }
} else if(o.type==='line'){
ctx.beginPath(); ctx.moveTo(o.x1,o.y1); ctx.lineTo(o.x2,o.y2); ctx.stroke();
} else if(o.type==='arrow'){
ctx.beginPath(); ctx.moveTo(o.x1,o.y1); ctx.lineTo(o.x2,o.y2); ctx.stroke();
// arrow head
const ang=Math.atan2(o.y2-o.y1, o.x2-o.x1);
const len=12 + (o.strokeWidth||2)*1.2;
const w= Math.PI/7;
ctx.beginPath();
ctx.moveTo(o.x2, o.y2);
ctx.lineTo(o.x2 - len*Math.cos(ang - w), o.y2 - len*Math.sin(ang - w));
ctx.moveTo(o.x2, o.y2);
ctx.lineTo(o.x2 - len*Math.cos(ang + w), o.y2 - len*Math.sin(ang + w));
ctx.stroke();
} else if(o.type==='pen'){
if(o.points.length<2) { ctx.restore(); return; }
ctx.beginPath();
// smoothed polyline using quadratic
ctx.moveTo(o.points[0].x, o.points[0].y);
for(let i=1;i<o.points.length-1;i++){
const p0=o.points[i], p1=o.points[i+1];
const mx=(p0.x+p1.x)/2, my=(p0.y+p1.y)/2;
ctx.quadraticCurveTo(p0.x,p0.y,mx,my);
}
const last=o.points[o.points.length-1];
if(o.points.length>2) ctx.lineTo(last.x,last.y);
else ctx.lineTo(last.x,last.y);
ctx.strokeStyle=o.stroke; ctx.lineWidth=o.strokeWidth||2;
ctx.stroke();
} else if(o.type==='text'){
ctx.font = `${o.fontSize||20}px ui-sans-system, Inter, sans-serif`;
ctx.fillStyle = o.color || '#111';
ctx.textBaseline='top';
// background if selected? no
ctx.fillText(o.text, o.x, o.y);
}
ctx.restore();
}
function renderSelection(){
if(selectedIds.size===0) return;
const b=getSelectionBounds();
if(!b) return;
ctx.save();
ctx.scale(cam.zoom, cam.zoom);
ctx.translate(-cam.x, -cam.y);
// dashed bounds
ctx.strokeStyle='#7c5cff';
ctx.lineWidth=1 / cam.zoom;
ctx.setLineDash([6 / cam.zoom, 4 / cam.zoom]);
ctx.strokeRect(b.x, b.y, b.w, b.h);
ctx.setLineDash([]);
// handles
const hs = CONFIG.HANDLE_SIZE / cam.zoom;
const half = hs/2;
const corners=[
{x:b.x, y:b.y, id:'nw'}, {x:b.x+b.w, y:b.y, id:'ne'},
{x:b.x, y:b.y+b.h, id:'sw'}, {x:b.x+b.w, y:b.y+b.h, id:'se'}
];
corners.forEach(c=>{
ctx.fillStyle='#ffffff';
ctx.strokeStyle='#7c5cff';
ctx.lineWidth=1.2 / cam.zoom;
ctx.fillRect(c.x-half, c.y-half, hs, hs);
ctx.strokeRect(c.x-half, c.y-half, hs, hs);
});
ctx.restore();
}
function renderRubber(){
if(!rubberRect) return;
ctx.save();
ctx.scale(cam.zoom, cam.zoom);
ctx.translate(-cam.x, -cam.y);
ctx.fillStyle='rgba(124,92,255,.08)';
ctx.strokeStyle='rgba(124,92,255,.9)';
ctx.lineWidth=1 / cam.zoom;
ctx.fillRect(rubberRect.x, rubberRect.y, rubberRect.w, rubberRect.h);
ctx.strokeRect(rubberRect.x, rubberRect.y, rubberRect.w, rubberRect.h);
ctx.restore();
}
function renderPreview(){
if(!currentShape) return;
ctx.save();
ctx.scale(cam.zoom, cam.zoom);
ctx.translate(-cam.x, -cam.y);
ctx.globalAlpha=0.9;
// dashed preview
ctx.setLineDash([6,4]);
renderShape(currentShape);
ctx.restore();
}
function renderPenPreview(){
if(tool!=='pen' || penPoints.length<2) return;
ctx.save();
ctx.scale(cam.zoom, cam.zoom);
ctx.translate(-cam.x, -cam.y);
ctx.strokeStyle=defaults.stroke;
ctx.lineWidth=defaults.strokeWidth;
ctx.lineCap='round'; ctx.lineJoin='round';
ctx.beginPath();
ctx.moveTo(penPoints[0].x, penPoints[0].y);
for(let i=1;i<penPoints.length-1;i++){
const p0=penPoints[i], p1=penPoints[i+1];
const mx=(p0.x+p1.x)/2, my=(p0.y+p1.y)/2;
ctx.quadraticCurveTo(p0.x,p0.y,mx,my);
}
if(penPoints.length>1){
const last=penPoints[penPoints.length-1];
ctx.lineTo(last.x,last.y);
}
ctx.stroke();
ctx.restore();
}
function render(){
clearCanvas();
drawGrid();
ctx.save();
ctx.scale(cam.zoom, cam.zoom);
ctx.translate(-cam.x, -cam.y);
scene.forEach(o=> renderShape(o));
ctx.restore();
renderPenPreview();
renderPreview();
renderRubber();
renderSelection();
// update UI
document.getElementById('zoomLabel').textContent = Math.round(cam.zoom*100)+'%';
document.getElementById('objCount').textContent = scene.length + ' objects';
// props bar active state
const bar=document.getElementById('propsBar');
if(selectedIds.size>0) bar.classList.remove('hidden'); else bar.classList.remove('hidden'); // always show but reflect selection
updateSwatchUI();
// toolbar active
document.querySelectorAll('.tool-btn[data-tool]').forEach(b=>{
b.classList.toggle('active', b.dataset.tool===tool);
});
}
/* ============================================================
HISTORY & PERSISTENCE
============================================================ */
function pushHistory(){
// truncate redo
history = history.slice(0, historyIndex+1);
history.push(deepClone(scene));
historyIndex = history.length-1;
if(history.length>120){ history.shift(); historyIndex--; }
saveToStorage();
updateUndoButtons();
}
function undo(){
if(historyIndex<=0) { showToast('Nothing to undo'); return; }
historyIndex--;
scene = deepClone(history[historyIndex]);
selectedIds.clear();
saveToStorage();
render();
updateUndoButtons();
showToast('Undone');
}
function redo(){
if(historyIndex>=history.length-1){ showToast('Nothing to redo'); return; }
historyIndex++;
scene = deepClone(history[historyIndex]);
selectedIds.clear();
saveToStorage();
render();
updateUndoButtons();
showToast('Redone');
}
function updateUndoButtons(){
document.getElementById('undoBtn').style.opacity = historyIndex>0? '1':'.4';
document.getElementById('redoBtn').style.opacity = historyIndex<history.length-1? '1':'.4';
}
function saveToStorage(){
try{
localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify({scene, cam}));
}catch(e){}
}
function loadFromStorage(){
try{
const raw=localStorage.getItem(CONFIG.STORAGE_KEY);
if(!raw) return false;
const data=JSON.parse(raw);
if(Array.isArray(data.scene)){ scene=data.scene; }
else if(Array.isArray(data)){ scene=data; }
if(data.cam){ cam = {...cam, ...data.cam}; cam.zoom=clamp(cam.zoom, CONFIG.ZOOM_MIN, CONFIG.ZOOM_MAX); }
// init history
history=[deepClone(scene)];
historyIndex=0;
return true;
}catch(e){ return false; }
}
/* ============================================================
PROPERTIES BAR
============================================================ */
function buildSwatches(){
const sCont=document.getElementById('strokeSwatches');
const fCont=document.getElementById('fillSwatches');
sCont.innerHTML=''; fCont.innerHTML='';
CONFIG.STROKE_PALETTE.forEach(c=>{
const d=document.createElement('div');
d.className='swatch'; d.style.background=c; d.dataset.color=c; d.dataset.kind='stroke';
d.title=c; sCont.appendChild(d);
});
CONFIG.FILL_PALETTE.forEach(c=>{
const d=document.createElement('div');
d.className='swatch'; if(c==='transparent') d.classList.add('transparent'); else d.style.background=c;
d.dataset.color=c; d.dataset.kind='fill'; d.title=c;
fCont.appendChild(d);
});
sCont.addEventListener('click', e=>{
const sw=e.target.closest('.swatch'); if(!sw) return;
const col=sw.dataset.color;
defaults.stroke=col;
if(selectedIds.size>0){
// apply to selection as one history step
const before=deepClone(scene);
scene.forEach(o=>{ if(selectedIds.has(o.id)){
if(o.type==='text') o.color=col; else o.stroke=col;
}});
if(JSON.stringify(before)!==JSON.stringify(scene)) pushHistory();
render();
} else updateSwatchUI();
});
fCont.addEventListener('click', e=>{
const sw=e.target.closest('.swatch'); if(!sw) return;
const col=sw.dataset.color;
defaults.fill=col;
if(selectedIds.size>0){
const before=deepClone(scene);
scene.forEach(o=>{ if(selectedIds.has(o.id) && (o.type==='rect'||o.type==='ellipse')){
o.fill=col;
}});
if(JSON.stringify(before)!==JSON.stringify(scene)) pushHistory();
render();
} else updateSwatchUI();
});
document.getElementById('widthRange').addEventListener('input', e=>{
const v=parseInt(e.target.value,10);
defaults.strokeWidth=v;
document.getElementById('widthVal').textContent=v;
if(selectedIds.size>0){
scene.forEach(o=>{ if(selectedIds.has(o.id) && o.type!=='text') o.strokeWidth=v; });
render();
}
});
document.getElementById('widthRange').addEventListener('change', ()=>{
if(selectedIds.size>0) pushHistory();
});
}
function updateSwatchUI(){
const strokeSw=document.querySelectorAll('#strokeSwatches .swatch');
const fillSw=document.querySelectorAll('#fillSwatches .swatch');
// determine current stroke/fill from selection or defaults
let curStroke=defaults.stroke, curFill=defaults.fill, curW=defaults.strokeWidth;
if(selectedIds.size>0){
const sel=[...scene].filter(o=>selectedIds.has(o.id));
if(sel.length===1){ curStroke = sel[0].type==='text'? sel[0].color : sel[0].stroke; curFill = sel[0].fill||'transparent'; curW=sel[0].strokeWidth||defaults.strokeWidth; }
else {
// mixed: keep defaults display but highlight if all same
const allStroke = sel.every(o=> (o.type==='text'?o.color:o.stroke)=== (sel[0].type==='text'?sel[0].color:sel[0].stroke));
if(allStroke) curStroke = sel[0].type==='text'?sel[0].color:sel[0].stroke;
}
}
strokeSw.forEach(s=> s.classList.toggle('active', s.dataset.color===curStroke));
fillSw.forEach(s=> s.classList.toggle('active', s.dataset.color===curFill));
document.getElementById('widthRange').value=curW;
document.getElementById('widthVal').textContent=curW;
}
/* ============================================================
VIEWPORT CONTROLS
============================================================ */
function setZoom(newZoom, centerScreen){
const oldZoom=cam.zoom;
newZoom=clamp(newZoom, CONFIG.ZOOM_MIN, CONFIG.ZOOM_MAX);
if(newZoom===oldZoom) return;
// keep world point under cursor stable
const worldBefore = screenToWorld(centerScreen.x, centerScreen.y);
cam.zoom=newZoom;
const worldAfter = screenToWorld(centerScreen.x, centerScreen.y);
// Actually we want worldBefore to stay under same screen point: cam.x = worldBefore.x - centerScreen.x/newZoom
cam.x = worldBefore.x - centerScreen.x / newZoom;
cam.y = worldBefore.y - centerScreen.y / newZoom;
render();
saveToStorage();
}
function resetView(){
cam.x = - (canvas.width/DPR)/2 / cam.zoom + 0; // center around 0,0
// Better: fit content or center
if(scene.length>0){
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
scene.forEach(o=>{ const b=getBounds(o); minX=Math.min(minX,b.x); minY=Math.min(minY,b.y); maxX=Math.max(maxX,b.x+b.w); maxY=Math.max(maxY,b.y+b.h); });
const cx=(minX+maxX)/2, cy=(minY+maxY)/2;
const vw=canvas.width/DPR, vh=canvas.height/DPR;
cam.x = cx - vw/(2*cam.zoom);
cam.y = cy - vh/(2*cam.zoom);
} else {
cam.x= -200; cam.y=-200;
}
cam.zoom=1;
render(); saveToStorage();
}
/* ============================================================
SELECTION HELPERS
============================================================ */
function getHandleAt(screenPt){
if(selectedIds.size===0) return null;
const b=getSelectionBounds(); if(!b) return null;
const corners={
nw: worldToScreen(b.x, b.y),
ne: worldToScreen(b.x+b.w, b.y),
sw: worldToScreen(b.x, b.y+b.h),
se: worldToScreen(b.x+b.w, b.y+b.h)
};
const hit=CONFIG.HANDLE_HIT;
for(const [k,pt] of Object.entries(corners)){
if(Math.abs(pt.x - screenPt.x) <= hit && Math.abs(pt.y - screenPt.y) <= hit) return k;
}
return null;
}
function nudgeSelection(dx,dy){
if(selectedIds.size===0) return;
// capture before for undo
const before=deepClone(scene);
scene.forEach(o=>{
if(!selectedIds.has(o.id)) return;
if(o.type==='rect'||o.type==='ellipse'||o.type==='text'){ o.x+=dx; o.y+=dy; }
else if(o.type==='line'||o.type==='arrow'){ o.x1+=dx; o.y1+=dy; o.x2+=dx; o.y2+=dy; }
else if(o.type==='pen'){ o.points.forEach(p=>{p.x+=dx; p.y+=dy;}); }
});
pushHistory();
render(); saveToStorage();
}
function deleteSelection(){
if(selectedIds.size===0) return;
scene = scene.filter(o=> !selectedIds.has(o.id));
selectedIds.clear();
pushHistory();
render();
showToast('Deleted');
}
function selectAll(){
selectedIds = new Set(scene.map(o=>o.id));
render();
}
/* ============================================================
TEXT TOOL
============================================================ */
const textOverlay=document.getElementById('textOverlay');
const textInput=document.getElementById('textInput');
let editingTextId=null;
let textWorldPos=null;
function openTextEditor(worldPos, existingObj=null){
textWorldPos=worldPos;
editingTextId = existingObj? existingObj.id : null;
const sp = worldToScreen(worldPos.x, worldPos.y);
textOverlay.style.left = (sp.x+8)+'px';
textOverlay.style.top = (sp.y+8)+'px';
textOverlay.style.display='block';
textInput.value = existingObj? existingObj.text : '';
textInput.focus();
textInput.select();
}
function commitText(){
const txt=textInput.value.trim();
textOverlay.style.display='none';
if(!txt){ editingTextId=null; textWorldPos=null; return; }
if(editingTextId){
const obj=scene.find(o=>o.id===editingTextId);
if(obj){ obj.text=txt; pushHistory(); render(); saveToStorage(); }
} else {
const obj=makeText(textWorldPos.x, textWorldPos.y, txt, {stroke: defaults.stroke});
scene.push(obj);
selectedIds.clear(); selectedIds.add(obj.id);
pushHistory(); render(); saveToStorage();
}
editingTextId=null; textWorldPos=null;
}
function cancelText(){
textOverlay.style.display='none';
editingTextId=null; textWorldPos=null;
}
textInput.addEventListener('keydown', e=>{
if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); commitText(); }
if(e.key==='Escape'){ e.preventDefault(); cancelText(); }
});
textInput.addEventListener('blur', ()=>{ if(textOverlay.style.display==='block') commitText(); });
/* ============================================================
MOUSE / POINTER HANDLERS
============================================================ */
function onPointerDown(e){
const screen = eventToScreen(e);
mousePos={...screen};
const world = screenToWorld(screen.x, screen.y);
const isMiddle = e.button===1;
const isSpacePan = spaceHeld;
// panning
if(isMiddle || isSpacePan){
isPanning=true;
dragStart={sx:screen.x, sy:screen.y, camX:cam.x, camY:cam.y};
canvas.setPointerCapture(e.pointerId);
e.preventDefault();
return;
}
if(tool==='select'){
const handle=getHandleAt(screen);
if(handle && selectedIds.size>0){
isResizing=true;
resizeHandle=handle;
const b=getSelectionBounds();
resizeStart={ box:{...b}, mouseWorld:{...world} };
// snapshot positions of all selected objects relative to bounds
moveStartPositions.clear();
scene.forEach(o=>{
if(selectedIds.has(o.id)){
if(o.type==='rect'||o.type==='ellipse'||o.type==='text') moveStartPositions.set(o.id, {x:o.x,y:o.y,w:o.w,h:o.h, fontSize:o.fontSize});
else if(o.type==='line'||o.type==='arrow') moveStartPositions.set(o.id, {x1:o.x1,y1:o.y1,x2:o.x2,y2:o.y2});
else if(o.type==='pen') moveStartPositions.set(o.id, {points: deepClone(o.points)});
}
});
canvas.setPointerCapture(e.pointerId);
return;
}
const hitId=hitTest(world);
if(hitId){
const isShift=e.shiftKey;
if(isShift){
if(selectedIds.has(hitId)) selectedIds.delete(hitId); else selectedIds.add(hitId);
} else {
if(!selectedIds.has(hitId)){ selectedIds.clear(); selectedIds.add(hitId); }
}
// start moving
isDragging=true;
dragStart={sx:screen.x, sy:screen.y, wx:world.x, wy:world.y};
moveStartPositions.clear();
scene.forEach(o=>{
if(selectedIds.has(o.id)){
if(o.type==='rect'||o.type==='ellipse'||o.type==='text') moveStartPositions.set(o.id, {x:o.x,y:o.y});
else if(o.type==='line'||o.type==='arrow') moveStartPositions.set(o.id, {x1:o.x1,y1:o.y1,x2:o.x2,y2:o.y2});
else if(o.type==='pen') moveStartPositions.set(o.id, {points: deepClone(o.points)});
}
});
render();
canvas.setPointerCapture(e.pointerId);
return;
} else {
// empty space: rubber band or deselect
if(!e.shiftKey) selectedIds.clear();
isRubber=true;
rubberRect={x:world.x, y:world.y, w:0, h:0};
dragStart={sx:screen.x, sy:screen.y, wx:world.x, wy:world.y};
render();
canvas.setPointerCapture(e.pointerId);
return;
}
}
if(tool==='pen'){
isDrawing=true;
penPoints=[world];
canvas.setPointerCapture(e.pointerId);
return;
}
if(tool==='rect' || tool==='ellipse' || tool==='arrow'){
isDrawing=true;
dragStart={sx:screen.x, sy:screen.y, wx:world.x, wy:world.y};
if(tool==='rect') currentShape=makeRect(world.x, world.y, 0,0);
else if(tool==='ellipse') currentShape=makeEllipse(world.x, world.y, 0,0);
else if(tool==='arrow') currentShape=makeArrow(world.x, world.y, world.x, world.y);
canvas.setPointerCapture(e.pointerId);
return;
}
if(tool==='text'){
// check if clicking existing text to edit
const hitId=hitTest(world);
if(hitId){
const obj=scene.find(o=>o.id===hitId);
if(obj && obj.type==='text'){
openTextEditor({x:obj.x, y:obj.y}, obj);
return;
}
}
openTextEditor(world, null);
return;
}
if(tool==='eraser'){
isDrawing=true;
eraserPath=[world];
// immediate erase at point
eraseAt(world);
canvas.setPointerCapture(e.pointerId);
return;
}
}
function onPointerMove(e){
const screen=eventToScreen(e);
mousePos={...screen};
const world=screenToWorld(screen.x, screen.y);
if(isPanning && dragStart){
const dx=screen.x - dragStart.sx;
const dy=screen.y - dragStart.sy;
cam.x = dragStart.camX - dx / cam.zoom;
cam.y = dragStart.camY - dy / cam.zoom;
render();
return;
}
if(isResizing && resizeStart){
const b0=resizeStart.box;
const shift=e.shiftKey;
// compute new bounds based on handle
let nx=b0.x, ny=b0.y, nw=b0.w, nh=b0.h;
const mx=world.x, my=world.y;
if(resizeHandle==='nw'){ nx=mx; ny=my; nw=(b0.x+b0.w)-mx; nh=(b0.y+b0.h)-my; }
if(resizeHandle==='ne'){ ny=my; nw=mx - b0.x; nh=(b0.y+b0.h)-my; }
if(resizeHandle==='sw'){ nx=mx; nw=(b0.x+b0.w)-mx; nh=my - b0.y; }
if(resizeHandle==='se'){ nw=mx - b0.x; nh=my - b0.y; }
// handle negative flips? allow flipping by normalizing
// preserve aspect with shift
if(shift){
const aspect = b0.w / (b0.h||1);
// adjust to keep aspect: use larger delta
if(Math.abs(nw) / Math.abs(nh) > aspect){
// width dominates
const signH = nh<0?-1:1;
nh = Math.abs(nw)/aspect * signH;
if(resizeHandle==='nw' || resizeHandle==='sw') ny = (b0.y+b0.h) - nh;
} else {
const signW = nw<0?-1:1;
nw = Math.abs(nh)*aspect * signW;
if(resizeHandle==='nw' || resizeHandle==='ne') nx = (b0.x+b0.w) - nw;
}
}
// minimum size
if(Math.abs(nw)<6) nw = 6*Math.sign(nw||1);
if(Math.abs(nh)<6) nh = 6*Math.sign(nh||1);
// normalize to positive w/h for storage but keep position
let finalX=nx, finalY=ny, finalW=nw, finalH=nh;
if(finalW<0){ finalX+=finalW; finalW=Math.abs(finalW); }
if(finalH<0){ finalY+=finalH; finalH=Math.abs(finalH); }
// apply to each selected object proportionally
const sx = finalW / b0.w;
const sy = finalH / b0.h;
scene.forEach(o=>{
if(!selectedIds.has(o.id)) return;
const start=moveStartPositions.get(o.id);
if(!start) return;
if(o.type==='rect' || o.type==='ellipse' || o.type==='text'){
// map object's bounds relative to selection
// compute object's original bounds
const ob = (()=> {
if(o.type==='text'){
// use start x,y
return {x:start.x, y:start.y - (start.fontSize*1.4+8)+8, w: (o.text.length*start.fontSize*0.6+12), h:start.fontSize*1.4+8 };
}
return {x:start.x, y:start.y, w:start.w, h:start.h};
})();
const relX = (ob.x - b0.x)/b0.w;
const relY = (ob.y - b0.y)/b0.h;
const relW = ob.w / b0.w;
const relH = ob.h / b0.h;
const newX = finalX + relX*finalW;
const newY = finalY + relY*finalH;
const newW = relW*finalW;
const newH = relH*finalH;
o.x=newX; o.y=newY;
if(o.type!=='text'){ o.w=newW; o.h=newH; }
else {
// scale font size proportionally (average)
const sc=(sx+sy)/2;
o.fontSize = Math.max(8, Math.round(start.fontSize*sc));
// keep x,y as newX, newY adjusted for text baseline
o.x=newX; o.y=newY + o.fontSize*1.4 -2;
}
} else if(o.type==='line' || o.type==='arrow'){
const mapPt=(px,py)=>{
const rx=(px - b0.x)/b0.w, ry=(py - b0.y)/b0.h;
return {x: finalX + rx*finalW, y: finalY + ry*finalH};
};
const p1=mapPt(start.x1,start.y1), p2=mapPt(start.x2,start.y2);
o.x1=p1.x; o.y1=p1.y; o.x2=p2.x; o.y2=p2.y;
} else if(o.type==='pen'){
o.points = start.points.map(pt=>{
const rx=(pt.x - b0.x)/b0.w, ry=(pt.y - b0.y)/b0.h;
return {x: finalX + rx*finalW, y: finalY + ry*finalH};
});
}
});
render();
return;
}
if(isDragging && dragStart){
const dx=world.x - dragStart.wx;
const dy=world.y - dragStart.wy;
scene.forEach(o=>{
if(!selectedIds.has(o.id)) return;
const s=moveStartPositions.get(o.id);
if(!s) return;
if(o.type==='rect'||o.type==='ellipse'||o.type==='text'){ o.x=s.x+dx; o.y=s.y+dy; }
else if(o.type==='line'||o.type==='arrow'){ o.x1=s.x1+dx; o.y1=s.y1+dy; o.x2=s.x2+dx; o.y2=s.y2+dy; }
else if(o.type==='pen'){ o.points = s.points.map(p=>({x:p.x+dx, y:p.y+dy})); }
});
render();
return;
}
if(isRubber && dragStart){
const x0=dragStart.wx, y0=dragStart.wy;
rubberRect={ x: Math.min(x0, world.x), y: Math.min(y0, world.y), w: Math.abs(world.x - x0), h: Math.abs(world.y - y0) };
// live selection: objects intersecting rubber
const rr=rubberRect;
const newSel=new Set(selectedIds); // keep existing if shift? Actually we cleared earlier unless shift
// For rubber, select those intersecting
scene.forEach(o=>{
const b=getBounds(o);
const intersect = !(b.x+b.w < rr.x || b.x > rr.x+rr.w || b.y+b.h < rr.y || b.y > rr.y+rr.h);
if(intersect) newSel.add(o.id);
});
// If not shift, we want only those in rubber plus previously shift-selected? Simpler: if shift, add; else replace with rubber hits
// To make undo-friendly, we just update visual selection during drag
// We'll compute from scratch: if shift was held at start, keep original plus rubber; else only rubber
// For simplicity, track initial selected before rubber
// We'll just set to rubber hits (plus shift)
// Let's store initial selection at dragStart
if(!dragStart.initialSel) dragStart.initialSel=new Set(selectedIds);
// Actually we cleared at mousedown if not shift, so initial is empty
// Recompute:
const base = dragStart.shift? dragStart.initialSel : new Set();
const combined=new Set(base);
scene.forEach(o=>{
const b=getBounds(o);
const inter = !(b.x+b.w < rr.x || b.x > rr.x+rr.w || b.y+b.h < rr.y || b.y > rr.y+rr.h);
if(inter) combined.add(o.id);
});
selectedIds=combined;
render();
return;
}
if(isDrawing){
if(tool==='pen'){
const last=penPoints[penPoints.length-1];
if(Math.hypot(world.x-last.x, world.y-last.y) >= CONFIG.PEN_MIN_DIST){
penPoints.push(world);
render();
}
return;
}
if(tool==='rect' || tool==='ellipse'){
const x0=dragStart.wx, y0=dragStart.wy;
const w=world.x - x0, h=world.y - y0;
// allow shift to force square
let fw=w, fh=h;
if(e.shiftKey){
const s=Math.max(Math.abs(w), Math.abs(h));
fw = Math.sign(w)*s; fh=Math.sign(h)*s;
}
if(tool==='rect'){
currentShape.x = fw<0? x0+fw : x0;
currentShape.y = fh<0? y0+fh : y0;
currentShape.w = Math.abs(fw); currentShape.h=Math.abs(fh);
} else {
currentShape.x = fw<0? x0+fw : x0;
currentShape.y = fh<0? y0+fh : y0;
currentShape.w = Math.abs(fw); currentShape.h=Math.abs(fh);
}
render();
return;
}
if(tool==='arrow'){
currentShape.x2=world.x; currentShape.y2=world.y;
render();
return;
}
if(tool==='eraser'){
eraserPath.push(world);
eraseAt(world);
render();
return;
}
}
// hover cursor
updateCursor(screen);
}
function onPointerUp(e){
const screen=eventToScreen(e);
const world=screenToWorld(screen.x, screen.y);
if(isPanning){ isPanning=false; dragStart=null; saveToStorage(); return; }
if(isResizing){
isResizing=false; resizeHandle=null; resizeStart=null;
moveStartPositions.clear();
pushHistory();
render();
return;
}
if(isDragging){
isDragging=false;
// if actually moved, push history
let moved=false;
scene.forEach(o=>{
if(!selectedIds.has(o.id)) return;
const s=moveStartPositions.get(o.id);
if(!s) return;
if(o.type==='rect'||o.type==='ellipse'||o.type==='text'){ if(o.x!==s.x||o.y!==s.y) moved=true; }
else if(o.type==='line'||o.type==='arrow'){ if(o.x1!==s.x1||o.y1!==s.y1) moved=true; }
else if(o.type==='pen'){ if(o.points[0].x!==s.points[0].x || o.points[0].y!==s.points[0].y) moved=true; }
});
moveStartPositions.clear();
dragStart=null;
if(moved) pushHistory();
render();
return;
}
if(isRubber){
isRubber=false;
rubberRect=null;
dragStart=null;
render();
return;
}
if(isDrawing){
isDrawing=false;
if(tool==='pen'){
if(penPoints.length>1){
const obj=makePen(penPoints);
scene.push(obj);
pushHistory();
}
penPoints=[];
render();
} else if(tool==='rect' || tool==='ellipse' || tool==='arrow'){
if(currentShape){
// ignore tiny shapes
let valid=true;
if(tool==='rect' || tool==='ellipse'){
if(currentShape.w<4 || currentShape.h<4) valid=false;
} else {
if(Math.hypot(currentShape.x2-currentShape.x1, currentShape.y2-currentShape.y1)<6) valid=false;
}
if(valid){
scene.push(currentShape);
selectedIds.clear(); selectedIds.add(currentShape.id);
pushHistory();
}
}
currentShape=null;
dragStart=null;
render();
} else if(tool==='eraser'){
// eraser already deleted objects live; push history if changed
// we pushed incrementally? Instead we should have pushed once at end if any deleted
// To make undo single step, we need to track before
// Our eraseAt deletes immediately without history; we need to push once
// We'll handle by checking if scene length changed vs history top
const lastHist = history[historyIndex];
if(lastHist && lastHist.length !== scene.length){
pushHistory();
} else if(scene.length>0){
// if no history yet
if(history.length===0) pushHistory();
}
eraserPath=[];
dragStart=null;
render();
}
return;
}
}
function eraseAt(worldPt){
const tol = 10 / cam.zoom;
// find objects hit
const toDelete=[];
scene.forEach(o=>{
if(hitTestObj(o, worldPt, tol)) toDelete.push(o.id);
});
if(toDelete.length>0){
// delete them
scene = scene.filter(o=> !toDelete.includes(o.id));
toDelete.forEach(id=> selectedIds.delete(id));
}
}
function updateCursor(screen){
if(tool==='select'){
const h=getHandleAt(screen);
if(h){
const cursors={nw:'nwse-resize', ne:'nesw-resize', sw:'nesw-resize', se:'nwse-resize'};
canvas.style.cursor=cursors[h];
return;
}
const world=screenToWorld(screen.x, screen.y);
const hit=hitTest(world);
canvas.style.cursor = hit? 'move' : 'default';
} else if(tool==='pen') canvas.style.cursor='crosshair';
else if(tool==='eraser') canvas.style.cursor='cell';
else canvas.style.cursor='crosshair';
}
/* ============================================================
KEYBOARD
============================================================ */
window.addEventListener('keydown', e=>{
const isMod = e.ctrlKey || e.metaKey;
if(e.code==='Space' && !spaceHeld && !e.repeat){
spaceHeld=true;
canvas.style.cursor='grab';
e.preventDefault();
}
if(isMod && e.key.toLowerCase()==='z'){
e.preventDefault();
if(e.shiftKey) redo(); else undo();
return;
}
if(isMod && e.key.toLowerCase()==='y'){ e.preventDefault(); redo(); return; }
if(e.key==='Delete' || e.key==='Backspace'){
// don't delete if editing text
if(textOverlay.style.display==='block') return;
if(selectedIds.size>0){ e.preventDefault(); deleteSelection(); }
return;
}
if(e.key==='Escape'){
if(textOverlay.style.display==='block'){ cancelText(); return; }
selectedIds.clear(); currentShape=null; penPoints=[]; isDrawing=false; isDragging=false; isRubber=false; rubberRect=null;
render();
return;
}
// tool shortcuts
if(!isMod && textOverlay.style.display!=='block'){
const k=e.key.toLowerCase();
if(k==='v') { tool='select'; render(); }
else if(k==='p') { tool='pen'; render(); }
else if(k==='r') { tool='rect'; render(); }
else if(k==='o') { tool='ellipse'; render(); }
else if(k==='l') { tool='arrow'; render(); }
else if(k==='t') { tool='text'; render(); }
else if(k==='e') { tool='eraser'; render(); }
else if(k==='a' && isMod){ /* select all handled below */ }
}
if(isMod && e.key.toLowerCase()==='a'){
e.preventDefault(); selectAll();
}
// arrow nudge
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key) && selectedIds.size>0 && textOverlay.style.display!=='block'){
e.preventDefault();
const step = e.shiftKey? 10 : 1;
let dx=0, dy=0;
if(e.key==='ArrowUp') dy=-step;
if(e.key==='ArrowDown') dy=step;
if(e.key==='ArrowLeft') dx=-step;
if(e.key==='ArrowRight') dx=step;
nudgeSelection(dx,dy);
}
});
window.addEventListener('keyup', e=>{
if(e.code==='Space'){ spaceHeld=false; canvas.style.cursor='default'; }
});
/* ============================================================
WHEEL ZOOM
============================================================ */
canvas.addEventListener('wheel', e=>{
e.preventDefault();
const screen=eventToScreen(e);
const delta = -e.deltaY;
const factor = delta>0 ? (1+CONFIG.ZOOM_STEP) : (1/(1+CONFIG.ZOOM_STEP));
const newZoom = clamp(cam.zoom * factor, CONFIG.ZOOM_MIN, CONFIG.ZOOM_MAX);
setZoom(newZoom, screen);
},{passive:false});
/* ============================================================
TOOLBAR EVENTS
============================================================ */
document.querySelectorAll('.tool-btn[data-tool]').forEach(btn=>{
btn.addEventListener('click', ()=>{
tool=btn.dataset.tool;
// cancel any in-progress
isDrawing=false; penPoints=[]; currentShape=null; rubberRect=null;
render();
});
});
document.getElementById('undoBtn').addEventListener('click', undo);
document.getElementById('redoBtn').addEventListener('click', redo);
document.getElementById('clearBtn').addEventListener('click', ()=>{
if(scene.length===0) return;
if(confirm('Clear entire board?')){
scene=[]; selectedIds.clear(); pushHistory(); render();
}
});
document.getElementById('zoomIn').addEventListener('click', ()=>{
const rect=getCanvasRect();
setZoom(cam.zoom*(1+CONFIG.ZOOM_STEP), {x:rect.width/2, y:rect.height/2});
});
document.getElementById('zoomOut').addEventListener('click', ()=>{
const rect=getCanvasRect();
setZoom(cam.zoom/(1+CONFIG.ZOOM_STEP), {x:rect.width/2, y:rect.height/2});
});
document.getElementById('resetView').addEventListener('click', resetView);
/* ============================================================
CANVAS POINTER EVENTS
============================================================ */
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerUp);
// prevent context menu
canvas.addEventListener('contextmenu', e=> e.preventDefault());
/* ============================================================
INIT
============================================================ */
buildSwatches();
if(!loadFromStorage()){
// demo content
scene.push(makeRect(120,140,220,140, {stroke:'#1a1a1e', fill:'#fef3c7', strokeWidth:2}));
scene.push(makeEllipse(420,160,180,120, {stroke:'#7c5cff', fill:'#e9d5ff', strokeWidth:2}));
scene.push(makeArrow(200,340,520,340, {stroke:'#1a1a1e', strokeWidth:2}));
scene.push(makeText(140, 120, 'Welcome to Board', {stroke:'#1a1a1e'}));
scene.push(makePen([{x:520,y:120},{x:560,y:140},{x:600,y:120},{x:640,y:160}], {stroke:'#ef4444', strokeWidth:3}));
history=[deepClone(scene)]; historyIndex=0;
cam.x=-80; cam.y=-60; cam.zoom=1;
saveToStorage();
}
resizeCanvas();
render();
updateUndoButtons();
window.addEventListener('resize', resizeCanvas);
// expose for probe tests
window.__board = {
get scene(){ return scene; },
get cam(){ return cam; },
get selected(){ return [...selectedIds]; },
get tool(){ return tool; },
set tool(v){ tool=v; render(); },
screenToWorld, worldToScreen, hitTest, getBounds, CONFIG
};
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2-contributor","provider":"openrouter","persona":"full","sessionId":"5adb59a4-bb78-4ae1-88af-3b06bc79a125","tokensIn":142327,"tokensOut":19105,"tokensTotal":161432,"cost":0.009769466,"turns":6,"toolCalls":5,"failedToolCalls":0,"timestamp":"2026-08-23T01:11:23.869Z"} -->