Whiteboard
GPT 5.6 terra · typescript

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 18,109 bytes · SHA-256 e85a56fc84ed
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Paperplane — Collaborative Whiteboard</title>
<style>
/* ── css: ui ── */
*{box-sizing:border-box}html,body,#app,#board-shell{margin:0;width:100%;height:100%;overflow:hidden;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2937}#board-shell{background:#eef2f7;position:relative}#canvas{display:block;width:100%;height:100%;background:#fbfcfe;cursor:crosshair}#topbar{position:absolute;top:16px;left:50%;transform:translateX(-50%);height:48px;background:rgba(255,255,255,.94);border:1px solid #dde3eb;box-shadow:0 8px 24px rgba(31,41,55,.12);border-radius:14px;display:flex;align-items:center;padding:5px 7px;gap:14px;user-select:none;z-index:2}.brand{display:flex;align-items:center;gap:7px;font-size:14px;font-weight:750;letter-spacing:-.02em;padding:0 8px;border-right:1px solid #e5e9ef}.brand-mark{color:#635bff;font-size:19px}.status{font-size:10px;font-weight:600;color:#8993a4;background:#f2f5f8;border-radius:5px;padding:3px 5px;margin-left:2px}#toolbar{display:flex;align-items:center;gap:2px}button{border:0;background:transparent;color:#475569;cursor:pointer;font:inherit}#toolbar button,.actions button{height:36px;min-width:36px;border-radius:9px;font-size:17px;display:flex;align-items:center;justify-content:center;gap:4px}#toolbar button:hover,.actions button:hover{background:#f0f3f7}#toolbar button.active{background:#e8e8ff;color:#4f46e5;box-shadow:inset 0 0 0 1px #d4d2ff}.tool-key{font-size:9px;font-weight:700;margin-left:2px;color:#94a3b8}.actions{display:flex;align-items:center;gap:2px;padding-left:8px;border-left:1px solid #e5e9ef}.actions button{font-size:18px}.actions #reset{font-size:12px;padding:0 8px;min-width:72px}#stylebar{position:absolute;left:18px;top:82px;background:rgba(255,255,255,.95);border:1px solid #dde3eb;border-radius:12px;box-shadow:0 7px 18px rgba(31,41,55,.09);padding:8px;display:flex;flex-direction:column;gap:8px;z-index:2}#stylebar label{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:11px;font-weight:700;color:#64748b}input[type=color]{width:26px;height:21px;padding:1px;border:1px solid #dbe1e9;border-radius:5px;background:#fff;cursor:pointer}select{font:600 11px inherit;color:#475569;border:1px solid #dbe1e9;border-radius:5px;padding:3px;background:#fff}#hint{position:absolute;bottom:16px;left:50%;transform:translateX(-50%);color:#64748b;background:rgba(255,255,255,.85);border:1px solid #e2e7ed;border-radius:99px;padding:7px 12px;font-size:11px;box-shadow:0 3px 12px rgba(31,41,55,.06);pointer-events:none}kbd{font:700 10px ui-monospace,SFMono-Regular,monospace;background:#eef1f5;border:1px solid #dbe0e7;border-radius:4px;padding:1px 4px;color:#475569}textarea.board-text{position:absolute;z-index:5;resize:none;min-width:90px;min-height:30px;border:1.5px solid #6366f1;outline:none;border-radius:4px;padding:5px;background:#fff;color:#1f2937;font:16px/1.35 Inter,ui-sans-serif,sans-serif;box-shadow:0 5px 16px rgba(79,70,229,.18)}@media(max-width:760px){.brand .status{display:none}.brand{padding:0 3px}#topbar{gap:5px}.tool-key{display:none}#stylebar{top:auto;bottom:58px;left:10px;flex-direction:row;align-items:center}#stylebar label{gap:4px;font-size:0}#hint{display:none}}
</style>
</head>
<body>
<div id="board-shell"><canvas id="canvas"></canvas><header id="topbar"><div class="brand"><span class="brand-mark">✦</span><span>Paperplane</span><span class="status">Saved locally</span></div><div id="toolbar" aria-label="Drawing tools"></div><div class="actions"><button id="undo" title="Undo (Ctrl+Z)">↶</button><button id="redo" title="Redo (Ctrl+Shift+Z)">↷</button><button id="reset" title="Reset view">⌖ <span id="zoom">100%</span></button></div></header><aside id="stylebar"><label>Stroke <input id="stroke" type="color" value="#1f2937"></label><label>Fill <input id="fill" type="color" value="#dbeafe"></label><label>Width <select id="width"><option value="1">1 px</option><option value="2" selected="">2 px</option><option value="4">4 px</option><option value="6">6 px</option></select></label></aside><div id="hint">Hold <kbd>Space</kbd> + drag to pan · Scroll to zoom</div></div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
// Paperplane Whiteboard — constants and serializable scene model
const K = { min: .25, max: 4, hit: 7, handle: 8, store: 'paperplane-whiteboard-v1' };
const cv = document.querySelector('#canvas'), g = cv.getContext('2d'), bar = document.querySelector('#toolbar'), zoom = document.querySelector('#zoom'), stroke = document.querySelector('#stroke'), fill = document.querySelector('#fill'), width = document.querySelector('#width');
let scene = [], sel = new Set(), view = { x: innerWidth / 2, y: innerHeight / 2, z: 1 }, tool = 'select', mode = 'idle', start = { x: 0, y: 0 }, last = { x: 0, y: 0 }, draft = null, before = '', corner = '', spaceHeld = false;
let undoQ = [], redoQ = [];
const tools = [['select', '↖', 'V', 'Select'], ['pen', '✎', 'P', 'Pen'], ['rect', '□', 'R', 'Rectangle'], ['ellipse', '◯', 'O', 'Ellipse'], ['line', '╱', 'L', 'Line'], ['arrow', '→', 'A', 'Arrow'], ['text', 'T', 'T', 'Text'], ['eraser', '⌫', 'E', 'Eraser']];
const id = () => Math.random().toString(36).slice(2, 10), snap = () => JSON.stringify(scene);
function persist() { localStorage.setItem(K.store, snap()); }
function commit() { if (before && before !== snap()) {
undoQ.push(before);
redoQ = [];
persist();
} before = ''; }
function undo() { if (!undoQ.length)
return; redoQ.push(snap()); scene = JSON.parse(undoQ.pop()); sel.clear(); persist(); render(); }
function redo() { if (!redoQ.length)
return; undoQ.push(snap()); scene = JSON.parse(redoQ.pop()); sel.clear(); persist(); render(); }
function wp(e) { let r = cv.getBoundingClientRect(); return { x: (e.clientX - r.left - view.x) / view.z, y: (e.clientY - r.top - view.y) / view.z }; }
function sp(p) { return { x: p.x * view.z + view.x, y: p.y * view.z + view.y }; }
function box(s) { if (s.type === 'pen') {
let a = s.points, xs = a.map(p => p.x), ys = a.map(p => p.y);
return { x: Math.min(...xs), y: Math.min(...ys), w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
} if (s.x2 !== undefined)
return { x: Math.min(s.x, s.x2), y: Math.min(s.y, s.y2), w: Math.abs(s.x2 - s.x), h: Math.abs(s.y2 - s.y) }; return { x: s.x, y: s.y, w: s.w || 0, h: s.h || 0 }; }
function normalize(s) { if (s.w !== undefined && s.w < 0) {
s.x += s.w;
s.w = -s.w;
} if (s.h !== undefined && s.h < 0) {
s.y += s.h;
s.h = -s.h;
} return s; }
function seg(p, a, b) { let x = b.x - a.x, y = b.y - a.y, n = x * x + y * y, t = n ? Math.max(0, Math.min(1, ((p.x - a.x) * x + (p.y - a.y) * y) / n)) : 0; return Math.hypot(p.x - a.x - t * x, p.y - a.y - t * y); }
function hit(s, p) { let t = K.hit / view.z + s.sw / 2; if (s.type === 'pen')
return s.points.some((q, i, a) => i > 0 && seg(p, a[i - 1], q) < t); if (s.x2 !== undefined)
return seg(p, { x: s.x, y: s.y }, { x: s.x2, y: s.y2 }) < t; let b = box(s); if (s.type === 'ellipse') {
let rx = b.w / 2, ry = b.h / 2;
if (!rx || !ry)
return false;
return ((p.x - b.x - rx) / rx) ** 2 + ((p.y - b.y - ry) / ry) ** 2 <= 1 + t / Math.min(rx, ry);
} return p.x >= b.x - t && p.x <= b.x + b.w + t && p.y >= b.y - t && p.y <= b.y + b.h + t; }
function draw(s) { g.save(); g.strokeStyle = s.stroke; g.fillStyle = s.fill; g.lineWidth = s.sw; g.lineCap = 'round'; g.lineJoin = 'round'; if (s.type === 'pen') {
let a = s.points;
g.beginPath();
g.moveTo(a[0].x, a[0].y);
for (let i = 1; i < a.length; i++)
g.quadraticCurveTo(a[i - 1].x, a[i - 1].y, (a[i - 1].x + a[i].x) / 2, (a[i - 1].y + a[i].y) / 2);
g.stroke();
}
else if (s.type === 'rect') {
g.fillRect(s.x, s.y, s.w, s.h);
g.strokeRect(s.x, s.y, s.w, s.h);
}
else if (s.type === 'ellipse') {
g.beginPath();
g.ellipse(s.x + s.w / 2, s.y + s.h / 2, Math.abs(s.w) / 2, Math.abs(s.h) / 2, 0, 0, 7);
g.fill();
g.stroke();
}
else if (s.type === 'text') {
g.fillStyle = s.stroke;
g.font = '16px Inter,sans-serif';
g.fillText(s.text || '', s.x, s.y + 16);
}
else {
g.beginPath();
g.moveTo(s.x, s.y);
g.lineTo(s.x2, s.y2);
g.stroke();
if (s.type === 'arrow') {
let a = Math.atan2(s.y2 - s.y, s.x2 - s.x), z = 10;
g.fillStyle = s.stroke;
g.beginPath();
g.moveTo(s.x2, s.y2);
g.lineTo(s.x2 - z * Math.cos(a - .5), s.y2 - z * Math.sin(a - .5));
g.lineTo(s.x2 - z * Math.cos(a + .5), s.y2 - z * Math.sin(a + .5));
g.fill();
}
} g.restore(); }
function selectedBox() { let a = [...sel].map(i => box(scene.find(s => s.id === i))); if (!a.length)
return null; let x = Math.min(...a.map(b => b.x)), y = Math.min(...a.map(b => b.y)), r = Math.max(...a.map(b => b.x + b.w)), d = Math.max(...a.map(b => b.y + b.h)); return { x, y, w: r - x, h: d - y }; }
function selection() { let b = selectedBox(); if (!b)
return; g.save(); g.strokeStyle = '#635bff'; g.lineWidth = 1 / view.z; g.setLineDash([5 / view.z, 3 / view.z]); g.strokeRect(b.x, b.y, b.w, b.h); g.setLineDash([]); for (let p of [{ x: b.x, y: b.y }, { x: b.x + b.w, y: b.y }, { x: b.x, y: b.y + b.h }, { x: b.x + b.w, y: b.y + b.h }]) {
let z = K.handle / view.z;
g.fillStyle = '#fff';
g.fillRect(p.x - z / 2, p.y - z / 2, z, z);
g.strokeRect(p.x - z / 2, p.y - z / 2, z, z);
} g.restore(); }
function render() { let d = devicePixelRatio, w = innerWidth, h = innerHeight; if (cv.width !== w * d || cv.height !== h * d) {
cv.width = w * d;
cv.height = h * d;
} g.setTransform(d, 0, 0, d, 0, 0); g.clearRect(0, 0, w, h); g.save(); g.translate(view.x, view.y); g.scale(view.z, view.z); g.fillStyle = '#dce4ee'; let l = -view.x / view.z, t = -view.y / view.z; for (let x = Math.floor(l / 20) * 20; x < l + w / view.z; x += 20)
for (let y = Math.floor(t / 20) * 20; y < t + h / view.z; y += 20) {
g.beginPath();
g.arc(x, y, .7 / view.z, 0, 7);
g.fill();
} scene.forEach(draw); if (draft)
draw(draft); selection(); g.restore(); zoom.textContent = Math.round(view.z * 100) + '%'; }
function setTool(t) { tool = t; bar.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.t === t)); cv.style.cursor = t === 'select' ? 'default' : 'crosshair'; }
tools.forEach(([t, i, k, n]) => { let b = document.createElement('button'); b.dataset.t = t; b.title = `${n} (${k})`; b.innerHTML = `${i}<span class="tool-key">${k}</span>`; b.onclick = () => setTool(t); bar.append(b); });
setTool('select');
function make(p) { let q = { id: id(), x: p.x, y: p.y, stroke: stroke.value, fill: fill.value, sw: +width.value }; if (tool === 'pen')
return { ...q, type: 'pen', points: [p] }; if (tool === 'line' || tool === 'arrow')
return { ...q, type: tool, x2: p.x, y2: p.y }; return { ...q, type: tool, w: 0, h: 0 }; }
function handle(p) { let b = selectedBox(); if (!b)
return ''; let z = K.handle / view.z; for (let [n, q] of Object.entries({ nw: { x: b.x, y: b.y }, ne: { x: b.x + b.w, y: b.y }, sw: { x: b.x, y: b.y + b.h }, se: { x: b.x + b.w, y: b.y + b.h } }))
if (Math.abs(p.x - q.x) < z && Math.abs(p.y - q.y) < z)
return n; return ''; }
function translate(dx, dy) { scene.forEach(s => { if (!sel.has(s.id))
return; s.x += dx; s.y += dy; if (s.x2 !== undefined) {
s.x2 += dx;
s.y2 += dy;
} if (s.points)
s.points.forEach(p => { p.x += dx; p.y += dy; }); }); }
function erase(p) { scene = scene.filter(s => !hit(s, p)); sel.clear(); render(); }
cv.addEventListener('pointerdown', e => { cv.setPointerCapture(e.pointerId); let p = wp(e); start = p; last = p; if (e.button === 1 || spaceHeld) {
mode = 'pan';
return;
} if (tool === 'text') {
textInput(p);
return;
} if (tool === 'eraser') {
before = snap();
mode = 'erase';
erase(p);
return;
} if (tool !== 'select') {
before = snap();
mode = 'draw';
draft = make(p);
return;
} let h = handle(p); if (h) {
before = snap();
mode = 'resize';
corner = h;
return;
} let s = [...scene].reverse().find(q => hit(q, p)); if (s) {
if (!e.shiftKey)
sel.clear();
sel.add(s.id);
before = snap();
mode = 'move';
}
else {
if (!e.shiftKey)
sel.clear();
mode = 'marquee';
draft = { id: 'm', type: 'rect', x: p.x, y: p.y, w: 0, h: 0, stroke: '#635bff', fill: 'rgba(99,91,255,.08)', sw: 1 };
} render(); });
cv.addEventListener('pointermove', e => { let p = wp(e); if (mode === 'idle') {
cv.style.cursor = handle(p) ? 'nwse-resize' : tool === 'select' ? 'default' : cv.style.cursor;
return;
} if (mode === 'pan') {
view.x += e.clientX - sp(last).x;
view.y += e.clientY - sp(last).y;
last = p;
render();
return;
} if (mode === 'draw' && draft) {
if (draft.type === 'pen')
draft.points.push(p);
else if (draft.x2 !== undefined) {
draft.x2 = p.x;
draft.y2 = p.y;
}
else {
draft.w = p.x - start.x;
draft.h = p.y - start.y;
}
render();
return;
} if (mode === 'erase') {
erase(p);
return;
} if (mode === 'marquee' && draft) {
draft.w = p.x - start.x;
draft.h = p.y - start.y;
render();
return;
} if (mode === 'move') {
translate(p.x - last.x, p.y - last.y);
last = p;
render();
return;
} if (mode === 'resize') {
let dx = p.x - last.x, dy = p.y - last.y;
scene.forEach(s => { if (!sel.has(s.id) || (s.type !== 'rect' && s.type !== 'ellipse'))
return; if (corner.includes('w')) {
s.x += dx;
s.w -= dx;
}
else
s.w += dx; if (corner.includes('n')) {
s.y += dy;
s.h -= dy;
}
else
s.h += dy; });
last = p;
render();
} });
cv.addEventListener('pointerup', () => { if (mode === 'draw' && draft) {
normalize(draft);
if (draft.type !== 'pen' || draft.points.length > 1) {
scene.push(draft);
sel.clear();
sel.add(draft.id);
}
draft = null;
commit();
}
else if (mode === 'marquee' && draft) {
let b = normalize(draft), bw = b.w, bh = b.h;
scene.forEach(s => { let q = box(s); if (q.x >= b.x && q.y >= b.y && q.x + q.w <= b.x + bw && q.y + q.h <= b.y + bh)
sel.add(s.id); });
draft = null;
}
else if (['move', 'resize', 'erase'].includes(mode))
commit(); mode = 'idle'; render(); });
cv.addEventListener('wheel', e => { e.preventDefault(); let r = cv.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top, wx = (x - view.x) / view.z, wy = (y - view.y) / view.z, z = Math.max(K.min, Math.min(K.max, view.z * (e.deltaY < 0 ? 1.12 : .89))); view = { z, x: x - wx * z, y: y - wy * z }; render(); }, { passive: false });
function textInput(p) { let a = document.createElement('textarea'); a.className = 'board-text'; let q = sp(p); a.style.left = q.x + 'px'; a.style.top = q.y + 'px'; document.querySelector('#board-shell').append(a); a.focus(); let finish = () => { let v = a.value.trim(); if (v) {
before = snap();
scene.push({ id: id(), type: 'text', x: p.x, y: p.y, text: v, stroke: stroke.value, fill: 'transparent', sw: +width.value });
commit();
} a.remove(); render(); }; a.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
finish();
} if (e.key === 'Escape') {
a.remove();
render();
} }; a.onblur = finish; }
document.addEventListener('keyup', e => { if (e.key === ' ')
spaceHeld = false; });
document.addEventListener('keydown', e => { if (e.key === ' ') {
spaceHeld = true;
e.preventDefault();
return;
} let tag = e.target.tagName; if (['TEXTAREA', 'INPUT', 'SELECT'].includes(tag))
return; if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
e.preventDefault();
e.shiftKey ? redo() : undo();
return;
} if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') {
e.preventDefault();
redo();
return;
} let t = tools.find(q => q[2] === e.key.toUpperCase()); if (t)
setTool(t[0]); if ((e.key === 'Delete' || e.key === 'Backspace') && sel.size) {
before = snap();
scene = scene.filter(s => !sel.has(s.id));
sel.clear();
commit();
render();
} if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key) && sel.size) {
e.preventDefault();
before = snap();
let n = e.shiftKey ? 10 : 1;
translate(e.key === 'ArrowLeft' ? -n : e.key === 'ArrowRight' ? n : 0, e.key === 'ArrowUp' ? -n : e.key === 'ArrowDown' ? n : 0);
commit();
render();
} });
document.querySelector('#undo').addEventListener('click', undo);
document.querySelector('#redo').addEventListener('click', redo);
document.querySelector('#reset').addEventListener('click', () => { view = { x: innerWidth / 2, y: innerHeight / 2, z: 1 }; render(); });
try {
scene = JSON.parse(localStorage.getItem(K.store) || '[]');
}
catch { }
addEventListener('resize', render);
render();
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"openai/gpt-5.6-terra","provider":"openrouter","persona":"typescript","sessionId":"0f2c2455-476f-41ec-a5b9-5fdd0686a14a","tokensIn":209707,"tokensOut":10785,"tokensTotal":220492,"turns":18,"toolCalls":19,"failedToolCalls":2,"timestamp":"2026-07-31T17:54:37.416Z"} -->