Whiteboard
Qwen 3.6 27b · 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 82,134 bytes · SHA-256 d7d9f2fca40c
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Whiteboard — Collaborative Canvas</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif; background: #f0f1f3; }
#app { position: relative; width: 100%; height: 100%; overflow: hidden; }
#canvas { display: block; width: 100%; height: 100%; cursor: crosshair; }
/* Cursor styles per tool */
#canvas.tool-select { cursor: default; }
#canvas.tool-pen { cursor: crosshair; }
#canvas.tool-rectangle, #canvas.tool-ellipse, #canvas.tool-line, #canvas.tool-arrow { cursor: crosshair; }
#canvas.tool-text { cursor: text; }
#canvas.tool-eraser { cursor: none; }
#canvas.panning { cursor: grabbing !important; }
/* Eraser cursor indicator */
#eraser-cursor {
position: absolute; pointer-events: none; z-index: 50;
width: 20px; height: 20px; border-radius: 50%;
border: 2px solid #fa5252; background: rgba(250, 82, 82, 0.1);
transform: translate(-50%, -50%);
display: none;
}
/* ── css: toolbar ── */
#toolbar {
position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
display: flex; gap: 2px; padding: 6px; z-index: 100;
background: #ffffff; border-radius: 12px;
border: 1px solid #dee2e6;
box-shadow: 0 2px 12px rgba(0,0,0,0.10);
}
#toolbar button {
width: 38px; height: 38px; border: none; border-radius: 8px;
background: transparent; cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 18px; color: #495057; transition: all 0.15s;
position: relative;
}
#toolbar button:hover { background: #f1f3f5; }
#toolbar button.active { background: #e7f5ff; color: #228be6; border: 1.5px solid #4dabf7; }
#toolbar button .tooltip {
display: none; position: absolute; bottom: -30px; left: 50%; transform: translateX(-50%);
background: #212529; color: #fff; font-size: 11px; padding: 3px 8px; border-radius: 4px;
white-space: nowrap; pointer-events: none;
}
#toolbar button:hover .tooltip { display: block; }
#toolbar button .shortcut {
position: absolute; top: 2px; right: 4px; font-size: 9px; color: #adb5bd; font-weight: 600;
}
/* ── css: propbar ── */
#propbar {
position: absolute; top: 70px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 10px; padding: 6px 14px; z-index: 100;
background: #ffffff; border-radius: 10px;
border: 1px solid #dee2e6;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
opacity: 0; pointer-events: none; transition: opacity 0.2s;
}
#propbar.visible { opacity: 1; pointer-events: auto; }
#propbar label { font-size: 11px; color: #868e96; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.color-swatch {
width: 24px; height: 24px; border-radius: 6px; border: 2px solid transparent;
cursor: pointer; transition: all 0.15s; display: inline-block;
}
.color-swatch:hover { transform: scale(1.15); }
.color-swatch.active { border-color: #4dabf7; box-shadow: 0 0 0 1px #4dabf7; }
.color-row { display: flex; gap: 4px; align-items: center; }
.width-btn {
width: 28px; height: 28px; border: 1px solid #dee2e6; border-radius: 6px;
background: #fff; cursor: pointer; font-size: 12px; color: #495057;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.width-btn:hover { background: #f1f3f5; }
.width-btn.active { background: #e7f5ff; border-color: #4dabf7; color: #228be6; }
.divider { width: 1px; height: 24px; background: #dee2e6; }
/* ── css: zoom-history ── */
#zoom-indicator {
position: absolute; bottom: 16px; right: 16px; z-index: 100;
display: flex; align-items: center; gap: 4px; padding: 4px 6px;
background: #ffffff; border-radius: 8px;
border: 1px solid #dee2e6;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
#zoom-indicator button {
width: 30px; height: 30px; border: none; border-radius: 6px;
background: transparent; cursor: pointer; font-size: 16px; color: #495057;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
#zoom-indicator button:hover { background: #f1f3f5; }
#zoom-level { font-size: 13px; color: #495057; min-width: 50px; text-align: center; font-weight: 500; user-select: none; }
#history-bar {
position: absolute; bottom: 16px; left: 16px; z-index: 100;
display: flex; align-items: center; gap: 4px; padding: 4px 6px;
background: #ffffff; border-radius: 8px;
border: 1px solid #dee2e6;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
#history-bar button {
width: 30px; height: 30px; border: none; border-radius: 6px;
background: transparent; cursor: pointer; font-size: 16px; color: #495057;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
#history-bar button:hover { background: #f1f3f5; }
#history-bar button:disabled { opacity: 0.3; cursor: default; }
#history-bar button:disabled:hover { background: transparent; }
</style>
</head>
<body>
<div id="app">
<div id="toolbar"></div>
<div id="propbar"></div>
<div id="zoom-indicator">
<button id="zoom-out" title="Zoom Out">−</button>
<span id="zoom-level">100%</span>
<button id="zoom-in" title="Zoom In">+</button>
<button id="zoom-reset" title="Reset View">⌂</button>
</div>
<div id="history-bar">
<button id="undo-btn" title="Undo (Ctrl+Z)">↶</button>
<button id="redo-btn" title="Redo (Ctrl+Shift+Z)">↷</button>
</div>
<canvas id="canvas"></canvas>
<textarea id="text-input" style="display:none; position:absolute; border:2px solid #4dabf7; border-radius:4px; padding:4px 8px; outline:none; resize:none; font-family:inherit; background:#fff; box-shadow:0 2px 8px rgba(0,0,0,0.15);"></textarea>
<div id="eraser-cursor"></div></div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./state":"src/state.ts","./render":"src/render.ts","./input":"src/input.ts","./ui":"src/ui.ts","./persistence":"src/persistence.ts","./history":"src/history.ts"},"src/state.ts":{"./types":"src/types.ts","./constants":"src/constants.ts"},"src/viewport.ts":{"./types":"src/types.ts","./constants":"src/constants.ts"},"src/scene.ts":{"./types":"src/types.ts","./state":"src/state.ts","./constants":"src/constants.ts"},"src/hitTest.ts":{"./types":"src/types.ts","./constants":"src/constants.ts"},"src/history.ts":{"./types":"src/types.ts","./scene":"src/scene.ts","./state":"src/state.ts"},"src/render.ts":{"./types":"src/types.ts","./viewport":"src/viewport.ts","./scene":"src/scene.ts","./state":"src/state.ts","./constants":"src/constants.ts"},"src/persistence.ts":{"./types":"src/types.ts","./constants":"src/constants.ts","./scene":"src/scene.ts","./state":"src/state.ts","./history":"src/history.ts"},"src/tools.ts":{"./types":"src/types.ts","./state":"src/state.ts","./scene":"src/scene.ts","./hitTest":"src/hitTest.ts","./history":"src/history.ts","./constants":"src/constants.ts","./persistence":"src/persistence.ts"},"src/input.ts":{"./types":"src/types.ts","./state":"src/state.ts","./viewport":"src/viewport.ts","./scene":"src/scene.ts","./hitTest":"src/hitTest.ts","./tools":"src/tools.ts","./history":"src/history.ts","./constants":"src/constants.ts","./persistence":"src/persistence.ts"},"src/ui.ts":{"./types":"src/types.ts","./constants":"src/constants.ts","./state":"src/state.ts","./history":"src/history.ts","./viewport":"src/viewport.ts","./tools":"src/tools.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";
// ── Entry Point ───────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.start = start;
const state_1 = require("./state");
const render_1 = require("./render");
const input_1 = require("./input");
const ui_1 = require("./ui");
const persistence_1 = require("./persistence");
const history_1 = require("./history");
function start() {
try {
// 1. Initialize state
(0, state_1.initState)();
// 2. Try to load from storage
const loaded = (0, persistence_1.loadFromStorage)();
if (!loaded)
(0, history_1.clearHistory)();
// 3. Build UI
(0, ui_1.buildUI)();
// 4. Get DOM elements
const canvas = document.getElementById('canvas');
const textInput = document.getElementById('text-input');
if (!canvas || !textInput) {
console.error('Missing required DOM elements');
return;
}
// 5. Initialize canvas
(0, render_1.initRender)(canvas);
(0, render_1.resizeCanvas)(window.innerWidth, window.innerHeight);
// 6. Initialize input handling
(0, input_1.initInput)(canvas, textInput);
// 7. Set up render callback
(0, input_1.setRenderCallback)(() => {
(0, render_1.render)();
(0, ui_1.updatePropBarVisibility)();
});
// 8. Set up toolbar callback
(0, input_1.setToolbarCallback)(() => {
(0, ui_1.updateToolbar)();
});
// 9. Set up zoom display callback
(0, input_1.setZoomDisplayCallback)(() => {
const el = document.getElementById('zoom-level');
if (el)
el.textContent = Math.round((0, state_1.getState)().viewport.zoom * 100) + '%';
});
// 10. Set up history callback
(0, input_1.setHistoryCallback)(() => {
(0, ui_1.updateHistoryButtons)();
});
// 11. Set render trigger for UI module
(0, ui_1.setRenderTrigger)(() => {
(0, render_1.render)();
(0, ui_1.updatePropBarVisibility)();
});
// 12. Initial render
(0, ui_1.updateToolbar)();
(0, ui_1.updateHistoryButtons)();
(0, render_1.render)();
// 13. Handle resize
window.addEventListener('resize', () => {
(0, render_1.resizeCanvas)(window.innerWidth, window.innerHeight);
(0, render_1.render)();
});
// 14. Periodic save
setInterval(() => (0, persistence_1.saveToStorage)(), 5000);
// 15. Save on page unload
window.addEventListener('beforeunload', () => (0, persistence_1.saveToStorage)());
}
catch (e) {
console.error('Whiteboard init error:', e);
}
}
// Self-invoke entry
start();
};
// ── module: src/constants.ts ──
__mods["src/constants.ts"] = function (exports, require, module) {
"use strict";
// ── Tunable Constants ──────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.TEXT_INPUT_DELAY = exports.STORAGE_KEY = exports.MIN_SHAPE_SIZE = exports.DEFAULT_FONT_SIZE = exports.DEFAULT_STROKE_WIDTH = exports.DEFAULT_FILL_COLOR = exports.DEFAULT_STROKE_COLOR = exports.NUDGE_STEP_SHIFT = exports.NUDGE_STEP = exports.GRID_SPACING = exports.HANDLE_SIZE = exports.HIT_TOLERANCE = exports.ZOOM_STEP = exports.ZOOM_MAX = exports.ZOOM_MIN = exports.FILL_PALETTE = exports.PALETTE = exports.COLORS = void 0;
exports.COLORS = {
canvas: '#f8f9fa',
chrome: '#ffffff',
chromeBorder: '#dee2e6',
chromeShadow: 'rgba(0,0,0,0.12)',
gridDot: '#d0d5dc',
gridLine: '#e2e5ea',
selection: '#4dabf7',
selectionFill: 'rgba(77,171,247,0.08)',
handle: '#ffffff',
handleStroke: '#4dabf7',
preview: 'rgba(77,171,247,0.5)',
textPlaceholder: '#adb5bd',
toolbarBg: '#ffffff',
toolbarBorder: '#dee2e6',
toolbarShadow: 'rgba(0,0,0,0.10)',
toolActiveBg: '#e7f5ff',
toolActiveBorder: '#4dabf7',
propBg: '#ffffff',
propBorder: '#dee2e6',
propShadow: 'rgba(0,0,0,0.08)',
zoomBg: '#ffffff',
zoomBorder: '#dee2e6',
zoomShadow: 'rgba(0,0,0,0.08)',
text: '#212529',
textMuted: '#868e96',
danger: '#fa5252',
success: '#40c057',
};
exports.PALETTE = [
'#212529', '#495057', '#868e96',
'#fa5252', '#e64980', '#be4bdb',
'#7950f2', '#4c6ef5', '#228be6',
'#15aabf', '#12b886', '#40c057',
'#fab005', '#fd7e14',
];
exports.FILL_PALETTE = [
'transparent',
'#fff3bf', '#d3f9d8', '#d0ebff', '#e5dbff',
'#ffe3e3', '#fce4ec', '#ffe8cc',
];
exports.ZOOM_MIN = 0.25;
exports.ZOOM_MAX = 4.0;
exports.ZOOM_STEP = 1.1;
exports.HIT_TOLERANCE = 6; // px for stroke hit-testing
exports.HANDLE_SIZE = 8; // px (screen space)
exports.GRID_SPACING = 24; // px (world space)
exports.NUDGE_STEP = 1; // px (world)
exports.NUDGE_STEP_SHIFT = 10; // px (world)
exports.DEFAULT_STROKE_COLOR = '#212529';
exports.DEFAULT_FILL_COLOR = 'transparent';
exports.DEFAULT_STROKE_WIDTH = 2;
exports.DEFAULT_FONT_SIZE = 20;
exports.MIN_SHAPE_SIZE = 2;
exports.STORAGE_KEY = 'whiteboard-state-v1';
exports.TEXT_INPUT_DELAY = 150; // ms debounce for text commits
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// ── Type Definitions ──────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
};
// ── module: src/state.ts ──
__mods["src/state.ts"] = function (exports, require, module) {
"use strict";
// ── Global State ──────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.initState = initState;
exports.getState = getState;
exports.setState = setState;
exports.setViewport = setViewport;
exports.setCurrentTool = setCurrentTool;
exports.setSelectedIds = setSelectedIds;
exports.getDefaultStyle = getDefaultStyle;
exports.setDefaultStyle = setDefaultStyle;
exports.nextId = nextId;
const constants_1 = require("./constants");
let state;
function createInitialState() {
return {
shapes: [],
viewport: { x: 0, y: 0, zoom: 1 },
currentTool: 'select',
selectedIds: [],
defaultStyle: {
strokeColor: constants_1.DEFAULT_STROKE_COLOR,
fillColor: constants_1.DEFAULT_FILL_COLOR,
strokeWidth: constants_1.DEFAULT_STROKE_WIDTH,
fontSize: constants_1.DEFAULT_FONT_SIZE,
},
isPanning: false,
dragState: null,
spaceDown: false,
};
}
function initState() {
state = createInitialState();
return state;
}
function getState() {
return state;
}
function setState(updater) {
updater(state);
}
function setViewport(vp) {
state.viewport = { ...vp };
}
function setCurrentTool(tool) {
state.currentTool = tool;
if (tool !== 'select') {
state.selectedIds = [];
}
}
function setSelectedIds(ids) {
state.selectedIds = ids;
}
function getDefaultStyle() {
return { ...state.defaultStyle };
}
function setDefaultStyle(style) {
state.defaultStyle = { ...state.defaultStyle, ...style };
}
// Generate a unique ID
let _idCounter = 0;
function nextId() {
return 's' + (++_idCounter).toString(36) + Date.now().toString(36);
}
};
// ── module: src/viewport.ts ──
__mods["src/viewport.ts"] = function (exports, require, module) {
"use strict";
// ── Viewport Math ─────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.screenToWorld = screenToWorld;
exports.worldToScreen = worldToScreen;
exports.zoomAtScreen = zoomAtScreen;
exports.resetView = resetView;
exports.panWorld = panWorld;
exports.panScreen = panScreen;
const constants_1 = require("./constants");
/** Convert screen (canvas pixel) → world coordinates */
function screenToWorld(sx, sy, vp) {
return [sx / vp.zoom - vp.x, sy / vp.zoom - vp.y];
}
/** Convert world → screen (canvas pixel) coordinates */
function worldToScreen(wx, wy, vp) {
return [(wx + vp.x) * vp.zoom, (wy + vp.y) * vp.zoom];
}
/** Zoom toward a screen point, clamping the zoom level */
function zoomAtScreen(sx, sy, vp, factor) {
const worldBefore = screenToWorld(sx, sy, vp);
let newZoom = vp.zoom * factor;
newZoom = Math.max(constants_1.ZOOM_MIN, Math.min(constants_1.ZOOM_MAX, newZoom));
// Keep the world point under the cursor
const newX = sx / newZoom - worldBefore[0];
const newY = sy / newZoom - worldBefore[1];
return { x: newX, y: newY, zoom: newZoom };
}
/** Reset view to origin at zoom 1 */
function resetView() {
return { x: 0, y: 0, zoom: 1 };
}
/** Pan the viewport by delta in world coordinates */
function panWorld(vp, dx, dy) {
return { x: vp.x - dx, y: vp.y - dy, zoom: vp.zoom };
}
/** Pan the viewport by delta in screen coordinates */
function panScreen(vp, dx, dy) {
return { x: vp.x - dx / vp.zoom, y: vp.y - dy / vp.zoom, zoom: vp.zoom };
}
};
// ── module: src/scene.ts ──
__mods["src/scene.ts"] = function (exports, require, module) {
"use strict";
// ── Scene Management ──────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.getShapes = getShapes;
exports.addShape = addShape;
exports.removeShape = removeShape;
exports.removeShapes = removeShapes;
exports.getShape = getShape;
exports.updateShape = updateShape;
exports.createRectangle = createRectangle;
exports.createEllipse = createEllipse;
exports.createLine = createLine;
exports.createFreehand = createFreehand;
exports.createText = createText;
exports.getShapeBounds = getShapeBounds;
exports.getSelectionBounds = getSelectionBounds;
const state_1 = require("./state");
const constants_1 = require("./constants");
function getShapes() {
return (0, state_1.getState)().shapes;
}
function addShape(shape) {
(0, state_1.setState)(s => s.shapes.push(shape));
}
function removeShape(id) {
(0, state_1.setState)(s => { s.shapes = s.shapes.filter(sh => sh.id !== id); });
}
function removeShapes(ids) {
(0, state_1.setState)(s => { s.shapes = s.shapes.filter(sh => !ids.includes(sh.id)); });
}
function getShape(id) {
return (0, state_1.getState)().shapes.find(sh => sh.id === id);
}
function updateShape(id, updater) {
(0, state_1.setState)(s => {
const idx = s.shapes.findIndex(sh => sh.id === id);
if (idx >= 0)
s.shapes[idx] = updater(s.shapes[idx]);
});
}
function createRectangle(x, y, w, h) {
const s = (0, state_1.getState)().defaultStyle;
return {
id: (0, state_1.nextId)(), type: 'rectangle',
x, y, width: Math.abs(w), height: Math.abs(h),
strokeColor: s.strokeColor, fillColor: s.fillColor, strokeWidth: s.strokeWidth,
};
}
function createEllipse(x, y, w, h) {
const s = (0, state_1.getState)().defaultStyle;
return {
id: (0, state_1.nextId)(), type: 'ellipse',
x, y, width: Math.abs(w), height: Math.abs(h),
strokeColor: s.strokeColor, fillColor: s.fillColor, strokeWidth: s.strokeWidth,
};
}
function createLine(x1, y1, x2, y2, arrow = false) {
const s = (0, state_1.getState)().defaultStyle;
return {
id: (0, state_1.nextId)(), type: arrow ? 'arrow' : 'line',
x1, y1, x2, y2,
strokeColor: s.strokeColor, strokeWidth: s.strokeWidth,
};
}
function createFreehand(points) {
const s = (0, state_1.getState)().defaultStyle;
return {
id: (0, state_1.nextId)(), type: 'freehand',
points, strokeColor: s.strokeColor, strokeWidth: s.strokeWidth,
};
}
function createText(x, y) {
const s = (0, state_1.getState)().defaultStyle;
return {
id: (0, state_1.nextId)(), type: 'text',
x, y, text: '', fontSize: s.fontSize ?? constants_1.DEFAULT_FONT_SIZE,
strokeColor: s.strokeColor, fillColor: s.fillColor,
width: 200, height: 40,
};
}
function getShapeBounds(shape) {
switch (shape.type) {
case 'rectangle':
case 'ellipse':
return { x: shape.x, y: shape.y, w: shape.width, h: shape.height };
case 'line':
case 'arrow': {
const minX = Math.min(shape.x1, shape.x2);
const minY = Math.min(shape.y1, shape.y2);
return { x: minX, y: minY, w: Math.abs(shape.x2 - shape.x1), h: Math.abs(shape.y2 - shape.y1) };
}
case 'freehand': {
if (!shape.points.length)
return { x: 0, y: 0, w: 0, h: 0 };
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const [px, py] of shape.points) {
if (px < minX)
minX = px;
if (py < minY)
minY = py;
if (px > maxX)
maxX = px;
if (py > maxY)
maxY = py;
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
case 'text':
return { x: shape.x, y: shape.y, w: shape.width, h: shape.height };
}
}
function getSelectionBounds(ids) {
const shapes = ids.map(id => getShape(id)).filter(Boolean);
if (!shapes.length)
return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const sh of shapes) {
const b = getShapeBounds(sh);
if (b.x < minX)
minX = b.x;
if (b.y < minY)
minY = b.y;
if (b.x + b.w > maxX)
maxX = b.x + b.w;
if (b.y + b.h > maxY)
maxY = b.y + b.h;
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
};
// ── module: src/hitTest.ts ──
__mods["src/hitTest.ts"] = function (exports, require, module) {
"use strict";
// ── Hit Testing ───────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.hitTestShape = hitTestShape;
exports.findHitShape = findHitShape;
exports.findShapesInRect = findShapesInRect;
exports.hitTestHandle = hitTestHandle;
const constants_1 = require("./constants");
/**
* Distance from point (px, py) to line segment (x1,y1)-(x2,y2).
*/
function pointToSegmentDist(px, py, x1, y1, x2, y2) {
const dx = x2 - x1, dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0)
return Math.hypot(px - x1, py - y1);
let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
const projX = x1 + t * dx, projY = y1 + t * dy;
return Math.hypot(px - projX, py - projY);
}
/**
* Check if point is inside a rectangle (including border with tolerance).
*/
function hitTestRectangle(wx, wy, shape) {
const hw = shape.strokeWidth / 2 + constants_1.HIT_TOLERANCE;
// Check border
if (wx >= shape.x - hw && wx <= shape.x + shape.width + hw &&
wy >= shape.y - hw && wy <= shape.y + shape.height + hw) {
// Inside the expanded rect
// Check if within the actual shape area (filled or border)
const inside = wx >= shape.x && wx <= shape.x + shape.width &&
wy >= shape.y && wy <= shape.y + shape.height;
if (inside)
return true;
// Check near border
const nearLeft = Math.abs(wx - shape.x) <= hw;
const nearRight = Math.abs(wx - (shape.x + shape.width)) <= hw;
const nearTop = Math.abs(wy - shape.y) <= hw;
const nearBottom = Math.abs(wy - (shape.y + shape.height)) <= hw;
if (nearLeft || nearRight || nearTop || nearBottom)
return true;
}
return false;
}
/**
* Check if point is inside an ellipse (including border with tolerance).
* Uses the ellipse equation: ((x-h)/a)^2 + ((y-k)/b)^2 <= 1
*/
function hitTestEllipse(wx, wy, shape) {
const cx = shape.x + shape.width / 2;
const cy = shape.y + shape.height / 2;
const rx = Math.abs(shape.width) / 2;
const ry = Math.abs(shape.height) / 2;
if (rx === 0 || ry === 0)
return false;
// Normalized distance from center
const dx = (wx - cx) / (rx + constants_1.HIT_TOLERANCE);
const dy = (wy - cy) / (ry + constants_1.HIT_TOLERANCE);
return dx * dx + dy * dy <= 1;
}
/**
* Check if point is near a line/arrow (within stroke width + tolerance).
*/
function hitTestLine(wx, wy, shape) {
const tolerance = shape.strokeWidth / 2 + constants_1.HIT_TOLERANCE;
return pointToSegmentDist(wx, wy, shape.x1, shape.y1, shape.x2, shape.y2) <= tolerance;
}
/**
* Check if point is near a freehand stroke (within stroke width + tolerance).
* Tests against each segment.
*/
function hitTestFreehand(wx, wy, shape) {
const tolerance = shape.strokeWidth / 2 + constants_1.HIT_TOLERANCE;
for (let i = 0; i < shape.points.length - 1; i++) {
const [x1, y1] = shape.points[i];
const [x2, y2] = shape.points[i + 1];
if (pointToSegmentDist(wx, wy, x1, y1, x2, y2) <= tolerance)
return true;
}
// Also check endpoints
if (shape.points.length > 0) {
const [lx, ly] = shape.points[shape.points.length - 1];
if (Math.hypot(wx - lx, wy - ly) <= tolerance)
return true;
const [fx, fy] = shape.points[0];
if (Math.hypot(wx - fx, wy - fy) <= tolerance)
return true;
}
return false;
}
/**
* Check if point is inside a text shape.
*/
function hitTestText(wx, wy, shape) {
return wx >= shape.x && wx <= shape.x + shape.width &&
wy >= shape.y && wy <= shape.y + shape.height;
}
/**
* Test a single shape for a hit at world coordinates.
*/
function hitTestShape(wx, wy, shape) {
switch (shape.type) {
case 'rectangle': return hitTestRectangle(wx, wy, shape);
case 'ellipse': return hitTestEllipse(wx, wy, shape);
case 'line':
case 'arrow': return hitTestLine(wx, wy, shape);
case 'freehand': return hitTestFreehand(wx, wy, shape);
case 'text': return hitTestText(wx, wy, shape);
}
}
/**
* Find the topmost shape hit at world coordinates.
* Tests shapes in reverse order (topmost rendered last).
*/
function findHitShape(shapes, wx, wy) {
for (let i = shapes.length - 1; i >= 0; i--) {
if (hitTestShape(wx, wy, shapes[i]))
return shapes[i];
}
return null;
}
/**
* Find all shapes whose bounding boxes intersect a rectangle.
*/
function findShapesInRect(shapes, rx, ry, rw, rh) {
const ids = [];
const left = Math.min(rx, rx + rw);
const top = Math.min(ry, ry + rh);
const right = Math.max(rx, rx + rw);
const bottom = Math.max(ry, ry + rh);
for (const sh of shapes) {
const b = getBounds(sh);
if (b.x < right && b.x + b.w > left && b.y < bottom && b.y + b.h > top) {
ids.push(sh.id);
}
}
return ids;
}
function getBounds(sh) {
switch (sh.type) {
case 'rectangle':
case 'ellipse':
return { x: sh.x, y: sh.y, w: sh.width, h: sh.height };
case 'line':
case 'arrow':
return {
x: Math.min(sh.x1, sh.x2), y: Math.min(sh.y1, sh.y2),
w: Math.abs(sh.x2 - sh.x1), h: Math.abs(sh.y2 - sh.y1),
};
case 'freehand': {
if (!sh.points.length)
return { x: 0, y: 0, w: 0, h: 0 };
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const [px, py] of sh.points) {
if (px < minX)
minX = px;
if (py < minY)
minY = py;
if (px > maxX)
maxX = px;
if (py > maxY)
maxY = py;
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
case 'text':
return { x: sh.x, y: sh.y, w: sh.width, h: sh.height };
}
}
/**
* Check if a world point is within a resize handle.
* Handles are positioned at the 4 corners of the selection bounding box.
*/
function hitTestHandle(wx, wy, bx, by, bw, bh, handleSize) {
// 4 corners: 0=TL, 1=TR, 2=BR, 3=BL
const handles = [
[bx, by],
[bx + bw, by],
[bx + bw, by + bh],
[bx, by + bh],
];
const half = handleSize / 2;
for (let i = 0; i < handles.length; i++) {
const [hx, hy] = handles[i];
if (wx >= hx - half && wx <= hx + half && wy >= hy - half && wy <= hy + half)
return i;
}
return null;
}
};
// ── module: src/history.ts ──
__mods["src/history.ts"] = function (exports, require, module) {
"use strict";
// ── Undo / Redo History ───────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.getHistoryState = getHistoryState;
exports.recordCreate = recordCreate;
exports.recordDelete = recordDelete;
exports.beginOp = beginOp;
exports.commitOp = commitOp;
exports.undo = undo;
exports.redo = redo;
exports.clearHistory = clearHistory;
const scene_1 = require("./scene");
let history = [];
let historyIndex = -1;
const MAX_HISTORY = 200;
function getHistoryState() {
return { length: history.length, index: historyIndex };
}
function pushEntry(entry) {
if (historyIndex < history.length - 1) {
history = history.slice(0, historyIndex + 1);
}
history.push(entry);
if (history.length > MAX_HISTORY)
history.shift();
historyIndex = history.length - 1;
}
function snapshot(ids) {
const m = new Map();
for (const id of ids)
m.set(id, (0, scene_1.getShape)(id));
return m;
}
/** Record a create. */
function recordCreate(shape) {
const before = new Map();
before.set(shape.id, undefined);
pushEntry({ label: 'Create ' + shape.type, action: 'create', before, after: snapshot([shape.id]) });
}
/** Record a delete. */
function recordDelete(ids) {
const before = snapshot(ids);
for (const id of ids)
(0, scene_1.removeShape)(id);
pushEntry({ label: 'Delete', action: 'delete', before, after: snapshot(ids) });
}
/**
* Record a multi-step operation (move, resize, nudge, style).
* Call beginOp(ids, label) before the operation, then commitOp() after.
*/
let _pendingOp = null;
function beginOp(ids, label, action) {
_pendingOp = { ids, label, action, before: snapshot(ids) };
}
function commitOp() {
if (!_pendingOp)
return;
const { ids, label, action, before } = _pendingOp;
pushEntry({ label, action, before, after: snapshot(ids) });
_pendingOp = null;
}
/** Undo. */
function undo() {
if (historyIndex < 0)
return false;
const entry = history[historyIndex];
for (const [id, shape] of entry.before) {
if (shape === undefined)
(0, scene_1.removeShape)(id);
else {
const existing = (0, scene_1.getShape)(id);
if (existing)
(0, scene_1.updateShape)(id, () => shape);
else
(0, scene_1.addShape)(shape);
}
}
historyIndex--;
return true;
}
/** Redo. */
function redo() {
if (historyIndex >= history.length - 1)
return false;
historyIndex++;
const entry = history[historyIndex];
for (const [id, shape] of entry.after) {
if (shape === undefined)
(0, scene_1.removeShape)(id);
else {
const existing = (0, scene_1.getShape)(id);
if (existing)
(0, scene_1.updateShape)(id, () => shape);
else
(0, scene_1.addShape)(shape);
}
}
return true;
}
function clearHistory() {
history = [];
historyIndex = -1;
}
};
// ── module: src/render.ts ──
__mods["src/render.ts"] = function (exports, require, module) {
"use strict";
// ── Canvas Rendering ──────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.initRender = initRender;
exports.resizeCanvas = resizeCanvas;
exports.render = render;
const viewport_1 = require("./viewport");
const scene_1 = require("./scene");
const state_1 = require("./state");
const constants_1 = require("./constants");
let canvas;
let ctx;
function initRender(c) {
canvas = c;
ctx = c.getContext('2d');
}
function resizeCanvas(w, h) {
canvas.width = w * devicePixelRatio;
canvas.height = h * devicePixelRatio;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
}
function render() {
const s = (0, state_1.getState)();
const vp = s.viewport;
const w = canvas.width / devicePixelRatio;
const h = canvas.height / devicePixelRatio;
ctx.clearRect(0, 0, w, h);
// Background
ctx.fillStyle = constants_1.COLORS.canvas;
ctx.fillRect(0, 0, w, h);
// Grid
drawGrid(vp, w, h);
// Apply viewport transform with high-quality rendering
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.translate(vp.x * vp.zoom, vp.y * vp.zoom);
ctx.scale(vp.zoom, vp.zoom);
// Draw shapes
for (const shape of (0, scene_1.getShapes)()) {
drawShape(shape, s.selectedIds.includes(shape.id));
}
ctx.restore();
// Draw selection overlay (handles in screen space)
drawSelectionOverlay(s.selectedIds, vp);
// Draw drag preview
if (s.dragState) {
drawDragPreview(s.dragState, vp);
}
// Draw rubberband rect
if (s.dragState && s.dragState.type === 'rubberband' && s.dragState.rubberbandRect) {
drawRubberband(s.dragState.rubberbandRect, vp);
}
}
function drawGrid(vp, w, h) {
const gridSize = constants_1.GRID_SPACING * vp.zoom;
if (gridSize < 3)
return; // Too small
const startX = ((-vp.x * vp.zoom) % gridSize + gridSize) % gridSize;
const startY = ((-vp.y * vp.zoom) % gridSize + gridSize) % gridSize;
const dotSize = Math.max(0.5, Math.min(2, gridSize * 0.06));
const alpha = Math.max(0.15, Math.min(0.5, 0.15 + gridSize * 0.003));
ctx.fillStyle = `rgba(0,0,0,${alpha})`;
for (let x = startX; x < w; x += gridSize) {
for (let y = startY; y < h; y += gridSize) {
ctx.beginPath();
ctx.arc(x, y, dotSize, 0, Math.PI * 2);
ctx.fill();
}
}
}
function drawShape(shape, selected) {
const sel = selected;
switch (shape.type) {
case 'rectangle':
drawRectangle(shape, sel);
break;
case 'ellipse':
drawEllipse(shape, sel);
break;
case 'line':
drawLine(shape, sel);
break;
case 'arrow':
drawArrow(shape, sel);
break;
case 'freehand':
drawFreehand(shape, sel);
break;
case 'text':
drawText(shape, sel);
break;
}
}
function drawRectangle(shape, selected) {
const s = shape;
ctx.save();
if (s.fillColor && s.fillColor !== 'transparent') {
ctx.fillStyle = s.fillColor;
ctx.fillRect(s.x, s.y, s.width, s.height);
}
ctx.strokeStyle = s.strokeColor;
ctx.lineWidth = s.strokeWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeRect(s.x, s.y, s.width, s.height);
if (selected) {
ctx.strokeStyle = constants_1.COLORS.selection;
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 3]);
ctx.strokeRect(s.x - 2, s.y - 2, s.width + 4, s.height + 4);
ctx.setLineDash([]);
}
ctx.restore();
}
function drawEllipse(shape, selected) {
const s = shape;
ctx.save();
const cx = s.x + s.width / 2;
const cy = s.y + s.height / 2;
const rx = Math.abs(s.width) / 2;
const ry = Math.abs(s.height) / 2;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
if (s.fillColor && s.fillColor !== 'transparent') {
ctx.fillStyle = s.fillColor;
ctx.fill();
}
ctx.strokeStyle = s.strokeColor;
ctx.lineWidth = s.strokeWidth;
ctx.lineCap = 'round';
ctx.stroke();
if (selected) {
ctx.strokeStyle = constants_1.COLORS.selection;
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 3]);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.restore();
}
function drawLine(shape, selected) {
const s = shape;
ctx.save();
ctx.strokeStyle = s.strokeColor;
ctx.lineWidth = s.strokeWidth;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(s.x1, s.y1);
ctx.lineTo(s.x2, s.y2);
ctx.stroke();
ctx.restore();
}
function drawArrow(shape, selected) {
const s = shape;
ctx.save();
ctx.strokeStyle = s.strokeColor;
ctx.fillStyle = s.strokeColor;
ctx.lineWidth = s.strokeWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Line
ctx.beginPath();
ctx.moveTo(s.x1, s.y1);
ctx.lineTo(s.x2, s.y2);
ctx.stroke();
// Arrowhead
const angle = Math.atan2(s.y2 - s.y1, s.x2 - s.x1);
const headLen = Math.max(12, s.strokeWidth * 4);
const headAngle = Math.PI / 7;
ctx.beginPath();
ctx.moveTo(s.x2, s.y2);
ctx.lineTo(s.x2 - headLen * Math.cos(angle - headAngle), s.y2 - headLen * Math.sin(angle - headAngle));
ctx.lineTo(s.x2 - headLen * Math.cos(angle + headAngle), s.y2 - headLen * Math.sin(angle + headAngle));
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawFreehand(shape, selected) {
const s = shape;
if (s.points.length < 2)
return;
ctx.save();
ctx.strokeStyle = s.strokeColor;
ctx.lineWidth = s.strokeWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Smooth the path using quadratic curves
ctx.beginPath();
ctx.moveTo(s.points[0][0], s.points[0][1]);
if (s.points.length === 2) {
ctx.lineTo(s.points[1][0], s.points[1][1]);
}
else {
// Quadratic bezier through midpoints
for (let i = 1; i < s.points.length - 1; i++) {
const mx = (s.points[i][0] + s.points[i + 1][0]) / 2;
const my = (s.points[i][1] + s.points[i + 1][1]) / 2;
ctx.quadraticCurveTo(s.points[i][0], s.points[i][1], mx, my);
}
// Last point
const last = s.points[s.points.length - 1];
ctx.lineTo(last[0], last[1]);
}
ctx.stroke();
ctx.restore();
}
function drawText(shape, selected) {
const s = shape;
ctx.save();
ctx.font = `${s.fontSize}px "Inter", "Segoe UI", system-ui, sans-serif`;
ctx.fillStyle = s.strokeColor;
ctx.textBaseline = 'top';
// Word wrap
const maxWidth = s.width;
const lines = wrapText(ctx, s.text || 'Type here...', maxWidth);
const lineHeight = s.fontSize * 1.3;
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], s.x + 4, s.y + 4 + i * lineHeight);
}
if (selected) {
ctx.strokeStyle = constants_1.COLORS.selection;
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 3]);
ctx.strokeRect(s.x - 2, s.y - 2, s.width + 4, s.height + 4);
ctx.setLineDash([]);
}
ctx.restore();
}
function wrapText(c, text, maxWidth) {
if (!text)
return [''];
const words = text.split(' ');
const lines = [];
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? currentLine + ' ' + word : word;
const metrics = c.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = word;
}
else {
currentLine = testLine;
}
}
if (currentLine)
lines.push(currentLine);
return lines.length ? lines : [''];
}
function drawSelectionOverlay(ids, vp) {
if (!ids.length)
return;
const bounds = (0, scene_1.getSelectionBounds)(ids);
if (!bounds)
return;
const [sx, sy] = (0, viewport_1.worldToScreen)(bounds.x, bounds.y, vp);
const [sw, sh] = [bounds.w * vp.zoom, bounds.h * vp.zoom];
const hs = constants_1.HANDLE_SIZE;
// Selection box
ctx.strokeStyle = constants_1.COLORS.selection;
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 3]);
ctx.strokeRect(sx - 2, sy - 2, sw + 4, sh + 4);
ctx.setLineDash([]);
// Handles
const handles = [
[sx, sy],
[sx + sw, sy],
[sx + sw, sy + sh],
[sx, sy + sh],
];
for (const [hx, hy] of handles) {
ctx.fillStyle = constants_1.COLORS.handle;
ctx.strokeStyle = constants_1.COLORS.handleStroke;
ctx.lineWidth = 1.5;
ctx.fillRect(hx - hs / 2, hy - hs / 2, hs, hs);
ctx.strokeRect(hx - hs / 2, hy - hs / 2, hs, hs);
}
}
function drawDragPreview(drag, vp) {
ctx.save();
ctx.translate(vp.x * vp.zoom, vp.y * vp.zoom);
ctx.scale(vp.zoom, vp.zoom);
ctx.strokeStyle = constants_1.COLORS.preview;
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
const dx = drag.startWorldX;
const dy = drag.startWorldY;
const endWorld = (0, viewport_1.screenToWorld)(drag._currentScreenX ?? drag.startX, drag._currentScreenY ?? drag.startY, vp);
if (drag.type === 'create') {
const w = endWorld[0] - dx;
const h = endWorld[1] - dy;
ctx.strokeRect(dx, dy, w, h);
}
else if (drag.type === 'draw' && drag.drawPoints && drag.drawPoints.length > 1) {
ctx.strokeStyle = (0, state_1.getState)().defaultStyle.strokeColor;
ctx.lineWidth = (0, state_1.getState)().defaultStyle.strokeWidth;
ctx.setLineDash([]);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(drag.drawPoints[0][0], drag.drawPoints[0][1]);
for (let i = 1; i < drag.drawPoints.length; i++) {
ctx.lineTo(drag.drawPoints[i][0], drag.drawPoints[i][1]);
}
ctx.stroke();
}
ctx.restore();
}
function drawRubberband(rect, vp) {
const [sx, sy] = (0, viewport_1.worldToScreen)(rect.x, rect.y, vp);
const sw = rect.w * vp.zoom;
const sh = rect.h * vp.zoom;
ctx.strokeStyle = constants_1.COLORS.selection;
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.strokeRect(sx, sy, sw, sh);
ctx.setLineDash([]);
ctx.fillStyle = constants_1.COLORS.selectionFill;
ctx.fillRect(sx, sy, sw, sh);
}
};
// ── module: src/persistence.ts ──
__mods["src/persistence.ts"] = function (exports, require, module) {
"use strict";
// ── localStorage Persistence ──────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.saveToStorage = saveToStorage;
exports.loadFromStorage = loadFromStorage;
exports.clearStorage = clearStorage;
const constants_1 = require("./constants");
const scene_1 = require("./scene");
const state_1 = require("./state");
const history_1 = require("./history");
function saveToStorage() {
try {
const shapes = (0, scene_1.getShapes)();
const data = JSON.stringify(shapes);
localStorage.setItem(constants_1.STORAGE_KEY, data);
}
catch {
// Storage full or unavailable — silently ignore
}
}
function loadFromStorage() {
try {
const data = localStorage.getItem(constants_1.STORAGE_KEY);
if (!data)
return false;
const shapes = JSON.parse(data);
if (!Array.isArray(shapes))
return false;
// Clear and restore
(0, state_1.setState)(s => { s.shapes = []; });
for (const shape of shapes) {
(0, scene_1.addShape)(shape);
}
return true;
}
catch {
return false;
}
}
function clearStorage() {
localStorage.removeItem(constants_1.STORAGE_KEY);
(0, state_1.setState)(s => { s.shapes = []; });
(0, history_1.clearHistory)();
}
};
// ── module: src/tools.ts ──
__mods["src/tools.ts"] = function (exports, require, module) {
"use strict";
// ── Tool Logic ────────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.selectShapeAt = selectShapeAt;
exports.deselectAll = deselectAll;
exports.deleteSelection = deleteSelection;
exports.nudgeSelection = nudgeSelection;
exports.resizeSelected = resizeSelected;
exports.finishCreateShape = finishCreateShape;
exports.finishFreehand = finishFreehand;
exports.finishText = finishText;
exports.eraseAt = eraseAt;
exports.applyStyleToSelection = applyStyleToSelection;
const state_1 = require("./state");
const scene_1 = require("./scene");
const hitTest_1 = require("./hitTest");
const history_1 = require("./history");
const constants_1 = require("./constants");
const persistence_1 = require("./persistence");
// ─── Selection ────────────────────────────────────────────────────────────────
function selectShapeAt(wx, wy, addMode) {
const hit = (0, hitTest_1.findHitShape)((0, scene_1.getShapes)(), wx, wy);
if (hit) {
(0, state_1.setState)(s => {
if (addMode) {
if (!s.selectedIds.includes(hit.id))
s.selectedIds.push(hit.id);
}
else
s.selectedIds = [hit.id];
});
syncStyleFromSelection();
}
else if (!addMode) {
(0, state_1.setSelectedIds)([]);
}
}
function deselectAll() { (0, state_1.setSelectedIds)([]); }
function deleteSelection() {
const s = (0, state_1.getState)();
if (!s.selectedIds.length)
return;
(0, history_1.recordDelete)([...s.selectedIds]);
(0, state_1.setSelectedIds)([]);
(0, persistence_1.saveToStorage)();
}
function nudgeSelection(dx, dy) {
const s = (0, state_1.getState)();
if (!s.selectedIds.length)
return;
const ids = [...s.selectedIds];
(0, history_1.beginOp)(ids, 'Nudge', 'move');
for (const id of ids) {
const sh = (0, scene_1.getShape)(id);
if (!sh)
continue;
(0, scene_1.updateShape)(id, (shape) => {
const n = { ...shape };
switch (n.type) {
case 'rectangle':
case 'ellipse':
case 'text':
n.x += dx;
n.y += dy;
break;
case 'line':
case 'arrow':
n.x1 += dx;
n.y1 += dy;
n.x2 += dx;
n.y2 += dy;
break;
case 'freehand':
n.points = n.points.map((p) => [p[0] + dx, p[1] + dy]);
break;
}
return n;
});
}
(0, history_1.commitOp)();
(0, persistence_1.saveToStorage)();
}
function syncStyleFromSelection() {
const s = (0, state_1.getState)();
if (s.selectedIds.length === 1) {
const sh = (0, scene_1.getShape)(s.selectedIds[0]);
if (sh) {
const style = (0, state_1.getDefaultStyle)();
if ('strokeColor' in sh)
style.strokeColor = sh.strokeColor;
if ('fillColor' in sh)
style.fillColor = sh.fillColor;
if ('strokeWidth' in sh)
style.strokeWidth = sh.strokeWidth;
(0, state_1.setDefaultStyle)(style);
}
}
}
// ─── Resize ───────────────────────────────────────────────────────────────────
function resizeSelected(handle, toWorldX, toWorldY, shiftKey) {
const s = (0, state_1.getState)();
if (!s.selectedIds.length)
return;
const bounds = (0, scene_1.getSelectionBounds)(s.selectedIds);
if (!bounds)
return;
const ids = [...s.selectedIds];
let newW = bounds.w, newH = bounds.h, newX = bounds.x, newY = bounds.y;
switch (handle) {
case 0:
newW = bounds.x + bounds.w - toWorldX;
newH = bounds.y + bounds.h - toWorldY;
newX = toWorldX;
newY = toWorldY;
break;
case 1:
newW = toWorldX - bounds.x;
newH = bounds.y + bounds.h - toWorldY;
newY = toWorldY;
break;
case 2:
newW = toWorldX - bounds.x;
newH = toWorldY - bounds.y;
break;
case 3:
newW = bounds.x + bounds.w - toWorldX;
newH = toWorldY - bounds.y;
newX = toWorldX;
break;
}
if (shiftKey) {
const aspect = bounds.w / Math.max(bounds.h, 0.001);
if (Math.abs(newW) > Math.abs(newH))
newH = newW / aspect;
else
newW = newH * aspect;
}
const dx = newX - bounds.x, dy = newY - bounds.y, dw = newW - bounds.w, dh = newH - bounds.h;
for (const id of ids) {
const sh = (0, scene_1.getShape)(id);
if (!sh)
continue;
const b = (0, scene_1.getShapeBounds)(sh);
const scaleW = b.w > 0 ? dw / b.w : 0, scaleH = b.h > 0 ? dh / b.h : 0;
(0, scene_1.updateShape)(id, (shape) => {
const n = { ...shape };
switch (n.type) {
case 'rectangle':
case 'ellipse':
n.x += dx;
n.y += dy;
n.width += dw;
n.height += dh;
break;
case 'line':
case 'arrow':
n.x1 += dx;
n.y1 += dy;
n.x2 += dx + dw;
n.y2 += dy + dh;
break;
case 'freehand':
n.points = n.points.map((p) => [p[0] + dx + (p[0] - b.x) * scaleW, p[1] + dy + (p[1] - b.y) * scaleH]);
break;
case 'text':
n.x += dx;
n.y += dy;
n.width = Math.max(50, n.width + dw);
n.height = Math.max(30, n.height + dh);
break;
}
return n;
});
}
}
// ─── Shape Creation ───────────────────────────────────────────────────────────
function finishCreateShape(tool, endX, endY, startX, startY) {
const dx = endX - startX, dy = endY - startY;
if (Math.abs(dx) < constants_1.MIN_SHAPE_SIZE && Math.abs(dy) < constants_1.MIN_SHAPE_SIZE)
return;
let shape;
switch (tool) {
case 'rectangle':
shape = (0, scene_1.createRectangle)(startX, startY, dx, dy);
break;
case 'ellipse':
shape = (0, scene_1.createEllipse)(startX, startY, dx, dy);
break;
case 'line':
shape = (0, scene_1.createLine)(startX, startY, endX, endY, false);
break;
case 'arrow':
shape = (0, scene_1.createLine)(startX, startY, endX, endY, true);
break;
default: return;
}
(0, scene_1.addShape)(shape);
(0, history_1.recordCreate)(shape);
(0, state_1.setSelectedIds)([shape.id]);
(0, persistence_1.saveToStorage)();
}
function finishFreehand(points) {
if (points.length < 2)
return;
const shape = (0, scene_1.createFreehand)(points);
(0, scene_1.addShape)(shape);
(0, history_1.recordCreate)(shape);
(0, state_1.setSelectedIds)([shape.id]);
(0, persistence_1.saveToStorage)();
}
function finishText(text, x, y) {
if (!text.trim())
return;
const s = (0, state_1.getDefaultStyle)();
const shape = (0, scene_1.createText)(x, y);
shape.text = text;
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
ctx.font = `${s.fontSize ?? 20}px "Inter", "Segoe UI", system-ui, sans-serif`;
const lines = text.split('\n');
let maxW = 0;
for (const line of lines) {
const m = ctx.measureText(line);
if (m.width > maxW)
maxW = m.width;
}
shape.width = Math.max(100, maxW + 20);
shape.height = Math.max(30, lines.length * (s.fontSize ?? 20) * 1.3 + 8);
(0, scene_1.addShape)(shape);
(0, history_1.recordCreate)(shape);
(0, state_1.setSelectedIds)([shape.id]);
(0, persistence_1.saveToStorage)();
}
// ─── Eraser ───────────────────────────────────────────────────────────────────
function eraseAt(wx, wy) {
const hit = (0, hitTest_1.findHitShape)((0, scene_1.getShapes)(), wx, wy);
if (hit) {
(0, history_1.recordDelete)([hit.id]);
(0, state_1.setState)(s => { s.selectedIds = s.selectedIds.filter(id => id !== hit.id); });
(0, persistence_1.saveToStorage)();
}
}
// ─── Style ────────────────────────────────────────────────────────────────────
function applyStyleToSelection(props) {
const s = (0, state_1.getState)();
if (!s.selectedIds.length) {
(0, state_1.setDefaultStyle)(props);
return;
}
const ids = [...s.selectedIds];
(0, history_1.beginOp)(ids, 'Restyle', 'modify');
for (const id of ids) {
const sh = (0, scene_1.getShape)(id);
if (!sh)
continue;
(0, scene_1.updateShape)(id, (shape) => {
const n = { ...shape };
if (props.strokeColor !== undefined && 'strokeColor' in n)
n.strokeColor = props.strokeColor;
if (props.fillColor !== undefined && 'fillColor' in n)
n.fillColor = props.fillColor;
if (props.strokeWidth !== undefined && 'strokeWidth' in n)
n.strokeWidth = props.strokeWidth;
return n;
});
}
(0, history_1.commitOp)();
(0, persistence_1.saveToStorage)();
}
};
// ── module: src/input.ts ──
__mods["src/input.ts"] = function (exports, require, module) {
"use strict";
// ── Input Handling ────────────────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.initInput = initInput;
exports.setRenderCallback = setRenderCallback;
exports.setToolbarCallback = setToolbarCallback;
exports.setZoomDisplayCallback = setZoomDisplayCallback;
exports.setHistoryCallback = setHistoryCallback;
exports.resetView = resetView;
const state_1 = require("./state");
const viewport_1 = require("./viewport");
const scene_1 = require("./scene");
const hitTest_1 = require("./hitTest");
const tools_1 = require("./tools");
const history_1 = require("./history");
const constants_1 = require("./constants");
const persistence_1 = require("./persistence");
let canvas = null;
let textOverlay = null;
function initInput(c, ta) {
canvas = c;
textOverlay = ta;
setupMouseEvents();
setupKeyboardEvents();
}
function getCanvas() { return canvas; }
function getMouseWorldPos(e) {
const r = getCanvas().getBoundingClientRect();
return (0, viewport_1.screenToWorld)(e.clientX - r.left, e.clientY - r.top, (0, state_1.getState)().viewport);
}
function getMouseScreenPos(e) {
const r = getCanvas().getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
function setupMouseEvents() {
const c = getCanvas();
c.addEventListener('mousedown', onMouseDown);
c.addEventListener('mousemove', onMouseMove);
c.addEventListener('mouseup', onMouseUp);
c.addEventListener('mouseleave', onMouseLeave);
c.addEventListener('wheel', onWheel, { passive: false });
c.addEventListener('contextmenu', e => e.preventDefault());
}
function onMouseDown(e) {
const s = (0, state_1.getState)();
const [wx, wy] = getMouseWorldPos(e);
const [sx, sy] = getMouseScreenPos(e);
if (e.button === 1 || (e.button === 0 && s.spaceDown)) {
e.preventDefault();
(0, state_1.setState)(st => { st.isPanning = true; st.dragState = { type: 'pan', startX: e.clientX, startY: e.clientY, startWorldX: 0, startWorldY: 0 }; });
return;
}
if (e.button !== 0)
return;
switch (s.currentTool) {
case 'select':
handleSelectDown(wx, wy, sx, sy, e);
break;
case 'pen':
handlePenDown(wx, wy, sx, sy);
break;
case 'rectangle':
case 'ellipse':
case 'line':
case 'arrow':
handleCreateDown(s.currentTool, wx, wy, sx, sy);
break;
case 'text':
handleTextDown(wx, wy, sx, sy);
break;
case 'eraser':
handleEraserDown(wx, wy, sx, sy);
break;
}
}
function onMouseMove(e) {
const s = (0, state_1.getState)();
const [wx, wy] = getMouseWorldPos(e);
const [sx, sy] = getMouseScreenPos(e);
updateEraserCursor(sx, sy);
if (!s.dragState)
return;
switch (s.dragState.type) {
case 'pan':
handlePanMove(e);
break;
case 'move':
handleMoveMove(wx, wy);
break;
case 'resize':
handleResizeMove(wx, wy, e);
break;
case 'create':
handleCreateMove(sx, sy);
break;
case 'draw':
handleDrawMove(wx, wy, sx, sy);
break;
case 'rubberband':
handleRubberbandMove(wx, wy, sx, sy);
break;
case 'erase':
handleEraserMove(wx, wy);
break;
}
}
function onMouseUp(e) {
const s = (0, state_1.getState)();
if (!s.dragState)
return;
const [wx, wy] = getMouseWorldPos(e);
switch (s.dragState.type) {
case 'pan':
handlePanUp();
break;
case 'move':
handleMoveUp();
break;
case 'resize':
handleResizeUp();
break;
case 'create':
handleCreateUp(s.currentTool, wx, wy);
break;
case 'draw':
handleDrawUp();
break;
case 'rubberband':
handleRubberbandUp();
break;
case 'erase':
handleEraserUp();
break;
}
(0, state_1.setState)(st => { st.dragState = null; });
}
function onMouseLeave(e) {
const s = (0, state_1.getState)();
if (s.dragState && s.dragState.type === 'pan')
handlePanUp();
(0, state_1.setState)(st => { st.dragState = null; });
}
function onWheel(e) {
e.preventDefault();
const [sx, sy] = getMouseScreenPos(e);
const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08;
(0, state_1.setViewport)((0, viewport_1.zoomAtScreen)(sx, sy, (0, state_1.getState)().viewport, factor));
onRender?.();
updateZoomDisplay?.();
}
// ─── Pan ──────────────────────────────────────────────────────────────────────
function handlePanMove(e) {
const s = (0, state_1.getState)();
(0, state_1.setViewport)((0, viewport_1.panScreen)(s.viewport, e.clientX - s.dragState.startX, e.clientY - s.dragState.startY));
onRender?.();
}
function handlePanUp() { (0, state_1.setState)(s => { s.isPanning = false; }); }
// ─── Select Tool ──────────────────────────────────────────────────────────────
function handleSelectDown(wx, wy, sx, sy, e) {
const s = (0, state_1.getState)();
// Check resize handles
if (s.selectedIds.length) {
const bounds = (0, scene_1.getSelectionBounds)(s.selectedIds);
if (bounds) {
const bsx = (bounds.x + s.viewport.x) * s.viewport.zoom;
const bsy = (bounds.y + s.viewport.y) * s.viewport.zoom;
const bsw = bounds.w * s.viewport.zoom, bsh = bounds.h * s.viewport.zoom;
const handle = (0, hitTest_1.hitTestHandle)(sx, sy, bsx, bsy, bsw, bsh, constants_1.HANDLE_SIZE);
if (handle !== null) {
const ids = [...s.selectedIds];
(0, history_1.beginOp)(ids, 'Resize', 'resize');
(0, state_1.setState)(st => {
st.dragState = {
type: 'resize', startX: sx, startY: sy,
startWorldX: bounds.x, startWorldY: bounds.y,
resizeHandle: handle, movedIds: ids,
movedFrom: new Map(ids.map(id => [id, (0, scene_1.getShape)(id)])),
};
});
return;
}
}
}
// Check shape click
const hit = (0, hitTest_1.findHitShape)((0, scene_1.getShapes)(), wx, wy);
if (hit) {
if (e.shiftKey)
(0, tools_1.selectShapeAt)(wx, wy, true);
else
(0, state_1.setSelectedIds)([hit.id]);
const ids = [...(0, state_1.getState)().selectedIds];
(0, history_1.beginOp)(ids, 'Move', 'move');
(0, state_1.setState)(st => {
st.dragState = {
type: 'move', startX: sx, startY: sy,
startWorldX: wx, startWorldY: wy,
movedIds: ids,
movedFrom: new Map(ids.map(id => [id, (0, scene_1.getShape)(id)])),
};
});
}
else {
if (!e.shiftKey)
(0, state_1.setSelectedIds)([]);
(0, state_1.setState)(st => {
st.dragState = {
type: 'rubberband', startX: sx, startY: sy,
startWorldX: wx, startWorldY: wy,
rubberbandRect: { x: wx, y: wy, w: 0, h: 0 },
};
});
}
}
function handleMoveMove(wx, wy) {
const drag = (0, state_1.getState)().dragState;
const dx = wx - drag.startWorldX, dy = wy - drag.startWorldY;
for (const id of drag.movedIds) {
const orig = drag.movedFrom.get(id);
if (!orig)
continue;
(0, scene_1.updateShape)(id, (shape) => {
const n = { ...shape };
switch (orig.type) {
case 'rectangle':
case 'ellipse':
case 'text':
n.x = orig.x + dx;
n.y = orig.y + dy;
break;
case 'line':
case 'arrow':
n.x1 = orig.x1 + dx;
n.y1 = orig.y1 + dy;
n.x2 = orig.x2 + dx;
n.y2 = orig.y2 + dy;
break;
case 'freehand':
n.points = orig.points.map((p) => [p[0] + dx, p[1] + dy]);
break;
}
return n;
});
}
onRender?.();
}
function handleMoveUp() { (0, history_1.commitOp)(); (0, persistence_1.saveToStorage)(); }
function handleResizeMove(wx, wy, e) {
const drag = (0, state_1.getState)().dragState;
(0, tools_1.resizeSelected)(drag.resizeHandle, wx, wy, e.shiftKey);
onRender?.();
}
function handleResizeUp() { (0, history_1.commitOp)(); (0, persistence_1.saveToStorage)(); }
function handleRubberbandMove(wx, wy, sx, sy) {
const drag = (0, state_1.getState)().dragState;
(0, state_1.setState)(st => {
st.dragState.rubberbandRect = { x: drag.startWorldX, y: drag.startWorldY, w: wx - drag.startWorldX, h: wy - drag.startWorldY };
st.dragState._currentScreenX = sx;
st.dragState._currentScreenY = sy;
});
onRender?.();
}
function handleRubberbandUp() {
const drag = (0, state_1.getState)().dragState;
if (!drag.rubberbandRect)
return;
(0, state_1.setSelectedIds)((0, hitTest_1.findShapesInRect)((0, scene_1.getShapes)(), drag.rubberbandRect.x, drag.rubberbandRect.y, drag.rubberbandRect.w, drag.rubberbandRect.h));
onRender?.();
}
// ─── Create Tool ──────────────────────────────────────────────────────────────
function handleCreateDown(tool, wx, wy, sx, sy) {
(0, state_1.setState)(st => { st.dragState = { type: 'create', startX: sx, startY: sy, startWorldX: wx, startWorldY: wy }; });
}
function handleCreateMove(sx, sy) {
(0, state_1.setState)(st => { if (st.dragState) {
st.dragState._currentScreenX = sx;
st.dragState._currentScreenY = sy;
} });
onRender?.();
}
function handleCreateUp(tool, wx, wy) {
const drag = (0, state_1.getState)().dragState;
(0, tools_1.finishCreateShape)(tool, wx, wy, drag.startWorldX, drag.startWorldY);
onRender?.();
}
// ─── Pen Tool ─────────────────────────────────────────────────────────────────
function handlePenDown(wx, wy, sx, sy) {
(0, tools_1.deselectAll)();
(0, state_1.setState)(st => { st.dragState = { type: 'draw', startX: sx, startY: sy, startWorldX: wx, startWorldY: wy, drawPoints: [[wx, wy]] }; });
}
function handleDrawMove(wx, wy, sx, sy) {
const drag = (0, state_1.getState)().dragState;
drag.drawPoints.push([wx, wy]);
drag._currentScreenX = sx;
drag._currentScreenY = sy;
onRender?.();
}
function handleDrawUp() {
const drag = (0, state_1.getState)().dragState;
if (drag.drawPoints && drag.drawPoints.length >= 2)
(0, tools_1.finishFreehand)(drag.drawPoints);
onRender?.();
}
// ─── Text Tool ────────────────────────────────────────────────────────────────
function handleTextDown(wx, wy, sx, sy) {
const ta = textOverlay;
const vp = (0, state_1.getState)().viewport;
ta.style.left = (wx + vp.x) * vp.zoom + 'px';
ta.style.top = (wy + vp.y) * vp.zoom + 'px';
ta.style.width = '200px';
ta.style.fontSize = ((0, state_1.getDefaultStyle)().fontSize ?? 20) + 'px';
ta.style.color = (0, state_1.getDefaultStyle)().strokeColor;
ta.style.display = 'block';
ta.value = '';
ta.placeholder = 'Type here...';
ta.focus();
ta.onblur = () => {
if (ta.value.trim())
(0, tools_1.finishText)(ta.value, wx, wy);
ta.style.display = 'none';
ta.value = '';
onRender?.();
};
ta.onkeydown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
ta.blur();
}
if (e.key === 'Escape') {
ta.value = '';
ta.blur();
}
};
}
// ─── Eraser Tool ──────────────────────────────────────────────────────────────
function handleEraserDown(wx, wy, sx, sy) {
(0, tools_1.eraseAt)(wx, wy);
(0, state_1.setState)(st => { st.dragState = { type: 'erase', startX: sx, startY: sy, startWorldX: wx, startWorldY: wy }; });
onRender?.();
}
function handleEraserMove(wx, wy) { (0, tools_1.eraseAt)(wx, wy); onRender?.(); }
function handleEraserUp() { onRender?.(); }
// Eraser cursor indicator
function updateEraserCursor(sx, sy) {
const el = document.getElementById('eraser-cursor');
if (!el)
return;
const s = (0, state_1.getState)();
if (s.currentTool === 'eraser') {
el.style.display = 'block';
el.style.left = sx + 'px';
el.style.top = sy + 'px';
}
else {
el.style.display = 'none';
}
}
// ─── Keyboard Events ──────────────────────────────────────────────────────────
function setupKeyboardEvents() {
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp);
}
function onKeyDown(e) {
const s = (0, state_1.getState)();
if (e.code === 'Space' && !e.repeat) {
e.preventDefault();
(0, state_1.setState)(st => { st.spaceDown = true; });
return;
}
if (textOverlay && textOverlay.style.display !== 'none' && textOverlay === document.activeElement)
return;
if (!e.ctrlKey && !e.metaKey) {
switch (e.key.toLowerCase()) {
case 'v':
(0, state_1.setCurrentTool)('select');
updateToolbar?.();
break;
case 'p':
(0, state_1.setCurrentTool)('pen');
updateToolbar?.();
break;
case 'r':
(0, state_1.setCurrentTool)('rectangle');
updateToolbar?.();
break;
case 'o':
(0, state_1.setCurrentTool)('ellipse');
updateToolbar?.();
break;
case 'l':
(0, state_1.setCurrentTool)('line');
updateToolbar?.();
break;
case 'a':
(0, state_1.setCurrentTool)('arrow');
updateToolbar?.();
break;
case 't':
(0, state_1.setCurrentTool)('text');
updateToolbar?.();
break;
case 'e':
(0, state_1.setCurrentTool)('eraser');
updateToolbar?.();
break;
}
}
if ((e.key === 'Delete' || e.key === 'Backspace') && s.selectedIds.length) {
e.preventDefault();
(0, tools_1.deleteSelection)();
onRender?.();
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
e.preventDefault();
if (e.shiftKey)
(0, history_1.redo)();
else
(0, history_1.undo)();
onRender?.();
updateHistoryButtons?.();
}
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) && s.selectedIds.length) {
e.preventDefault();
const step = e.shiftKey ? constants_1.NUDGE_STEP_SHIFT : constants_1.NUDGE_STEP;
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;
(0, tools_1.nudgeSelection)(dx, dy);
onRender?.();
}
}
function onKeyUp(e) {
if (e.code === 'Space')
(0, state_1.setState)(st => { st.spaceDown = false; });
}
// ─── Callbacks ────────────────────────────────────────────────────────────────
let onRender = null;
function setRenderCallback(cb) { onRender = cb; }
let updateToolbar = null;
function setToolbarCallback(cb) { updateToolbar = cb; }
let updateZoomDisplay = null;
function setZoomDisplayCallback(cb) { updateZoomDisplay = cb; }
let updateHistoryButtons = null;
function setHistoryCallback(cb) { updateHistoryButtons = cb; }
function resetView() {
(0, state_1.setViewport)((0, viewport_1.resetView)());
onRender?.();
updateZoomDisplay?.();
}
};
// ── module: src/ui.ts ──
__mods["src/ui.ts"] = function (exports, require, module) {
"use strict";
// ── UI Construction & Updates ─────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.setRenderTrigger = setRenderTrigger;
exports.buildUI = buildUI;
exports.updateToolbar = updateToolbar;
exports.updateHistoryButtons = updateHistoryButtons;
exports.updatePropBarVisibility = updatePropBarVisibility;
const constants_1 = require("./constants");
const state_1 = require("./state");
const history_1 = require("./history");
const viewport_1 = require("./viewport");
const tools_1 = require("./tools");
const TOOLS = [
{ id: 'select', icon: '◇', label: 'Select', shortcut: 'V' },
{ id: 'pen', icon: '✎', label: 'Pen', shortcut: 'P' },
{ id: 'rectangle', icon: '▭', label: 'Rectangle', shortcut: 'R' },
{ id: 'ellipse', icon: '◯', label: 'Ellipse', shortcut: 'O' },
{ id: 'line', icon: '╱', label: 'Line', shortcut: 'L' },
{ id: 'arrow', icon: '→', label: 'Arrow', shortcut: 'A' },
{ id: 'text', icon: 'T', label: 'Text', shortcut: 'T' },
{ id: 'eraser', icon: '◻', label: 'Eraser', shortcut: 'E' },
];
// Render trigger (set by main)
let _triggerRender = null;
function setRenderTrigger(cb) { _triggerRender = cb; }
function triggerRender() { _triggerRender?.(); }
function buildUI() {
buildToolbar();
buildPropBar();
setupZoomButtons();
setupHistoryButtons();
}
function buildToolbar() {
const toolbar = document.getElementById('toolbar');
toolbar.innerHTML = '';
for (const tool of TOOLS) {
const btn = document.createElement('button');
btn.id = 'tool-' + tool.id;
btn.title = `${tool.label} (${tool.shortcut})`;
btn.innerHTML = `<span class="shortcut">${tool.shortcut}</span>${tool.icon}`;
btn.addEventListener('click', () => {
(0, state_1.setCurrentTool)(tool.id);
updateToolbar();
});
toolbar.appendChild(btn);
}
}
function updateToolbar() {
const s = (0, state_1.getState)();
for (const tool of TOOLS) {
const btn = document.getElementById('tool-' + tool.id);
if (btn) {
btn.classList.toggle('active', s.currentTool === tool.id);
}
}
// Update canvas cursor
const canvas = document.getElementById('canvas');
if (canvas) {
canvas.className = 'tool-' + s.currentTool;
}
}
function buildPropBar() {
const propbar = document.getElementById('propbar');
propbar.innerHTML = '';
// Stroke color label
const strokeLabel = document.createElement('label');
strokeLabel.textContent = 'Stroke';
propbar.appendChild(strokeLabel);
// Stroke color swatches
const strokeRow = document.createElement('div');
strokeRow.className = 'color-row';
strokeRow.id = 'stroke-colors';
for (const color of constants_1.PALETTE) {
const swatch = document.createElement('div');
swatch.className = 'color-swatch';
swatch.style.background = color;
swatch.addEventListener('click', () => {
(0, tools_1.applyStyleToSelection)({ strokeColor: color });
(0, state_1.setDefaultStyle)({ strokeColor: color });
triggerRender();
});
strokeRow.appendChild(swatch);
}
propbar.appendChild(strokeRow);
// Divider
const div1 = document.createElement('div');
div1.className = 'divider';
propbar.appendChild(div1);
// Fill color label
const fillLabel = document.createElement('label');
fillLabel.textContent = 'Fill';
propbar.appendChild(fillLabel);
// Fill color swatches
const fillRow = document.createElement('div');
fillRow.className = 'color-row';
fillRow.id = 'fill-colors';
for (const color of constants_1.FILL_PALETTE) {
const swatch = document.createElement('div');
swatch.className = 'color-swatch';
if (color === 'transparent') {
swatch.style.background = 'linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%)';
swatch.style.border = '2px solid #dee2e6';
}
else {
swatch.style.background = color;
swatch.style.border = '2px solid transparent';
}
swatch.addEventListener('click', () => {
(0, tools_1.applyStyleToSelection)({ fillColor: color });
(0, state_1.setDefaultStyle)({ fillColor: color });
triggerRender();
});
fillRow.appendChild(swatch);
}
propbar.appendChild(fillRow);
// Divider
const div2 = document.createElement('div');
div2.className = 'divider';
propbar.appendChild(div2);
// Stroke width buttons
const widthLabel = document.createElement('label');
widthLabel.textContent = 'Width';
propbar.appendChild(widthLabel);
const widthRow = document.createElement('div');
widthRow.className = 'color-row';
widthRow.id = 'stroke-widths';
for (const w of [1, 2, 3, 5, 8]) {
const btn = document.createElement('button');
btn.className = 'width-btn';
btn.innerHTML = `<span style="width:${Math.min(w * 3, 16)}px;height:${w}px;background:#495057;display:block;border-radius:1px;"></span>`;
btn.addEventListener('click', () => {
(0, tools_1.applyStyleToSelection)({ strokeWidth: w });
(0, state_1.setDefaultStyle)({ strokeWidth: w });
triggerRender();
});
widthRow.appendChild(btn);
}
propbar.appendChild(widthRow);
}
function setupZoomButtons() {
const zoomIn = document.getElementById('zoom-in');
const zoomOut = document.getElementById('zoom-out');
const zoomReset = document.getElementById('zoom-reset');
if (zoomIn) {
zoomIn.addEventListener('click', () => {
const canvas = document.getElementById('canvas');
const cx = canvas.clientWidth / 2;
const cy = canvas.clientHeight / 2;
const newVp = (0, viewport_1.zoomAtScreen)(cx, cy, (0, state_1.getState)().viewport, 1.25);
(0, state_1.setViewport)(newVp);
triggerRender();
updateZoomLevel();
});
}
if (zoomOut) {
zoomOut.addEventListener('click', () => {
const canvas = document.getElementById('canvas');
const cx = canvas.clientWidth / 2;
const cy = canvas.clientHeight / 2;
const newVp = (0, viewport_1.zoomAtScreen)(cx, cy, (0, state_1.getState)().viewport, 1 / 1.25);
(0, state_1.setViewport)(newVp);
triggerRender();
updateZoomLevel();
});
}
if (zoomReset) {
zoomReset.addEventListener('click', () => {
(0, state_1.setViewport)((0, viewport_1.resetView)());
triggerRender();
updateZoomLevel();
});
}
}
function updateZoomLevel() {
const el = document.getElementById('zoom-level');
if (el)
el.textContent = Math.round((0, state_1.getState)().viewport.zoom * 100) + '%';
}
function setupHistoryButtons() {
const undoBtn = document.getElementById('undo-btn');
const redoBtn = document.getElementById('redo-btn');
if (undoBtn) {
undoBtn.addEventListener('click', () => {
(0, history_1.undo)();
triggerRender();
updateHistoryButtons();
});
}
if (redoBtn) {
redoBtn.addEventListener('click', () => {
(0, history_1.redo)();
triggerRender();
updateHistoryButtons();
});
}
}
function updateHistoryButtons() {
const { length, index } = (0, history_1.getHistoryState)();
const undoBtn = document.getElementById('undo-btn');
const redoBtn = document.getElementById('redo-btn');
if (undoBtn)
undoBtn.disabled = index < 0;
if (redoBtn)
redoBtn.disabled = index >= length - 1;
}
// Show/hide prop bar based on selection
function updatePropBarVisibility() {
const propbar = document.getElementById('propbar');
if (!propbar)
return;
const hasSelection = (0, state_1.getState)().selectedIds.length > 0;
propbar.classList.toggle('visible', hasSelection);
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.6-27b","provider":"lmstudio","persona":"typescript","sessionId":"84b88095-bc8d-4f15-ac13-97c0b16e9855","tokensIn":4204090,"tokensOut":68711,"tokensTotal":4272801,"cost":0,"turns":72,"toolCalls":85,"failedToolCalls":5,"timestamp":"2026-08-01T17:05:28.498Z"} -->