Whiteboard
nex agi Nex N2.5 mini GGUF · 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 59,093 bytes · SHA-256 393265b36d07
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Whiteboard</title>
<style>
/* ── css: layout ── */
* { box-sizing: border-box; }
html, body, #app { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #263141; background: #f7f8fb; }
#app { position: relative; isolation: isolate; }
#canvas { position: absolute; inset: 0; display: block; touch-action: none; cursor: default; }
#canvas.selecting { cursor: crosshair; }
#canvas.pen { cursor: crosshair; }
#canvas.eraser { cursor: cell; }
#canvas.text-mode { cursor: text; }
.topbar { position: absolute; z-index: 20; top: 18px; left: 20px; right: 20px; height: 58px; display: flex; align-items: center; justify-content: space-between; pointer-events: none; }
.brand, .top-actions, .toolbar, .properties, .zoom-bar, .status-pill { pointer-events: auto; box-shadow: 0 10px 30px rgba(30, 41, 59, 0.12), 0 1px 3px rgba(30, 41, 59, 0.16); backdrop-filter: blur(16px); }
.brand { display: flex; align-items: center; gap: 11px; height: 46px; padding: 0 16px 0 13px; border-radius: 14px; background: rgba(255, 255, 255, 0.82); }
.brand-mark { width: 27px; height: 27px; border-radius: 9px; display: grid; place-items: center; color: #fff; background: linear-gradient(135deg, #3157d5, #6d5ce7); font-size: 20px; line-height: 1; transform: rotate(-3deg); }
.brand strong { display: block; font-size: 14px; letter-spacing: -0.01em; color: #1f2937; }
.brand small { display: block; margin-top: 2px; color: #8491a5; font-size: 10px; }
.top-actions { display: flex; gap: 8px; }
.ghost { height: 36px; padding: 0 13px; border: 1px solid rgba(63, 78, 102, 0.12); border-radius: 10px; background: rgba(255, 255, 255, 0.88); color: #526174; font-size: 12px; font-weight: 600; cursor: pointer; transition: 160ms ease; }
.ghost:hover { border-color: rgba(49, 87, 213, 0.38); color: #3157d5; transform: translateY(-1px); }
.ghost[aria-pressed="false"] { color: #9aa6b7; }
.ghost[aria-pressed="false"]:hover { color: #526174; }
.toolbar { position: absolute; z-index: 20; top: 96px; left: 20px; width: 190px; padding: 9px; border-radius: 16px; background: rgba(255, 255, 255, 0.9); }
.tool-group { display: grid; gap: 4px; }
.tool { position: relative; display: grid; grid-template-columns: 27px 1fr 24px; align-items: center; gap: 8px; width: 100%; height: 40px; padding: 0 10px; border: 1px solid transparent; border-radius: 10px; background: transparent; color: #526174; text-align: left; cursor: pointer; font-size: 12px; font-weight: 600; transition: 140ms ease; }
.tool:hover { background: #f3f5fa; color: #263141; }
.tool.active { background: #edf1ff; border-color: rgba(49, 87, 213, 0.16); color: #3157d5; box-shadow: inset 0 0 0 1px rgba(49, 87, 213, 0.08); }
.tool .icon { color: #71809a; font-size: 17px; line-height: 1; text-align: center; }
.tool.active .icon { color: #3157d5; }
.tool kbd { justify-self: end; min-width: 20px; padding: 3px 4px; border: 1px solid #dfe4ed; border-radius: 5px; background: #fff; color: #98a3b4; font-family: inherit; font-size: 9px; line-height: 1; }
.tool.active kbd { border-color: #d5dcff; color: #7081dc; background: #f7f8ff; }
.tool-divider { height: 1px; margin: 8px 4px; background: #e9edf4; }
.properties { position: absolute; z-index: 20; left: 50%; bottom: 22px; transform: translateX(-50%); display: flex; align-items: center; gap: 14px; min-height: 48px; padding: 8px 10px; border-radius: 14px; background: rgba(255, 255, 255, 0.91); }
.property-label { color: #8996a9; font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.color-control { position: relative; display: grid; place-items: center; width: 31px; height: 31px; border-radius: 9px; background: #f2f4f8; }
.color-control input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.swatch { width: 22px; height: 22px; border: 2px solid rgba(38, 49, 65, 0.12); border-radius: 7px; background: var(--swatch); }
.swatch.fill { background: color-mix(in srgb, var(--swatch) 32%, white); }
.width-control { display: flex; align-items: center; gap: 7px; color: #8491a5; font-size: 11px; font-weight: 600; }
.width-control input { width: 74px; accent-color: #3157d5; cursor: pointer; }
.width-control output { min-width: 14px; color: #526174; font-size: 11px; text-align: center; }
.property-spacer { width: 1px; height: 27px; background: #e9edf4; }
.icon-button { width: 34px; height: 34px; border: 1px solid #e5eaf1; border-radius: 9px; background: #fff; color: #66758b; font-size: 18px; cursor: pointer; transition: 140ms ease; }
.icon-button:hover { color: #3157d5; border-color: #d8defc; }
.zoom-bar { position: absolute; z-index: 20; right: 20px; bottom: 22px; display: flex; align-items: center; gap: 9px; height: 36px; padding: 0 12px; border-radius: 11px; background: rgba(255, 255, 255, 0.88); color: #748299; font-size: 11px; font-weight: 600; }
.zoom-bar .dot { width: 7px; height: 7px; border-radius: 50%; background: #45c48f; box-shadow: 0 0 0 3px rgba(69, 196, 143, 0.12); }
.zoom-sep { width: 1px; height: 15px; background: #e3e8ef; }
#zoom-label { color: #3b4a5f; }
.status-pill { position: absolute; z-index: 19; left: 50%; bottom: 80px; transform: translateX(-50%); padding: 8px 12px; border-radius: 9px; background: rgba(39, 49, 64, 0.82); color: rgba(255, 255, 255, 0.84); font-size: 11px; opacity: 0; pointer-events: none; transition: opacity 180ms ease; }
.status-pill.visible { opacity: 1; }
.inline-text { position: absolute; z-index: 30; resize: none; overflow: hidden; border: 1px solid #3157d5; border-radius: 4px; outline: none; padding: 0; color: #273140; background: rgba(255, 255, 255, 0.95); font: 600 22px Inter, ui-sans-serif, system-ui, sans-serif; line-height: 1.2; box-shadow: 0 8px 22px rgba(31, 41, 55, 0.13); }
@media (max-width: 760px) {
.toolbar { top: 84px; left: 12px; width: 166px; }
.properties { bottom: 14px; gap: 9px; }
.property-label, .property-spacer { display: none; }
.width-control input { width: 55px; }
.topbar { left: 12px; right: 12px; top: 12px; }
.brand small { display: none; }
.zoom-bar { right: 12px; bottom: 72px; }
.status-pill { bottom: 128px; max-width: calc(100vw - 24px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
}
</style>
</head>
<body>
<div id="app"></div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./constants":"src/constants.ts","./board":"src/board.ts","./renderer":"src/renderer.ts","./ui":"src/ui.ts","./viewport":"src/viewport.ts"},"src/board.ts":{"./constants":"src/constants.ts"},"src/viewport.ts":{"./constants":"src/constants.ts"},"src/renderer.ts":{"./constants":"src/constants.ts"},"src/ui.ts":{"./constants":"src/constants.ts"}};
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 });
exports.start = start;
const constants_1 = require("./constants");
const board_1 = require("./board");
const renderer_1 = require("./renderer");
const ui_1 = require("./ui");
const viewport_1 = require("./viewport");
function start() {
const app = document.getElementById('app');
const canvas = document.createElement('canvas');
canvas.id = 'canvas';
app.appendChild(canvas);
const board = new board_1.BoardModel();
const viewport = new viewport_1.ViewportController();
const state = { tool: 'select', selectedIds: [], zoom: 1, showGrid: true, editingTextId: null, pointerDown: false };
const draft = { mode: 'idle', pointerId: null, pointerScreen: { x: 0, y: 0 }, pointerWorld: { x: 0, y: 0 }, startScreen: { x: 0, y: 0 }, startWorld: { x: 0, y: 0 }, original: [], targetIds: [], target: null, handle: null, created: null, rubberBand: [], textObject: null };
const pointer = { x: 0, y: 0 };
const context = { app, canvas, viewport, board, renderer: new renderer_1.CanvasRenderer(canvas, () => board.getObjects(), () => draft, () => board.selectedIds()), state, draft, pointer, activeTool: 'select', selectedIds: [], pointerDown: false, lastPointer: { x: 0, y: 0 } };
const setTool = (tool) => {
if (commitTextEditing())
return;
context.activeTool = tool;
state.tool = tool;
state.selectedIds = [];
draft.mode = 'idle';
render();
};
const selectAt = (screenPoint, additive) => {
const hit = board.findObjectAt(screenPoint, viewport.viewport, state.editingTextId);
const id = hit?.id ?? null;
if (!additive)
state.selectedIds = [];
if (id) {
if (!state.selectedIds.includes(id))
state.selectedIds.push(id);
}
else if (!additive) {
state.selectedIds = [];
}
board.setSelected(state.selectedIds);
updateChrome();
render();
};
const setPointer = (event, capture = true) => {
const rect = canvas.getBoundingClientRect();
pointer.x = event.clientX - rect.left;
pointer.y = event.clientY - rect.top;
pointerScreenToWorld(pointer, viewport.viewport);
draft.pointerScreen = { x: pointer.x, y: pointer.y };
if (capture)
canvas.setPointerCapture(event.pointerId);
render();
};
const pointerScreenToWorld = (point, viewportArg) => {
draft.pointerWorld = { x: (point.x - viewportArg.tx) / viewportArg.zoom, y: (point.y - viewportArg.ty) / viewportArg.zoom };
};
const startAction = (event) => {
if (commitTextEditing())
return;
setPointer(event);
context.pointerDown = true;
draft.mode = 'pan';
draft.pointerId = event.pointerId;
draft.startScreen = { ...pointer };
draft.startWorld = { ...draft.pointerWorld };
draft.pointerScreen = { ...pointer };
render();
};
const moveAction = (event) => {
if (!context.pointerDown || event.pointerId !== draft.pointerId)
return;
const before = { ...draft.pointerWorld };
setPointer(event);
const dx = draft.pointerWorld.x - before.x;
const dy = draft.pointerWorld.y - before.y;
if (draft.mode === 'pan') {
viewport.viewport.tx += dx;
viewport.viewport.ty += dy;
}
else if (draft.mode === 'move') {
board.updateMany(draft.targetIds, {
...(defaultShapePatch(draft.target, dx, dy)),
points: draft.target?.type === 'freehand' ? draft.target.points.map((point) => ({ ...point, x: point.x + dx, y: point.y + dy })) : undefined,
});
}
else if (draft.mode === 'stroke') {
appendStrokePoint(draft.pointerWorld);
}
else if (draft.mode === 'rubberband') {
// Rubber-band bounds are derived live from start and current world points.
}
else if (draft.mode === 'erase') {
eraseAt(draft.pointerWorld, event.pointerId);
}
render();
};
const endAction = (event) => {
if (!context.pointerDown || event.pointerId !== draft.pointerId)
return;
context.pointerDown = false;
draft.pointerId = null;
canvas.releasePointerCapture?.(event.pointerId);
if (draft.mode === 'stroke')
finishStroke();
if (draft.mode === 'rubberband')
finishRubberBand();
if (draft.mode === 'move')
board.commitTransaction();
if (draft.mode === 'erase')
board.commitTransaction();
if (draft.mode === 'pan') {
viewport.clampToBoard(board.getObjects());
board.commitTransaction();
}
draft.mode = 'idle';
render();
};
const wheel = (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const point = { x: event.clientX - rect.left, y: event.clientY - rect.top };
viewport.zoomBy(event.deltaY, point, canvas);
updateZoom();
render();
};
const resizeTarget = (handle) => {
const object = board.getObject(draft.targetIds[0]);
if (!object || !draft.target)
return;
const start = { x: draft.startWorld.x, y: draft.startWorld.y };
const current = draft.pointerWorld;
const left = handle.includes('w');
const right = handle.includes('e');
const top = handle.includes('n');
const bottom = handle.includes('s');
if (object.type === 'ellipse' || object.type === 'rectangle') {
let x = object.x;
let y = object.y;
let w = object.w;
let h = object.h;
if (right)
w = current.x - start.x;
if (left) {
w = start.x - current.x;
x = current.x;
}
if (bottom)
h = current.y - start.y;
if (top) {
h = start.y - current.y;
y = current.y;
}
if (eventModifiers().shift && object.type === 'ellipse') {
const side = Math.max(Math.abs(w), Math.abs(h));
w = side;
h = side;
}
board.update(object.id, { x, y, w, h });
}
else if (object.type === 'line') {
board.update(object.id, { x2: current.x, y2: current.y });
}
};
const handleFor = (screenPoint) => {
const selected = board.getObject(state.selectedIds[0]);
if (!selected)
return null;
const rect = shapeBounds(selected);
const size = HANDLE_TOLERANCE();
const corners = [['nw', { x: rect.x, y: rect.y }], ['ne', { x: rect.x + rect.w, y: rect.y }], ['sw', { x: rect.x, y: rect.y + rect.h }], ['se', { x: rect.x + rect.w, y: rect.y + rect.h }]];
return corners.find(([_, point]) => distance(screenPoint, point) <= size)?.[0] ?? null;
};
const shapeBounds = (object) => {
if (object.type === 'line' || object.type === 'freehand') {
const points = object.type === 'line' ? [{ x: object.x, y: object.y }, { x: object.x2, y: object.y2 }] : object.points;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const point of points) {
minX = Math.min(minX, point.x);
minY = Math.min(minY, point.y);
maxX = Math.max(maxX, point.x);
maxY = Math.max(maxY, point.y);
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
return { x: object.x, y: object.y, w: object.w, h: object.h };
};
const eraseAt = (world, _pointerId) => {
const hit = board.findObjectAt(screenFromWorld(world), viewport.viewport, state.editingTextId);
if (hit)
board.delete([hit.id]);
updateChrome();
};
const screenFromWorld = (world) => ({ x: world.x * viewport.viewport.zoom + viewport.viewport.tx, y: world.y * viewport.viewport.zoom + viewport.viewport.ty });
const appendStrokePoint = (point) => {
if (!draft.created) {
draft.created = strokeFromPoint(point);
}
else {
const last = draft.created.points.at(-1);
if (!last || distance(last, point) > 2 / viewport.viewport.zoom)
draft.created.points.push(point);
}
};
const strokeFromPoint = (point) => ({ id: '', type: 'freehand', points: [point], stroke: constants_1.DEFAULT_PALETTE.stroke, strokeWidth: 2.5, fill: 'none', opacity: 1, _createdOrder: 0, _selected: false });
const finishStroke = () => {
const object = draft.created;
if (object.points.length < 2) {
draft.created = null;
return;
}
board.create({ ...object });
draft.created = null;
board.commitTransaction();
updateChrome();
};
const rubberBandBounds = () => ({ x: Math.min(draft.startWorld.x, draft.pointerWorld.x), y: Math.min(draft.startWorld.y, draft.pointerWorld.y), w: Math.abs(draft.pointerWorld.x - draft.startWorld.x), h: Math.abs(draft.pointerWorld.y - draft.startWorld.y) });
const finishRubberBand = () => {
const bounds = rubberBandBounds();
const hits = board.getObjects().filter((object) => intersects(bounds, shapeBounds(object)));
board.setSelected(hits.map((object) => object.id));
state.selectedIds = board.selectedIds();
updateChrome();
};
const intersects = (a, b) => a.x <= b.x + b.w + constants_1.HIT_TOLERANCE_SCREEN / viewport.viewport.zoom && a.x + a.w + constants_1.HIT_TOLERANCE_SCREEN / viewport.viewport.zoom >= b.x && a.y <= b.y + b.h + constants_1.HIT_TOLERANCE_SCREEN / viewport.viewport.zoom && a.y + a.h + constants_1.HIT_TOLERANCE_SCREEN / viewport.viewport.zoom >= b.y;
const commitTextEditing = () => {
if (!state.editingTextId || !draft.textObject)
return false;
const editor = document.getElementById('inline-text');
if (editor)
draft.textObject.text = editor.value;
board.update(state.editingTextId, { text: draft.textObject.text });
state.editingTextId = null;
draft.textObject = null;
return true;
};
const canvasEvents = () => {
canvas.addEventListener('pointerdown', (event) => {
if (event.button === 1 || (event.button === 0 && context.activeTool === 'select' && (event.ctrlKey || event.metaKey))) {
startAction(event);
return;
}
if (context.activeTool === 'select') {
setPointer(event);
const hit = board.findObjectAt(pointer, viewport.viewport, state.editingTextId);
if (hit) {
const handle = handleFor(pointer);
const selected = board.getObject(hit.id);
if (!state.selectedIds.includes(hit.id)) {
state.selectedIds = [hit.id];
board.setSelected([hit.id]);
}
if (handle) {
draft.mode = 'resize';
draft.handle = handle;
draft.target = selected;
draft.targetIds = [hit.id];
draft.startWorld = { ...draft.pointerWorld };
draft.original = [structuredClone(selected)];
board.beginTransaction(`Resize ${label(selected.type)}`);
}
else {
const wasSelected = state.selectedIds.includes(hit.id);
draft.mode = 'move';
draft.target = selected;
draft.targetIds = wasSelected ? [...state.selectedIds] : [hit.id];
draft.startWorld = { ...draft.pointerWorld };
draft.original = board.getObjects().filter((object) => draft.targetIds.includes(object.id)).map(structuredClone);
if (!wasSelected) {
state.selectedIds = [hit.id];
board.setSelected(state.selectedIds);
}
board.beginTransaction(`Move ${plural(draft.targetIds.length)} object`);
}
}
else {
if (!event.shiftKey) {
state.selectedIds = [];
board.setSelected([]);
}
draft.mode = 'rubberband';
draft.pointerId = event.pointerId;
draft.startWorld = { ...draft.pointerWorld };
draft.pointerScreen = { ...pointer };
board.beginTransaction('Rubber-band select');
}
context.pointerDown = true;
draft.pointerId = event.pointerId;
canvas.setPointerCapture(event.pointerId);
}
else if (context.activeTool === 'pen') {
setPointer(event);
draft.mode = 'stroke';
draft.pointerId = event.pointerId;
appendStrokePoint(draft.pointerWorld);
canvas.setPointerCapture(event.pointerId);
}
else if (context.activeTool === 'eraser') {
setPointer(event);
draft.mode = 'erase';
draft.pointerId = event.pointerId;
eraseAt(draft.pointerWorld, event.pointerId);
canvas.setPointerCapture(event.pointerId);
}
else if (context.activeTool === 'text') {
if (commitTextEditing())
return;
const object = { id: `text-${Date.now()}-${Math.random().toString(36).slice(2)}`, type: 'text', x: draft.pointerWorld.x, y: draft.pointerWorld.y, text: '', fontSize: 22, stroke: constants_1.DEFAULT_PALETTE.stroke, strokeWidth: 1, fill: 'none', opacity: 1, _createdOrder: 0, _selected: false };
draft.textObject = object;
state.editingTextId = object.id;
board.setSelected([object.id]);
draft.mode = 'text';
const editor = document.createElement('textarea');
editor.id = 'inline-text';
editor.value = '';
editor.style.left = `${object.x * viewport.viewport.zoom + viewport.viewport.tx}px`;
editor.style.top = `${object.y * viewport.viewport.zoom + viewport.viewport.ty}px`;
editor.style.width = `${Math.max(120, object.fontSize * 12)}px`;
editor.style.height = '54px';
app.appendChild(editor);
editor.focus();
}
else {
setPointer(event);
draft.mode = 'shape';
draft.pointerId = event.pointerId;
draft.startWorld = { ...draft.pointerWorld };
draft.created = shapeDraft(context.activeTool);
canvas.setPointerCapture(event.pointerId);
}
});
canvas.addEventListener('pointermove', moveAction);
canvas.addEventListener('pointerup', endAction);
canvas.addEventListener('pointercancel', endAction);
canvas.addEventListener('wheel', wheel, { passive: false });
};
const shapeDraft = (tool) => {
if (tool === 'line')
return { id: '', type: 'line', x: draft.startWorld.x, y: draft.startWorld.y, x2: draft.startWorld.x, y2: draft.startWorld.y, stroke: constants_1.DEFAULT_PALETTE.stroke, strokeWidth: 2.5, fill: 'none', opacity: 1, _createdOrder: 0, _selected: false };
if (tool === 'text')
return { id: '', type: 'text', x: draft.startWorld.x, y: draft.startWorld.y, text: '', fontSize: 22, stroke: constants_1.DEFAULT_PALETTE.stroke, strokeWidth: 1, fill: 'none', opacity: 1, _createdOrder: 0, _selected: false };
return { id: '', type: tool, x: draft.startWorld.x, y: draft.startWorld.y, w: 0, h: 0, stroke: constants_1.DEFAULT_PALETTE.stroke, strokeWidth: 2.5, fill: constants_1.DEFAULT_PALETTE.fill, opacity: 1, _createdOrder: 0, _selected: false };
};
const shapeFromDraft = () => {
if (!draft.created)
return { id: '', type: 'rectangle', x: draft.startWorld.x, y: draft.startWorld.y, w: 0, h: 0, stroke: constants_1.DEFAULT_PALETTE.stroke, strokeWidth: 2.5, fill: constants_1.DEFAULT_PALETTE.fill, opacity: 1, _createdOrder: 0, _selected: false };
return draft.created;
};
const updateShapeDraft = () => {
if (!draft.created)
return;
const object = draft.created;
if (object.type === 'line') {
object.x2 = draft.pointerWorld.x;
object.y2 = draft.pointerWorld.y;
return;
}
if (object.type === 'text') {
object.text = '';
return;
}
const bounds = normalizedShape(draft.startWorld, draft.pointerWorld);
object.x = bounds.x;
object.y = bounds.y;
object.w = bounds.w;
object.h = bounds.h;
};
const normalizedShape = (start, current) => ({ x: Math.min(start.x, current.x), y: Math.min(start.y, current.y), w: Math.abs(current.x - start.x), h: Math.abs(current.y - start.y) });
const updateDraft = () => {
if (draft.mode === 'shape')
updateShapeDraft();
if (draft.mode === 'rubberband')
render();
};
const handleDrag = (event) => {
if (draft.mode !== 'resize')
return;
resizeTarget(draft.handle);
render();
};
const keydown = (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.preventDefault();
event.shiftKey ? board.redo() : board.undo();
updateChrome();
render();
return;
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'y') {
event.preventDefault();
board.redo();
updateChrome();
render();
return;
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
board.save();
setStatus('Board saved locally');
return;
}
if (state.editingTextId)
return;
const key = event.key.toLowerCase();
if (key === 'v')
setTool('select');
else if (key === 'p')
setTool('pen');
else if (key === 'r')
setTool('rectangle');
else if (key === 'o')
setTool('ellipse');
else if (key === 'l')
setTool('line');
else if (key === 't')
setTool('text');
else if (key === 'e')
setTool('eraser');
else if ((key === 'delete' || key === 'backspace') && state.selectedIds.length > 0) {
event.preventDefault();
board.delete(state.selectedIds);
state.selectedIds = [];
board.setSelected([]);
updateChrome();
render();
}
else if (key === 'arrowleft' || key === 'arrowright' || key === 'arrowup' || key === 'arrowdown') {
const step = event.shiftKey ? constants_1.NUDGE_SHIFT_AMOUNT : constants_1.NUDGE_AMOUNT;
const dx = key === 'arrowleft' ? -step : key === 'arrowright' ? step : 0;
const dy = key === 'arrowup' ? -step : key === 'arrowdown' ? step : 0;
if (dx || dy) {
event.preventDefault();
board.nudge(dx, dy);
updateChrome();
render();
}
}
};
const eventModifiers = () => ({ shiftKey: false });
const render = () => {
window.boardViewport = viewport.viewport;
window.selectedIds = state.selectedIds;
renderer.requestRender();
requestAnimationFrame(render);
};
const setStatus = (message) => { const status = document.getElementById('status'); if (status)
status.textContent = message; };
const updateZoom = () => { const label = document.getElementById('zoom-label'); if (label)
label.textContent = `${Math.round(viewport.viewport.zoom * 100)}%`; };
const updateChrome = () => {
updateZoom();
document.getElementById('object-count').textContent = `${board.getObjects().length} objects`;
Array.from(app.querySelectorAll('.tool')).forEach((button) => button.classList.toggle('active', button.dataset.tool === state.tool));
};
(0, ui_1.createChrome)(app, {
setTool,
setColors: (stroke, fill, strokeWidth) => {
const selected = board.getObject(state.selectedIds[0]);
if (selected)
board.update(state.selectedIds[0], { stroke, fill, strokeWidth });
board.setSelected(state.selectedIds);
updateChrome();
render();
},
resetView: () => { viewport.reset(); updateZoom(); render(); setStatus('View reset'); },
toggleGrid: () => { state.showGrid = !state.showGrid; setStatus(state.showGrid ? 'Grid on' : 'Grid off'); },
undo: () => { if (board.undo()) {
state.selectedIds = board.selectedIds();
updateChrome();
render();
setStatus('Undo');
} },
redo: () => { if (board.redo()) {
state.selectedIds = board.selectedIds();
updateChrome();
render();
setStatus('Redo');
} },
setStatus,
getZoom: () => viewport.viewport.zoom,
});
canvasEvents();
window.addEventListener('keydown', keydown);
updateChrome();
render();
}
const HANDLE_TOLERANCE = () => constants_1.HIT_TOLERANCE_SCREEN;
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
const label = (type) => type[0].toUpperCase() + type.slice(1);
const plural = (count) => count === 1 ? '1 object' : `${count} objects`;
function defaultShapePatch(object, dx, dy) {
if (!object)
return { x: dx, y: dy };
if (object.type === 'line')
return { x2: object.x2 + dx, y2: object.y2 + dy };
if (object.type === 'freehand')
return {};
return { x: object.x + dx, y: object.y + dy };
}
};
// ── module: src/constants.ts ──
__mods["src/constants.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.TOOL_ORDER = exports.DEFAULT_PALETTE = exports.NUDGE_SHIFT_AMOUNT = exports.NUDGE_AMOUNT = exports.HANDLE_SIZE_SCREEN = exports.HIT_TOLERANCE_SCREEN = exports.DEFAULT_ZOOM = exports.ZOOM_MAX = exports.ZOOM_MIN = exports.APP_KEY = void 0;
exports.APP_KEY = 'collaborative-whiteboard-prototype-v1';
exports.ZOOM_MIN = 0.25;
exports.ZOOM_MAX = 4;
exports.DEFAULT_ZOOM = 1;
exports.HIT_TOLERANCE_SCREEN = 7;
exports.HANDLE_SIZE_SCREEN = 8;
exports.NUDGE_AMOUNT = 1;
exports.NUDGE_SHIFT_AMOUNT = 10;
exports.DEFAULT_PALETTE = {
stroke: '#273140',
fill: 'rgba(47, 84, 150, 0.16)',
background: '#f7f8fb',
accent: '#3157d5',
};
exports.TOOL_ORDER = ['select', 'pen', 'rectangle', 'ellipse', 'line', 'text', 'eraser'];
};
// ── module: src/board.ts ──
__mods["src/board.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.BoardModel = void 0;
const constants_1 = require("./constants");
class BoardModel {
constructor() {
this.objects = [];
this.history = [];
this.future = [];
this.nextOrder = 1;
this.nextId = 1;
this.undoDepth = 0;
this.transaction = null;
this.objects = this.readPersisted().map((item, index) => ({ ...item, _order: index + 1, _selected: false }));
this.nextOrder = Math.max(1, ...this.objects.map((item) => item._order)) + 1;
this.nextId = Math.max(1, ...this.objects.map((item) => Number(String(item.id).replace(/\D/g, '')) || 0)) + 1;
}
readPersisted() {
try {
const raw = window.localStorage.getItem(constants_1.APP_KEY);
if (!raw)
return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed))
return [];
return parsed.filter(isStoredObject);
}
catch {
return [];
}
}
save() { try {
window.localStorage.setItem(constants_1.APP_KEY, JSON.stringify(this.objects.map(({ _order, _selected, ...item }) => item)));
}
catch { /* storage unavailable */ } }
clearHistory() { this.history = []; this.future = []; this.undoDepth = 0; }
setObjects(objects) {
this.objects = objects.map((item, index) => ({ ...item, _order: index + 1, _selected: false }));
this.nextOrder = Math.max(1, ...this.objects.map((item) => item._order)) + 1;
this.nextId = Math.max(1, ...this.objects.map((item) => Number(String(item.id).replace(/\D/g, '')) || 0)) + 1;
this.clearHistory();
this.save();
}
getObjects() { return this.objects; }
getObject(id) { return this.objects.find((item) => item.id === id); }
selectedIds() { return this.objects.filter((item) => item._selected).map((item) => item.id); }
setSelected(ids) { const selected = new Set(ids); this.objects = this.objects.map((item) => ({ ...item, _selected: selected.has(item.id) })); this.save(); }
findObjectAt(screenPoint, viewport, editingTextId) {
const world = this.screenToWorld(screenPoint, viewport);
return [...this.objects].sort((a, b) => b._order - a._order).find((item) => hitObject(item, world, viewport.zoom));
}
screenToWorld(point, viewport) {
return { x: (point.x - viewport.tx) / viewport.zoom, y: (point.y - viewport.ty) / viewport.zoom };
}
worldToScreen(point, viewport) {
return { x: point.x * viewport.zoom + viewport.tx, y: point.y * viewport.zoom + viewport.ty };
}
create(object) {
const item = { ...object, id: `${Date.now().toString(36)}-${this.nextId++}`, _order: this.nextOrder++, _selected: false };
this.beginTransaction(`Add ${labelFor(item.type)}`);
this.objects = [...this.objects, item].sort((a, b) => a._order - b._order);
this.commitTransaction();
return item;
}
update(id, patch) {
const index = this.objects.findIndex((item) => item.id === id);
if (index < 0)
return null;
const next = { ...this.objects[index], ...patch, _order: this.objects[index]._order, _selected: this.objects[index]._selected };
this.beginTransaction(`Edit ${labelFor(next.type)}`);
this.objects[index] = next;
this.commitTransaction();
return next;
}
updateMany(ids, patch) {
const found = this.objects.filter((item) => ids.includes(item.id));
if (!found.length)
return [];
this.beginTransaction(`Move ${pluralized(found.length)} object`);
this.objects = this.objects.map((item) => ids.includes(item.id) ? { ...item, ...patch, _order: item._order, _selected: item._selected } : item);
this.commitTransaction();
return found;
}
nudge(dx, dy) {
const selectedIds = new Set(this.selectedIds());
if (!selectedIds.size)
return;
this.beginTransaction(`Nudge ${pluralized(selectedIds.size)} object`);
this.objects = this.objects.map((item) => selectedIds.has(item.id) ? {
...item,
...(item.type === 'line' ? { x2: item.x2 + dx, y2: item.y2 + dy } : item.type === 'freehand' ? { points: item.points.map((point) => ({ ...point, x: point.x + dx, y: point.y + dy })) } : { x: item.x + dx, y: item.y + dy }),
} : item);
this.commitTransaction();
}
delete(ids) {
const found = this.objects.filter((item) => ids.includes(item.id));
if (!found.length)
return [];
this.beginTransaction(`Erase ${pluralized(found.length)} object`);
this.objects = this.objects.filter((item) => !ids.includes(item.id));
this.commitTransaction();
return found;
}
undo() {
if (!this.undoDepth)
return false;
const entry = this.history[this.undoDepth - 1];
this.future.unshift({ label: entry.label, board: structuredClone(this.objects) });
this.objects = structuredClone(entry.board);
this.undoDepth -= 1;
this.save();
return true;
}
redo() {
if (!this.future.length)
return false;
const entry = this.future.shift();
this.undoDepth = Math.min(this.undoDepth, this.history.length);
this.history.push(entry);
this.objects = structuredClone(entry.board);
this.undoDepth += 1;
this.save();
return true;
}
canUndo() { return this.undoDepth > 0; }
canRedo() { return this.future.length > 0; }
lastLabel() { return this.undoDepth > 0 ? this.history[this.undoDepth - 1].label : null; }
beginTransaction(label) { if (this.transaction)
return; this.transaction = { label, before: structuredClone(this.objects) }; }
commitTransaction() {
const current = this.transaction;
if (!current)
return;
this.transaction = null;
if (!sceneEquals(current.before, this.objects))
return;
this.undoDepth = Math.max(0, Math.min(this.undoDepth, this.history.length));
this.history = this.history.slice(0, this.undoDepth);
this.history.push({ label: current.label, board: structuredClone(current.before) });
if (this.history.length > 250)
this.history.shift();
this.undoDepth = this.history.length;
this.future = [];
this.save();
}
discardTransaction() { this.transaction = null; }
}
exports.BoardModel = BoardModel;
function isStoredObject(value) {
if (!value || typeof value !== 'object')
return false;
const item = value;
return typeof item.id === 'string' && ['rectangle', 'ellipse', 'line', 'freehand', 'text'].includes(item.type) && typeof item.stroke === 'string' && typeof item.fill === 'string' && typeof item.opacity === 'number';
}
function hitObject(item, world, zoom) {
const tolerance = 7 / zoom;
if (item.type === 'rectangle')
return world.x >= item.x - tolerance && world.x <= item.x + item.w + tolerance && world.y >= item.y - tolerance && world.y <= item.y + item.h + tolerance;
if (item.type === 'ellipse') {
const rx = Math.abs(item.w) / 2 + tolerance;
const ry = Math.abs(item.h) / 2 + tolerance;
return (((world.x - item.x - item.w / 2) ** 2) / (rx ** 2)) + (((world.y - item.y - item.h / 2) ** 2) / (ry ** 2)) <= 1;
}
if (item.type === 'line')
return pointSegmentDistance(world, { x: item.x, y: item.y }, { x: item.x2, y: item.y2 }) <= tolerance;
if (item.type === 'freehand')
return hitStroke(item.points, world, tolerance);
return hitText(item, world);
}
function hitStroke(points, world, tolerance) {
if (points.length < 2)
return false;
if (distance(world, points[0]) <= tolerance)
return true;
for (let i = 1; i < points.length; i++)
if (pointSegmentDistance(world, points[i - 1], points[i]) <= tolerance)
return true;
return false;
}
function hitText(item, world) {
if (item.text.length === 0)
return false;
return world.x >= item.x - 6 && world.x <= item.x + textWidth(item.text, item.fontSize) + 10 && world.y >= item.y - item.fontSize && world.y <= item.y + item.fontSize * 1.2;
}
function textWidth(text, fontSize) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context)
return text.length * fontSize * 0.55;
context.font = `600 ${fontSize}px Inter, ui-sans-serif, system-ui, sans-serif`;
return Math.max(context.measureText(text).width, 10);
}
function pointSegmentDistance(point, start, end) {
const dx = end.x - start.x;
const dy = end.y - start.y;
if (dx === 0 && dy === 0)
return distance(point, start);
const t = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy)));
return distance(point, { x: start.x + t * dx, y: start.y + t * dy });
}
function distance(left, right) { return Math.hypot(left.x - right.x, left.y - right.y); }
function sceneEquals(a, b) {
if (a.length !== b.length)
return false;
return a.every((item, index) => item.id === b[index].id && JSON.stringify({ ...item, _order: undefined, _selected: undefined }) === JSON.stringify({ ...b[index], _order: undefined, _selected: undefined }));
}
function labelFor(type) { return type[0].toUpperCase() + type.slice(1); }
function pluralized(count) { return count === 1 ? '1 object' : `${count} objects`; }
};
// ── module: src/viewport.ts ──
__mods["src/viewport.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ViewportController = void 0;
const constants_1 = require("./constants");
class ViewportController {
constructor() {
this.viewport = { zoom: 1, tx: 0, ty: 0 };
this.lastWheel = 0;
}
setViewport(next) {
this.viewport = { zoom: clamp(next.zoom, constants_1.ZOOM_MIN, constants_1.ZOOM_MAX), tx: next.tx, ty: next.ty };
}
setZoomAt(zoom, screenPoint, pointOnCanvas) {
const target = clamp(zoom, constants_1.ZOOM_MIN, constants_1.ZOOM_MAX);
const rect = pointOnCanvas.getBoundingClientRect();
const local = { x: screenPoint.x - rect.left, y: screenPoint.y - rect.top };
const worldBefore = { x: (local.x - this.viewport.tx) / this.viewport.zoom, y: (local.y - this.viewport.ty) / this.viewport.zoom };
const scale = target / this.viewport.zoom;
this.viewport = { zoom: target, tx: local.x - worldBefore.x * target, ty: local.y - worldBefore.y * target };
}
zoomBy(delta, screenPoint, pointOnCanvas) {
const exponent = Math.exp(-delta * 0.0015);
this.setZoomAt(this.viewport.zoom * exponent, screenPoint, pointOnCanvas);
}
reset() { this.viewport = { zoom: 1, tx: 0, ty: 0 }; }
panBy(dx, dy) {
this.viewport.tx += dx;
this.viewport.ty += dy;
}
clampToBoard(_objects) {
// Keep pan within generous bounds so the prototype stays usable on small screens.
this.viewport.tx = clamp(this.viewport.tx, -18000, 18000);
this.viewport.ty = clamp(this.viewport.ty, -18000, 18000);
}
}
exports.ViewportController = ViewportController;
function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }
};
// ── module: src/renderer.ts ──
__mods["src/renderer.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CanvasRenderer = void 0;
const constants_1 = require("./constants");
class CanvasRenderer {
constructor(canvas, getObjects, getDraft, getSelectedIds) {
this.getObjects = getObjects;
this.getDraft = getDraft;
this.getSelectedIds = getSelectedIds;
this.dirty = true;
this.canvas = canvas;
this.context = canvas.getContext('2d');
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(canvas);
this.resize();
}
requestRender() { this.dirty = true; }
resize() {
const rect = this.canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.canvas.width = Math.max(1, Math.round(rect.width * dpr));
this.canvas.height = Math.max(1, Math.round(rect.height * dpr));
this.canvas.style.width = `${rect.width}px`;
this.canvas.style.height = `${rect.height}px`;
this.context.setTransform(dpr, 0, 0, dpr, 0, 0);
this.dirty = true;
}
render() {
if (!this.dirty)
return;
this.dirty = false;
const ctx = this.context;
const width = this.canvas.clientWidth;
const height = this.canvas.clientHeight;
const viewport = window.boardViewport;
if (!viewport)
return;
const { zoom, tx, ty } = viewport;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#f7f8fb';
ctx.fillRect(0, 0, width, height);
this.drawGrid(ctx, width, height, viewport);
ctx.save();
ctx.translate(tx, ty);
ctx.scale(zoom, zoom);
for (const object of this.getObjects())
this.drawObject(ctx, object, this.getSelectedIds().includes(object.id));
const draft = this.getDraft();
if (draft && draft.mode !== 'idle')
this.drawDraft(ctx, draft, zoom);
ctx.restore();
}
drawGrid(ctx, width, height, viewport) {
const zoom = viewport.zoom;
const gridSize = 48 * (zoom >= 1 ? 1 : zoom === 0.5 ? 2 : 4);
const startX = Math.floor((-viewport.tx / zoom) / gridSize) * gridSize * zoom;
const startY = Math.floor((-viewport.ty / zoom) / gridSize) * gridSize * zoom;
ctx.strokeStyle = 'rgba(45, 55, 72, 0.07)';
ctx.lineWidth = Math.max(1, zoom * 0.8);
ctx.beginPath();
for (let x = startX; x < viewport.tx + width + gridSize * zoom; x += gridSize * zoom) {
ctx.moveTo(x, viewport.ty);
ctx.lineTo(x, viewport.ty + height);
}
for (let y = startY; y < viewport.ty + height + gridSize * zoom; y += gridSize * zoom) {
ctx.moveTo(viewport.tx, y);
ctx.lineTo(viewport.tx + width, y);
}
ctx.stroke();
}
drawObject(ctx, object, selected) {
ctx.save();
ctx.globalAlpha = object.opacity;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = object.stroke;
ctx.fillStyle = object.fill;
ctx.lineWidth = object.strokeWidth;
if (object.type === 'rectangle')
this.drawRectangle(ctx, object, selected);
else if (object.type === 'ellipse')
this.drawEllipse(ctx, object, selected);
else if (object.type === 'line')
this.drawLine(ctx, object, selected);
else if (object.type === 'freehand')
this.drawFreehand(ctx, object);
else
this.drawText(ctx, object);
ctx.restore();
}
drawRectangle(ctx, object, selected) {
const x = object.x, y = object.y, w = object.w, h = object.h;
if (object.fill && object.fill !== 'none') {
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.fill();
}
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.stroke();
if (selected)
this.drawSelectionBox(ctx, x, y, w, h);
}
drawEllipse(ctx, object, selected) {
ctx.beginPath();
ctx.ellipse(object.x + object.w / 2, object.y + object.h / 2, Math.abs(object.w / 2), Math.abs(object.h / 2), 0, 0, Math.PI * 2);
if (object.fill && object.fill !== 'none')
ctx.fill();
ctx.stroke();
if (selected)
this.drawSelectionBox(ctx, object.x, object.y, object.w, object.h);
}
drawLine(ctx, object, selected) {
this.drawArrow(ctx, object.x, object.y, object.x2, object.y2);
if (selected) {
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(object.x, object.y, constants_1.HANDLE_SIZE_SCREEN / zoomOf(ctx), 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(object.x2, object.y2, constants_1.HANDLE_SIZE_SCREEN / zoomOf(ctx), 0, Math.PI * 2);
ctx.fill();
}
}
drawArrow(ctx, x1, y1, x2, y2) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
const angle = Math.atan2(y2 - y1, x2 - x1);
const size = Math.max(8, ctx.lineWidth * 2.4);
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - size * Math.cos(angle - Math.PI / 6), y2 - size * Math.sin(angle - Math.PI / 6));
ctx.lineTo(x2 - size * Math.cos(angle + Math.PI / 6), y2 - size * Math.sin(angle + Math.PI / 6));
ctx.closePath();
ctx.fillStyle = ctx.strokeStyle;
ctx.fill();
}
drawFreehand(ctx, object) {
if (object.points.length < 2)
return;
ctx.beginPath();
ctx.moveTo(object.points[0].x, object.points[0].y);
for (let i = 1; i < object.points.length - 1; i++) {
const current = object.points[i];
const next = object.points[i + 1];
const midX = (current.x + next.x) / 2;
const midY = (current.y + next.y) / 2;
ctx.quadraticCurveTo(current.x, current.y, midX, midY);
}
const last = object.points[object.points.length - 1];
ctx.lineTo(last.x, last.y);
ctx.stroke();
}
drawText(ctx, object) {
ctx.font = `600 ${object.fontSize}px Inter, ui-sans-serif, system-ui, sans-serif`;
ctx.fillStyle = '#273140';
ctx.fillText(object.text, object.x, object.y);
ctx.strokeStyle = 'rgba(39, 49, 64, 0.18)';
ctx.lineWidth = 1;
ctx.strokeText(object.text, object.x, object.y);
if (selectedOf(ctx))
this.drawTextSelection(ctx, object);
}
drawDraft(ctx, draft, zoom) {
ctx.save();
ctx.globalAlpha = 0.9;
if (draft.mode === 'rubberband') {
const x = Math.min(draft.startWorld.x, draft.pointerWorld.x);
const y = Math.min(draft.startWorld.y, draft.pointerWorld.y);
const w = Math.abs(draft.pointerWorld.x - draft.startWorld.x);
const h = Math.abs(draft.pointerWorld.y - draft.startWorld.y);
ctx.strokeStyle = '#3157d5';
ctx.lineWidth = 1.5 / zoom;
ctx.setLineDash([6 / zoom, 5 / zoom]);
ctx.strokeRect(x, y, w, h);
ctx.restore();
ctx.setLineDash([]);
return;
}
if (draft.mode === 'stroke') {
const points = draft.pointerWorld ? [draft.startWorld, draft.pointerWorld] : [draft.startWorld];
this.drawFreehandStroke(ctx, points, '#273140', 2.5);
ctx.restore();
return;
}
if (draft.created) {
const object = draft.created;
if (object.type === 'line') {
this.drawArrow(ctx, object.x, object.y, object.x2, object.y2);
}
else if (object.type === 'ellipse') {
ctx.beginPath();
ctx.ellipse(object.x + object.w / 2, object.y + object.h / 2, Math.abs(object.w / 2), Math.abs(object.h / 2), 0, 0, Math.PI * 2);
ctx.strokeStyle = object.stroke;
ctx.lineWidth = object.strokeWidth;
ctx.stroke();
}
else if (object.type === 'text') {
ctx.font = `600 ${object.fontSize}px Inter, ui-sans-serif, system-ui, sans-serif`;
ctx.fillStyle = '#273140';
ctx.fillText(object.text, object.x, object.y);
}
else {
ctx.beginPath();
ctx.rect(object.x, object.y, object.w, object.h);
if (object.fill)
ctx.fillStyle = object.fill;
ctx.fill();
ctx.strokeStyle = object.stroke;
ctx.lineWidth = object.strokeWidth;
ctx.stroke();
}
}
ctx.restore();
}
drawFreehandStroke(ctx, points, stroke, width) {
if (points.length < 2)
return;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length - 1; i++) {
const current = points[i];
const next = points[i + 1];
ctx.quadraticCurveTo(current.x, current.y, (current.x + next.x) / 2, (current.y + next.y) / 2);
}
const last = points[points.length - 1];
ctx.lineTo(last.x, last.y);
ctx.strokeStyle = stroke;
ctx.lineWidth = width;
ctx.stroke();
}
drawSelectionBox(ctx, x, y, w, h) {
const zoom = zoomOf(ctx);
const size = constants_1.HANDLE_SIZE_SCREEN / zoom;
ctx.strokeStyle = '#3157d5';
ctx.lineWidth = 1.2 / zoom;
ctx.strokeRect(x, y, w, h);
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#3157d5';
for (const [px, py] of [[x, y], [x + w, y], [x, y + h], [x + w, y + h]]) {
ctx.beginPath();
ctx.rect(px - size / 2, py - size / 2, size, size);
ctx.fill();
ctx.stroke();
}
}
drawTextSelection(ctx, object) {
const zoom = zoomOf(ctx);
ctx.font = `600 ${object.fontSize}px Inter, ui-sans-serif, system-ui, sans-serif`;
const width = ctx.measureText(object.text).width;
const height = object.fontSize;
const size = constants_1.HANDLE_SIZE_SCREEN / zoom;
ctx.strokeStyle = '#3157d5';
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.rect(object.x, object.y - height, width + 12, height + 14);
ctx.stroke();
for (const [px, py] of [[object.x, object.y - height], [object.x + width + 12, object.y - height], [object.x, object.y + 4], [object.x + width + 12, object.y + 4]]) {
ctx.beginPath();
ctx.rect(px - size / 2, py - size / 2, size, size);
ctx.fill();
ctx.stroke();
}
}
}
exports.CanvasRenderer = CanvasRenderer;
function zoomOf(ctx) { return window.boardViewport?.zoom ?? 1; }
function selectedOf(ctx) { return window.selectedIds?.length === 1 && ctx.measureText(''); }
};
// ── module: src/ui.ts ──
__mods["src/ui.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createChrome = createChrome;
const constants_1 = require("./constants");
function createChrome(app, hooks) {
app.innerHTML = `
<header class="topbar">
<div class="brand"><span class="brand-mark">◫</span><div><strong>Canvas</strong><small>Collaborative whiteboard prototype</small></div></div>
<div class="top-actions"><button id="reset-view" class="ghost">Reset view</button><button id="toggle-grid" class="ghost" aria-pressed="true">Grid</button></div>
</header>
<aside class="toolbar" aria-label="Drawing tools">
<div class="tool-group">
<button data-tool="select" class="tool active" title="Select / pan (V)"><span class="icon">↖</span><span>Select</span><kbd>V</kbd></button>
<button data-tool="pen" class="tool" title="Freehand pen (P)"><span class="icon">⌁</span><span>Pen</span><kbd>P</kbd></button>
<button data-tool="rectangle" class="tool" title="Rectangle (R)"><span class="icon">▭</span><span>Rectangle</span><kbd>R</kbd></button>
<button data-tool="ellipse" class="tool" title="Ellipse (O)"><span class="icon">◯</span><span>Ellipse</span><kbd>O</kbd></button>
<button data-tool="line" class="tool" title="Line / arrow (L)"><span class="icon">➤</span><span>Line</span><kbd>L</kbd></button>
</div>
<div class="tool-divider"></div>
<div class="tool-group">
<button data-tool="text" class="tool" title="Text (T)"><span class="icon">T</span><span>Text</span><kbd>T</kbd></button>
<button data-tool="eraser" class="tool" title="Eraser (E)"><span class="icon">⌫</span><span>Eraser</span><kbd>E</kbd></button>
</div>
</aside>
<div class="properties" id="properties">
<div class="property-label">Properties</div>
<label class="color-control"><span class="swatch" id="stroke-swatch" style="--swatch: ${constants_1.DEFAULT_PALETTE.stroke}"></span><input id="stroke-color" type="color" value="${constants_1.DEFAULT_PALETTE.stroke}" aria-label="Stroke color"></label>
<label class="color-control"><span class="swatch fill" id="fill-swatch" style="--swatch: ${constants_1.DEFAULT_PALETTE.fill}"></span><input id="fill-color" type="color" value="${constants_1.DEFAULT_PALETTE.fill}" aria-label="Fill color"></label>
<label class="width-control">Line <input id="line-width" type="range" min="1" max="8" value="2"><output id="width-output">2</output></label>
<div class="property-spacer"></div>
<button id="undo" class="icon-button" title="Undo (Ctrl+Z)" aria-label="Undo">↶</button>
<button id="redo" class="icon-button" title="Redo (Ctrl+Shift+Z)" aria-label="Redo">↷</button>
</div>
<div class="zoom-bar"><span class="dot"></span><span id="zoom-label">100%</span><span class="zoom-sep"></span><span id="object-count">0 objects</span></div>
<div class="status-pill" id="status">Select a tool or draw directly on the canvas</div>
`;
const buttons = Array.from(app.querySelectorAll('.tool'));
buttons.forEach((button) => button.addEventListener('click', () => { hooks.setTool(button.dataset.tool); setStatusFrom(button); }));
app.querySelector('#reset-view').addEventListener('click', () => { hooks.resetView(); setStatusFrom('View reset'); });
app.querySelector('#toggle-grid').addEventListener('click', (event) => { const button = event.currentTarget; button.setAttribute('aria-pressed', button.getAttribute('aria-pressed') === 'true' ? 'false' : 'true'); hooks.toggleGrid(); });
app.querySelector('#undo').addEventListener('click', () => { hooks.undo(); setStatusFrom('Undo'); });
app.querySelector('#redo').addEventListener('click', () => { hooks.redo(); setStatusFrom('Redo'); });
app.querySelector('#stroke-color').addEventListener('input', (event) => { const value = event.target.value; app.querySelector('#stroke-swatch').style.setProperty('--swatch', value); hooks.setColors(value, app.querySelector('#fill-color').value, Number(app.querySelector('#line-width').value)); });
app.querySelector('#fill-color').addEventListener('input', (event) => { const value = event.target.value; app.querySelector('#fill-swatch').style.setProperty('--swatch', value); hooks.setColors(app.querySelector('#stroke-color').value, value, Number(app.querySelector('#line-width').value)); });
app.querySelector('#line-width').addEventListener('input', (event) => { const value = Number(event.target.value); app.querySelector('#width-output').value = String(value); hooks.setColors(app.querySelector('#stroke-color').value, app.querySelector('#fill-color').value, value); });
window.addEventListener('resize', () => { app.querySelector('#object-count').textContent = `${window.objectCount ?? 0} objects`; });
}
function setStatusFrom(source) {
const label = typeof source === 'string' ? source : source.title.replace(/ \(.*/u, '').replace(/^Select/u, 'Select');
if (source !== 'View reset')
window.statusMessage = label;
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>