Whiteboard
thinkingcap qwen3.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 70,172 bytes · SHA-256 05eb5219f8d0
<!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 ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; font-family: 'Inter', system-ui, -apple-system, sans-serif; background: #e8eaed; }
#board-canvas {
display: block;
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
cursor: crosshair;
}
/* ─── Floating Toolbar ───────────────────────────────────────────── */
.floating-toolbar {
position: fixed;
top: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 2px;
padding: 6px 8px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.06);
z-index: 100;
}
.tool-btn {
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
border: none; background: transparent;
border-radius: 8px; cursor: pointer;
font-size: 16px; color: #495057;
transition: background 0.1s, color 0.1s;
}
.tool-btn:hover { background: #f1f3f5; }
.tool-btn.active { background: #e7f5ff; color: #1971c2; font-weight: 600; }
.toolbar-sep {
width: 1px; height: 24px;
background: #dee2e6; margin: 0 4px;
}
/* ─── Properties Bar ─────────────────────────────────────────────── */
.floating-props {
position: fixed;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.06);
z-index: 100;
}
.props-label {
font-size: 11px; color: #868e96; margin-right: 4px; text-transform: uppercase; letter-spacing: 0.5px;
}
.color-swatch {
width: 24px; height: 24px;
border-radius: 6px; border: 2px solid transparent;
cursor: pointer; transition: transform 0.1s, border-color 0.1s;
}
.color-swatch:hover { transform: scale(1.15); }
.color-swatch.active { border-color: #4dabf7; }
.transparent-swatch { background-color: #fff !important; }
.width-btn {
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
border: none; border-bottom-style: solid; border-bottom-color: #495057;
background: transparent; cursor: pointer;
border-radius: 6px; transition: background 0.1s;
}
.width-btn:hover { background: #f1f3f5; }
.width-btn.active { background: #e7f5ff; }
/* ─── Zoom Indicator ─────────────────────────────────────────────── */
.zoom-badge {
position: fixed;
bottom: 12px; right: 16px;
padding: 4px 10px;
background: #ffffff;
border-radius: 8px;
font-size: 13px; color: #495057;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
z-index: 100;
user-select: none;
}
/* ─── Text Editor Overlay ────────────────────────────────────────── */
.text-editor {
position: absolute;
z-index: 200;
border: 2px solid #4dabf7;
border-radius: 4px;
padding: 4px 8px;
outline: none;
resize: both;
overflow: auto;
background: rgba(255,255,255,0.95);
min-width: 100px;
}
/* ─── Cursor styles per tool ─────────────────────────────────────── */
#board-canvas.tool-select { cursor: default; }
#board-canvas.tool-pen { cursor: crosshair; }
#board-canvas.tool-rectangle, #board-canvas.tool-ellipse, #board-canvas.tool-line { cursor: crosshair; }
#board-canvas.tool-text { cursor: text; }
#board-canvas.tool-eraser { cursor: cell; }
</style>
</head>
<body>
<div id="toolbar" class="floating-toolbar"></div>
<div id="props-bar" class="floating-props"></div>
<div id="zoom-indicator" class="zoom-badge">100%</div>
<canvas id="board-canvas"></canvas>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./state":"src/state.ts","./viewport":"src/viewport.ts","./rendering":"src/rendering.ts","./input":"src/input.ts","./ui":"src/ui.ts","./types":"src/types.ts","./constants":"src/constants.ts"},"src/state.ts":{"./types":"src/types.ts","./constants":"src/constants.ts"},"src/viewport.ts":{"./types":"src/types.ts","./constants":"src/constants.ts"},"src/hitTesting.ts":{"./types":"src/types.ts","./constants":"src/constants.ts"},"src/rendering.ts":{"./types":"src/types.ts","./constants":"src/constants.ts","./viewport":"src/viewport.ts"},"src/history.ts":{"./types":"src/types.ts"},"src/input.ts":{"./types":"src/types.ts","./state":"src/state.ts","./viewport":"src/viewport.ts","./hitTesting":"src/hitTesting.ts","./history":"src/history.ts","./constants":"src/constants.ts"},"src/ui.ts":{"./types":"src/types.ts","./state":"src/state.ts","./input":"src/input.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";
// ─── Entry Point ──────────────────────────────────────────────────────
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const state = __importStar(require("./state"));
const render = __importStar(require("./rendering"));
const input = __importStar(require("./input"));
const ui = __importStar(require("./ui"));
const constants_1 = require("./constants");
// ─── Canvas Setup ──────────────────────────────────────────────────────
const canvas = document.getElementById('board-canvas');
const ctx = canvas.getContext('2d', { alpha: false });
function resizeCanvas() {
const dpr = window.devicePixelRatio || 1;
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
// ─── Init State & UI ──────────────────────────────────────────────────
state.init();
ui.initUI();
input.setCanvas(canvas);
// ─── Render Loop ──────────────────────────────────────────────────────
function renderFrame() {
const w = window.innerWidth;
const h = window.innerHeight;
const shapes = state.getShapes();
const v = state.getViewport();
const selIds = state.getSelectedIds();
// Main scene
render.render(ctx, shapes, v, selIds, w, h);
// Pen stroke preview
const penPts = input.getCurrentStrokePoints();
if (penPts.length > 1) {
drawPenPreview(ctx, penPts, state.getDefaultStyle());
}
// Shape creation preview
const creatingType = input.getCreatingShapeType();
if (creatingType && input.isDragActive()) {
const startWp = input.getShapeStartWorld();
const endWp = input.getShapeEndWorld();
if (startWp && endWp) {
drawShapePreview(ctx, creatingType, startWp, endWp);
}
}
// Rubber band selection rect
const rbRect = input.getRubberRect();
if (rbRect) {
ctx.strokeStyle = constants_1.COLORS.rubberBandStroke;
ctx.lineWidth = 1;
ctx.setLineDash([6, 4]);
ctx.fillStyle = constants_1.COLORS.rubberBandFill;
ctx.fillRect(rbRect.x, rbRect.y, rbRect.w, rbRect.h);
ctx.strokeRect(rbRect.x, rbRect.y, rbRect.w, rbRect.h);
ctx.setLineDash([]);
}
// Cursor style per tool
canvas.className = 'tool-' + state.getTool();
requestAnimationFrame(renderFrame);
}
// ─── Preview Drawing Helpers ──────────────────────────────────────────
function drawPenPreview(ctx, points, style) {
if (points.length < 2)
return;
ctx.save();
const v = state.getViewport();
ctx.translate(v.offsetX, v.offsetY);
ctx.scale(v.zoom, v.zoom);
ctx.strokeStyle = style.strokeColor;
ctx.lineWidth = style.strokeWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length - 1; i++) {
const mx = (points[i].x + points[i + 1].x) / 2;
const my = (points[i].y + points[i + 1].y) / 2;
ctx.quadraticCurveTo(points[i].x, points[i].y, mx, my);
}
ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
ctx.stroke();
ctx.restore();
}
function drawShapePreview(ctx, type, startWp, endWp) {
ctx.save();
const v = state.getViewport();
ctx.translate(v.offsetX, v.offsetY);
ctx.scale(v.zoom, v.zoom);
const style = state.getDefaultStyle();
ctx.strokeStyle = style.strokeColor;
ctx.lineWidth = style.strokeWidth;
ctx.lineCap = 'round';
if (style.fillColor && style.fillColor !== 'transparent') {
ctx.fillStyle = style.fillColor;
}
switch (type) {
case 'rectangle':
ctx.beginPath();
ctx.rect(startWp.x, startWp.y, endWp.x - startWp.x, endWp.y - startWp.y);
if (style.fillColor && style.fillColor !== 'transparent')
ctx.fill();
ctx.stroke();
break;
case 'ellipse': {
const cx = (startWp.x + endWp.x) / 2;
const cy = (startWp.y + endWp.y) / 2;
const rx = Math.abs(endWp.x - startWp.x) / 2;
const ry = Math.abs(endWp.y - startWp.y) / 2;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
if (style.fillColor && style.fillColor !== 'transparent')
ctx.fill();
ctx.stroke();
break;
}
case 'line':
ctx.beginPath();
ctx.moveTo(startWp.x, startWp.y);
ctx.lineTo(endWp.x, endWp.y);
ctx.stroke();
break;
}
ctx.restore();
}
// ─── Start Render Loop ────────────────────────────────────────────────
requestAnimationFrame(renderFrame);
};
// ── module: src/constants.ts ──
__mods["src/constants.ts"] = function (exports, require, module) {
"use strict";
// ─── Tunable Constants ────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.SHIFT_NUDGE_AMOUNT = exports.NUDGE_AMOUNT = exports.GRID_SPACING = exports.HIT_TOLERANCE = exports.HANDLE_SIZE = exports.ZOOM_STEP = exports.ZOOM_MAX = exports.ZOOM_MIN = exports.STROKE_WIDTHS = exports.FILL_PALETTE = exports.PALETTE = exports.COLORS = void 0;
exports.COLORS = {
bg: '#f8f9fa',
gridDot: '#e0e0e0',
chrome: '#ffffff',
chromeBorder: '#dee2e6',
toolbarShadow: 'rgba(0,0,0,0.12)',
selectedStroke: '#4dabf7',
handleFill: '#ffffff',
handleStroke: '#4dabf7',
rubberBandStroke: '#4dabf7',
rubberBandFill: 'rgba(77,171,247,0.1)',
};
exports.PALETTE = [
'#1e1e1e', '#ee5659', '#bb3e03', '#f6c40e', '#2b8a3e',
'#0c73bd', '#6a0dad', '#8c6293', '#000000', '#ffffff',
];
exports.FILL_PALETTE = [
'transparent', '#fee2e2', '#fef3c7', '#d1fae5', '#dbeafe',
'#ede9fe', '#f3e8ff', '#fce7f3', '#ffffff', '#1e1e1e',
];
exports.STROKE_WIDTHS = [1, 2, 4, 6];
exports.ZOOM_MIN = 0.25;
exports.ZOOM_MAX = 4.0;
exports.ZOOM_STEP = 1.1;
exports.HANDLE_SIZE = 8; // in world coords (scales with zoom)
exports.HIT_TOLERANCE = 6; // px for stroke hit testing
exports.GRID_SPACING = 24;
exports.NUDGE_AMOUNT = 1;
exports.SHIFT_NUDGE_AMOUNT = 10;
};
// ── 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";
// ─── Application State & Persistence ──────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.init = init;
exports.getState = getState;
exports.setShapes = setShapes;
exports.getShapes = getShapes;
exports.addShape = addShape;
exports.removeShape = removeShape;
exports.updateShape = updateShape;
exports.setViewport = setViewport;
exports.getViewport = getViewport;
exports.setTool = setTool;
exports.getTool = getTool;
exports.setSelectedIds = setSelectedIds;
exports.getSelectedIds = getSelectedIds;
exports.toggleSelection = toggleSelection;
exports.setDefaultStyle = setDefaultStyle;
exports.getDefaultStyle = getDefaultStyle;
const constants_1 = require("./constants");
const STORAGE_KEY = 'whiteboard-board-data';
let state;
function createInitialState() {
return {
shapes: [],
viewport: { offsetX: 0, offsetY: 0, zoom: 1 },
tool: 'select',
selectedIds: new Set(),
defaultStyle: { strokeColor: '#1e1e1e', fillColor: 'transparent', strokeWidth: constants_1.STROKE_WIDTHS[2] },
};
}
function init() {
state = loadFromStorage() ?? createInitialState();
// Ensure selectedIds is a Set (can't survive JSON serialization)
if (!(state.selectedIds instanceof Set)) {
state.selectedIds = new Set(state.selectedIds);
}
return state;
}
function getState() { return state; }
function setShapes(shapes) { state.shapes = shapes; saveToStorage(); }
function getShapes() { return state.shapes; }
function addShape(shape) { state.shapes.push(shape); saveToStorage(); }
function removeShape(id) { state.shapes = state.shapes.filter(s => s.id !== id); saveToStorage(); }
function updateShape(updates) {
const idx = state.shapes.findIndex(s => s.id === updates.id);
if (idx >= 0)
Object.assign(state.shapes[idx], updates);
saveToStorage();
}
function setViewport(vp) { state.viewport = vp; }
function getViewport() { return state.viewport; }
function setTool(tool) { state.tool = tool; }
function getTool() { return state.tool; }
function setSelectedIds(ids) { state.selectedIds = ids; }
function getSelectedIds() { return state.selectedIds; }
function toggleSelection(id, addMode) {
if (addMode && state.selectedIds.has(id)) {
state.selectedIds.delete(id);
}
else {
state.selectedIds.add(id);
}
}
function setDefaultStyle(style) { state.defaultStyle = style; saveToStorage(); }
function getDefaultStyle() { return state.defaultStyle; }
// ─── Persistence ──────────────────────────────────────────────────────
function serialize() {
const data = {
shapes: state.shapes,
viewport: state.viewport,
defaultStyle: state.defaultStyle,
};
try {
return JSON.stringify(data);
}
catch {
return '';
}
}
function saveToStorage() {
try {
localStorage.setItem(STORAGE_KEY, serialize());
}
catch { }
}
function loadFromStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw)
return null;
const data = JSON.parse(raw);
return {
shapes: data.shapes ?? [],
viewport: data.viewport ?? { offsetX: 0, offsetY: 0, zoom: 1 },
tool: 'select',
selectedIds: new Set(),
defaultStyle: data.defaultStyle ?? { strokeColor: '#1e1e1e', fillColor: 'transparent', strokeWidth: 4 },
};
}
catch {
return null;
}
}
};
// ── module: src/viewport.ts ──
__mods["src/viewport.ts"] = function (exports, require, module) {
"use strict";
// ─── Viewport Transform (world ↔ screen) ──────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.worldToScreen = worldToScreen;
exports.screenToWorld = screenToWorld;
exports.zoomAt = zoomAt;
exports.clampZoom = clampZoom;
const constants_1 = require("./constants");
function worldToScreen(wp, vp) {
return { x: wp.x * vp.zoom + vp.offsetX, y: wp.y * vp.zoom + vp.offsetY };
}
function screenToWorld(sp, vp) {
return { x: (sp.x - vp.offsetX) / vp.zoom, y: (sp.y - vp.offsetY) / vp.zoom };
}
// Zoom toward a specific screen point — the world point under that cursor stays fixed.
function zoomAt(vp, sp, factor) {
const newZoom = Math.max(constants_1.ZOOM_MIN, Math.min(constants_1.ZOOM_MAX, vp.zoom * factor));
// The world point under sp must stay under sp after the zoom change.
const worldPt = screenToWorld(sp, vp);
return {
offsetX: sp.x - worldPt.x * newZoom,
offsetY: sp.y - worldPt.y * newZoom,
zoom: newZoom,
};
}
function clampZoom(vp) {
const z = Math.max(constants_1.ZOOM_MIN, Math.min(constants_1.ZOOM_MAX, vp.zoom));
return { ...vp, zoom: z };
}
};
// ── module: src/hitTesting.ts ──
__mods["src/hitTesting.ts"] = function (exports, require, module) {
"use strict";
// ─── Hit Testing (world coordinates) ──────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.hitTest = hitTest;
exports.boundingBox = boundingBox;
const constants_1 = require("./constants");
function distPointToSegment(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));
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
}
function pointInRect(px, py, x, y, w, h) {
const left = Math.min(x, x + w), right = Math.max(x, x + w);
const top = Math.min(y, y + h), bottom = Math.max(y, y + h);
return px >= left && px <= right && py >= top && py <= bottom;
}
function pointInEllipse(px, py, cx, cy, rx, ry) {
if (rx === 0 || ry === 0)
return false;
return ((px - cx) ** 2 / rx ** 2 + (py - cy) ** 2 / ry ** 2) <= 1;
}
function pointNearPenStroke(points, px, py) {
for (let i = 1; i < points.length; i++) {
if (distPointToSegment(px, py, points[i - 1].x, points[i - 1].y, points[i].x, points[i].y) <= constants_1.HIT_TOLERANCE) {
return true;
}
}
return false;
}
function pointNearLine(x1, y1, x2, y2, px, py) {
return distPointToSegment(px, py, x1, y1, x2, y2) <= constants_1.HIT_TOLERANCE;
}
function pointInTextRect(x, y, text, fontSize, px, py) {
// Approximate text bounding box
const w = text.length * fontSize * 0.6 + 8;
const h = fontSize * 1.3;
return px >= x && px <= x + w && py >= y - h && py <= y;
}
function hitTest(shape, wp) {
const { x: px, y: py } = wp;
switch (shape.type) {
case 'rectangle':
return pointInRect(px, py, shape.x, shape.y, shape.w, shape.h);
case 'ellipse':
return pointInEllipse(px, py, shape.cx, shape.cy, Math.abs(shape.rx), Math.abs(shape.ry));
case 'line': {
const tol = Math.max(constants_1.HIT_TOLERANCE, shape.strokeWidth / 2 + 2);
return distPointToSegment(px, py, shape.x1, shape.y1, shape.x2, shape.y2) <= tol;
}
case 'pen':
return pointNearPenStroke(shape.points, px, py);
case 'text':
return pointInTextRect(shape.x, shape.y, shape.text, shape.fontSize, px, py);
}
}
// Compute bounding box in world coords for a set of shapes.
function boundingBox(shapes) {
if (!shapes.length)
return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const s of shapes) {
switch (s.type) {
case 'rectangle': {
const l = Math.min(s.x, s.x + s.w), r = Math.max(s.x, s.x + s.w);
const t = Math.min(s.y, s.y + s.h), b = Math.max(s.y, s.y + s.h);
minX = Math.min(minX, l);
minY = Math.min(minY, t);
maxX = Math.max(maxX, r);
maxY = Math.max(maxY, b);
break;
}
case 'ellipse': {
minX = Math.min(minX, s.cx - s.rx);
minY = Math.min(minY, s.cy - s.ry);
maxX = Math.max(maxX, s.cx + s.rx);
maxY = Math.max(maxY, s.cy + s.ry);
break;
}
case 'line': {
minX = Math.min(minX, s.x1, s.x2);
minY = Math.min(minY, s.y1, s.y2);
maxX = Math.max(maxX, s.x1, s.x2);
maxY = Math.max(maxY, s.y1, s.y2);
break;
}
case 'pen': {
for (const p of s.points) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
break;
}
case 'text': {
const w = s.text.length * s.fontSize * 0.6 + 8;
const h = s.fontSize * 1.3;
minX = Math.min(minX, s.x);
minY = Math.min(minY, s.y - h);
maxX = Math.max(maxX, s.x + w);
maxY = Math.max(maxY, s.y);
break;
}
}
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
};
// ── module: src/rendering.ts ──
__mods["src/rendering.ts"] = function (exports, require, module) {
"use strict";
// ─── Canvas Rendering ────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.render = render;
const constants_1 = require("./constants");
const viewport_1 = require("./viewport");
function render(ctx, shapes, vp, selectedIds, width, height) {
// ── Background ────────────────────────────────────────────────────
ctx.fillStyle = constants_1.COLORS.bg;
ctx.fillRect(0, 0, width, height);
// ── Grid dots ─────────────────────────────────────────────────────
drawGrid(ctx, vp, width, height);
// ── Shapes ────────────────────────────────────────────────────────
for (const shape of shapes) {
drawShape(ctx, shape, vp, selectedIds.has(shape.id));
}
// ── Selection overlay ─────────────────────────────────────────────
const selected = shapes.filter(s => selectedIds.has(s.id));
if (selected.length > 0) {
drawSelectionOverlay(ctx, selected, vp);
}
}
// ─── Grid ──────────────────────────────────────────────────────────────
function drawGrid(ctx, vp, w, h) {
if (vp.zoom < 0.3)
return; // skip grid at very low zooms
const spacing = constants_1.GRID_SPACING * vp.zoom;
if (spacing < 6)
return; // too dense
const startX = vp.offsetX % spacing;
const startY = vp.offsetY % spacing;
ctx.fillStyle = constants_1.COLORS.gridDot;
for (let x = startX; x < w; x += spacing) {
for (let y = startY; y < h; y += spacing) {
ctx.beginPath();
ctx.arc(x, y, Math.max(0.5, vp.zoom * 1), 0, Math.PI * 2);
ctx.fill();
}
}
}
// ─── Shape Drawing ─────────────────────────────────────────────────────
function drawShape(ctx, shape, vp, selected) {
const z = vp.zoom;
ctx.save();
ctx.translate(vp.offsetX, vp.offsetY);
ctx.scale(z, z);
ctx.strokeStyle = shape.strokeColor;
ctx.lineWidth = shape.strokeWidth / z * z; // keep consistent in world coords... actually just use strokeWidth
ctx.lineWidth = shape.strokeWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
switch (shape.type) {
case 'rectangle':
drawRectangle(ctx, shape);
break;
case 'ellipse':
drawEllipse(ctx, shape);
break;
case 'line':
drawLine(ctx, shape);
break;
case 'pen':
drawPenStroke(ctx, shape);
break;
case 'text':
drawText(ctx, shape);
break;
}
ctx.restore();
}
function drawRectangle(ctx, s) {
const rect = s.x !== undefined ? s : null;
if (!rect)
return;
if (rect.fillColor && rect.fillColor !== 'transparent') {
ctx.fillStyle = rect.fillColor;
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
}
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
}
function drawEllipse(ctx, s) {
const e = s;
if (!e.cx && !e.cy && !e.rx && !e.ry)
return; // degenerate
ctx.beginPath();
ctx.ellipse(e.cx, e.cy, Math.abs(e.rx), Math.abs(e.ry), 0, 0, Math.PI * 2);
if (e.fillColor && e.fillColor !== 'transparent') {
ctx.fillStyle = e.fillColor;
ctx.fill();
}
ctx.stroke();
}
function drawLine(ctx, s) {
const l = s;
ctx.beginPath();
ctx.moveTo(l.x1, l.y1);
ctx.lineTo(l.x2, l.y2);
ctx.stroke();
if (l.arrowHead) {
drawArrowHead(ctx, l.x1, l.y1, l.x2, l.y2, l.strokeWidth);
}
}
function drawArrowHead(ctx, x1, y1, x2, y2, sw) {
const angle = Math.atan2(y2 - y1, x2 - x1);
const headLen = Math.max(8 + sw * 3, 16);
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6));
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6));
ctx.stroke();
}
function drawPenStroke(ctx, s) {
const pen = s;
if (pen.points.length < 2)
return;
ctx.beginPath();
ctx.moveTo(pen.points[0].x, pen.points[0].y);
// Smooth the stroke using quadratic curves through midpoints
for (let i = 1; i < pen.points.length - 1; i++) {
const mx = (pen.points[i].x + pen.points[i + 1].x) / 2;
const my = (pen.points[i].y + pen.points[i + 1].y) / 2;
ctx.quadraticCurveTo(pen.points[i].x, pen.points[i].y, mx, my);
}
// Last segment
const last = pen.points[pen.points.length - 1];
ctx.lineTo(last.x, last.y);
ctx.stroke();
}
function drawText(ctx, s) {
const t = s;
ctx.font = `${t.fontSize}px "Inter", system-ui, sans-serif`;
if (t.fillColor && t.fillColor !== 'transparent') {
ctx.fillStyle = t.fillColor;
const lines = wrapText(t.text, 40);
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], t.x, t.y - (lines.length - 1 - i) * t.fontSize * 1.2);
}
}
else {
ctx.fillStyle = s.strokeColor;
const lines = wrapText(t.text, 40);
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], t.x, t.y - (lines.length - 1 - i) * t.fontSize * 1.2);
}
}
}
function wrapText(text, maxChars) {
if (text.length <= maxChars)
return [text];
const lines = [];
let current = '';
for (const ch of text) {
if (current.length >= maxChars) {
lines.push(current);
current = '';
}
current += ch;
}
if (current)
lines.push(current);
return lines;
}
// ─── Selection Overlay ────────────────────────────────────────────────
function drawSelectionOverlay(ctx, shapes, vp) {
const box = computeScreenBounds(shapes, vp);
if (!box)
return;
ctx.strokeStyle = constants_1.COLORS.selectedStroke;
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 4]);
ctx.strokeRect(box.x - 4, box.y - 4, box.w + 8, box.h + 8);
ctx.setLineDash([]);
// Corner handles
const hs = constants_1.HANDLE_SIZE * vp.zoom;
const corners = [
{ x: box.x - 4, y: box.y - 4 },
{ x: box.x + box.w + 4, y: box.y - 4 },
{ x: box.x - 4, y: box.y + box.h + 4 },
{ x: box.x + box.w + 4, y: box.y + box.h + 4 },
];
for (const c of corners) {
ctx.fillStyle = constants_1.COLORS.handleFill;
ctx.strokeStyle = constants_1.COLORS.handleStroke;
ctx.lineWidth = 1.5;
ctx.fillRect(c.x - hs / 2, c.y - hs / 2, hs, hs);
ctx.strokeRect(c.x - hs / 2, c.y - hs / 2, hs, hs);
}
}
function computeScreenBounds(shapes, vp) {
if (!shapes.length)
return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const s of shapes) {
switch (s.type) {
case 'rectangle': {
const l = Math.min(s.x, s.x + s.w), r = Math.max(s.x, s.x + s.w);
const t = Math.min(s.y, s.y + s.h), b = Math.max(s.y, s.y + s.h);
minX = Math.min(minX, l);
minY = Math.min(minY, t);
maxX = Math.max(maxX, r);
maxY = Math.max(maxY, b);
break;
}
case 'ellipse': {
minX = Math.min(minX, s.cx - Math.abs(s.rx));
minY = Math.min(minY, s.cy - Math.abs(s.ry));
maxX = Math.max(maxX, s.cx + Math.abs(s.rx));
maxY = Math.max(maxY, s.cy + Math.abs(s.ry));
break;
}
case 'line': {
minX = Math.min(minX, s.x1, s.x2);
minY = Math.min(minY, s.y1, s.y2);
maxX = Math.max(maxX, s.x1, s.x2);
maxY = Math.max(maxY, s.y1, s.y2);
break;
}
case 'pen': {
for (const p of s.points) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
break;
}
case 'text': {
const w = s.text.length * s.fontSize * 0.6 + 8;
const h = s.fontSize * 1.3;
minX = Math.min(minX, s.x);
minY = Math.min(minY, s.y - h);
maxX = Math.max(maxX, s.x + w);
maxY = Math.max(maxY, s.y);
break;
}
}
}
const tl = (0, viewport_1.worldToScreen)({ x: minX, y: minY }, vp);
const br = (0, viewport_1.worldToScreen)({ x: maxX, y: maxY }, vp);
return { x: tl.x, y: tl.y, w: br.x - tl.x, h: br.y - tl.y };
}
};
// ── module: src/history.ts ──
__mods["src/history.ts"] = function (exports, require, module) {
"use strict";
// ─── Undo / Redo History ──────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.canUndo = canUndo;
exports.canRedo = canRedo;
exports.beginEdit = beginEdit;
exports.commitEdit = commitEdit;
exports.undo = undo;
exports.redo = redo;
exports.clearHistory = clearHistory;
const MAX_HISTORY = 100;
let undoStack = [];
let redoStack = [];
function canUndo() { return undoStack.length > 0; }
function canRedo() { return redoStack.length > 0; }
// Begin recording a change. Returns the shapes that existed before (for snapshot).
function beginEdit(allShapes) {
const before = new Map();
for (const s of allShapes)
before.set(s.id, structuredClone(s));
return before;
}
// Commit the edit. `after` is the current full shape list.
function commitEdit(before, after) {
const shapesAfter = new Map();
for (const s of after)
shapesAfter.set(s.id, structuredClone(s));
undoStack.push({ action: 'modify', shapesBefore: before, shapesAfter });
if (undoStack.length > MAX_HISTORY)
undoStack.shift();
redoStack = []; // discard redo on new edit
}
function undo(allShapes) {
const entry = undoStack.pop();
if (!entry)
return allShapes;
// Save current state for redo
const shapesNow = new Map();
for (const s of allShapes)
shapesNow.set(s.id, structuredClone(s));
redoStack.push({ action: 'modify', shapesBefore: entry.shapesAfter, shapesAfter: shapesNow });
return applyState(entry.shapesBefore);
}
function redo(allShapes) {
const entry = redoStack.pop();
if (!entry)
return allShapes;
// Save current state for undo
const shapesNow = new Map();
for (const s of allShapes)
shapesNow.set(s.id, structuredClone(s));
undoStack.push({ action: 'modify', shapesBefore: entry.shapesBefore, shapesAfter: shapesNow });
return applyState(entry.shapesAfter);
}
function applyState(shapeMap) {
const result = [];
// Preserve insertion order from the map (which was built in shape order)
for (const [, s] of shapeMap)
result.push(s);
return result;
}
function clearHistory() {
undoStack = [];
redoStack = [];
}
};
// ── module: src/input.ts ──
__mods["src/input.ts"] = function (exports, require, module) {
"use strict";
// ─── Input Handling (mouse, keyboard) ──────────────────────────────────
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.setCanvas = setCanvas;
exports.startTextEditing = startTextEditing;
exports.commitTextEdit = commitTextEdit;
exports.cancelTextEdit = cancelTextEdit;
exports.getTextEditingId = getTextEditingId;
exports.on = on;
exports.getRubberRect = getRubberRect;
exports.getCurrentStrokePoints = getCurrentStrokePoints;
exports.isDragActive = isDragActive;
exports.getCreatingShapeType = getCreatingShapeType;
exports.getShapeStartWorld = getShapeStartWorld;
exports.getShapeEndWorld = getShapeEndWorld;
exports.setShapeEndWorld = setShapeEndWorld;
exports.doUndo = doUndo;
exports.doRedo = doRedo;
const state = __importStar(require("./state"));
const vp = __importStar(require("./viewport"));
const hit = __importStar(require("./hitTesting"));
const history = __importStar(require("./history"));
const constants_1 = require("./constants");
// ─── Interaction State ────────────────────────────────────────────────
let isDragging = false;
let dragStartScreen = null;
let dragStartWorld = null;
let currentStrokePoints = [];
let textEditingId = null;
// Shape creation preview state
let creatingShapeType = null;
let dragMode = 'none';
let resizeHandleIdx = -1;
let editSnapshot = null;
// Rubber band rect (screen coords)
let rubberRect = null;
function setCanvas(canvas) {
canvas.addEventListener('mousedown', onMouseDown);
canvas.addEventListener('mousemove', onMouseMove);
canvas.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('mouseleave', onMouseUp);
canvas.addEventListener('wheel', onWheel, { passive: false });
canvas.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('keydown', onKeyDown);
}
// ─── Mouse Events ──────────────────────────────────────────────────────
function onMouseDown(e) {
const rect = e.target.getBoundingClientRect();
const sp = { x: e.clientX - rect.left, y: e.clientY - rect.top };
const wp = vp.screenToWorld(sp, state.getViewport());
const tool = state.getTool();
if (e.button === 1 || (e.button === 0 && e.altKey)) {
startPan(sp);
return;
}
if (e.button !== 0)
return;
switch (tool) {
case 'select':
handleSelectDown(sp, wp, e.shiftKey);
break;
case 'pen':
startDrawing(wp);
break;
case 'rectangle':
case 'ellipse':
case 'line':
startShapeCreation(tool, wp);
break;
case 'text':
placeText(wp);
break;
case 'eraser':
startErasing(wp);
break;
}
}
function onMouseMove(e) {
const rect = e.target.getBoundingClientRect();
const sp = { x: e.clientX - rect.left, y: e.clientY - rect.top };
const wp = vp.screenToWorld(sp, state.getViewport());
if (!isDragging)
return;
switch (dragMode) {
case 'pan':
doPan(sp);
break;
case 'move':
doMove(wp);
break;
case 'resize':
doResize(wp, e.shiftKey);
break;
case 'rubberband':
rubberRect = computeRubberRect(sp);
break;
case 'draw':
continueDrawing(wp);
break;
case 'erase':
eraseAt(wp);
break;
}
}
function onMouseUp(e) {
if (!isDragging)
return;
const rect = e.target.getBoundingClientRect();
const sp = { x: e.clientX - rect.left, y: e.clientY - rect.top };
switch (dragMode) {
case 'move':
finishMove();
break;
case 'resize':
finishResize();
break;
case 'rubberband':
finishRubberBand(sp);
rubberRect = null;
break;
case 'draw':
finishDrawing();
break;
}
isDragging = false;
dragMode = 'none';
}
function onWheel(e) {
e.preventDefault();
const rect = e.target.getBoundingClientRect();
const sp = { x: e.clientX - rect.left, y: e.clientY - rect.top };
const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08;
state.setViewport(vp.zoomAt(state.getViewport(), sp, factor));
}
// ─── Pan ──────────────────────────────────────────────────────────────
function startPan(sp) {
isDragging = true;
dragMode = 'pan';
dragStartScreen = sp;
}
function doPan(sp) {
if (!dragStartScreen)
return;
const dx = sp.x - dragStartScreen.x, dy = sp.y - dragStartScreen.y;
const v = state.getViewport();
state.setViewport({ offsetX: v.offsetX + dx, offsetY: v.offsetY + dy, zoom: v.zoom });
dragStartScreen = sp;
}
// ─── Select Tool ──────────────────────────────────────────────────────
function handleSelectDown(sp, wp, shiftKey) {
const shapes = state.getShapes();
const selectedIds = state.getSelectedIds();
// Check resize handles (single selection only)
if (selectedIds.size === 1) {
const selShape = shapes.find(s => s.id === [...selectedIds][0]);
if (selShape) {
const hIdx = hitResizeHandle(sp, selShape);
if (hIdx >= 0) {
startResize(hIdx, wp);
return;
}
}
}
// Hit test shapes top-to-bottom
let hitShape = null;
for (let i = shapes.length - 1; i >= 0; i--) {
if (hit.hitTest(shapes[i], wp)) {
hitShape = shapes[i];
break;
}
}
if (hitShape) {
if (shiftKey)
state.toggleSelection(hitShape.id, true);
else if (!selectedIds.has(hitShape.id))
setSelected([hitShape.id]);
startMove(wp);
}
else {
if (shiftKey)
return;
setSelected([]);
dragMode = 'rubberband';
isDragging = true;
dragStartScreen = sp;
}
}
function setSelected(ids) { state.setSelectedIds(new Set(ids)); }
// ─── Move ──────────────────────────────────────────────────────────────
function startMove(wp) {
isDragging = true;
dragMode = 'move';
dragStartWorld = wp;
editSnapshot = history.beginEdit(state.getShapes());
}
function doMove(wp) {
if (!dragStartWorld || !editSnapshot)
return;
const dx = wp.x - dragStartWorld.x, dy = wp.y - dragStartWorld.y;
for (const s of state.getShapes()) {
if (state.getSelectedIds().has(s.id))
moveShape(s, dx, dy);
}
}
function finishMove() {
history.commitEdit(editSnapshot, state.getShapes());
editSnapshot = null;
}
// ─── Resize ────────────────────────────────────────────────────────────
function startResize(handleIdx, wp) {
isDragging = true;
dragMode = 'resize';
resizeHandleIdx = handleIdx;
dragStartWorld = wp;
editSnapshot = history.beginEdit(state.getShapes());
}
function doResize(wp, shiftKey) {
if (!dragStartWorld || !editSnapshot)
return;
const shapes = state.getShapes();
const selIds = state.getSelectedIds();
if (selIds.size !== 1)
return;
const shape = shapes.find(s => s.id === [...selIds][0]);
if (!shape)
return;
resizeShape(shape, resizeHandleIdx, wp.x - dragStartWorld.x, wp.y - dragStartWorld.y, shiftKey);
}
function finishResize() {
history.commitEdit(editSnapshot, state.getShapes());
editSnapshot = null;
resizeHandleIdx = -1;
}
// ─── Rubber Band Selection ─────────────────────────────────────────────
function computeRubberRect(sp) {
if (!dragStartScreen)
return null;
return {
x: Math.min(dragStartScreen.x, sp.x), y: Math.min(dragStartScreen.y, sp.y),
w: Math.abs(sp.x - dragStartScreen.x), h: Math.abs(sp.y - dragStartScreen.y),
};
}
function finishRubberBand(sp) {
if (!dragStartScreen)
return;
const dx = sp.x - dragStartScreen.x, dy = sp.y - dragStartScreen.y;
if (Math.abs(dx) < 3 && Math.abs(dy) < 3)
return;
const rect = computeRubberRect(sp);
if (!rect || rect.w < 2 || rect.h < 2)
return;
const v = state.getViewport();
const tl = vp.screenToWorld({ x: rect.x, y: rect.y }, v);
const br = vp.screenToWorld({ x: rect.x + rect.w, y: rect.y + rect.h }, v);
const selected = [];
for (const s of state.getShapes()) {
if (shapeIntersectsRect(s, tl.x, tl.y, br.x - tl.x, br.y - tl.y))
selected.push(s.id);
}
setSelected(selected);
}
function shapeIntersectsRect(shape, rx, ry, rw, rh) {
const bb = hit.boundingBox([shape]);
if (!bb)
return false;
return !(bb.x + bb.w < rx || bb.x > rx + rw || bb.y + bb.h < ry || bb.y > ry + rh);
}
// ─── Drawing (Pen Tool) ────────────────────────────────────────────────
function startDrawing(wp) {
isDragging = true;
dragMode = 'draw';
creatingShapeType = null;
currentStrokePoints = [wp];
}
function continueDrawing(wp) {
if (creatingShapeType) {
shapeEndWorld = wp;
return;
}
currentStrokePoints.push({ ...wp });
}
function finishDrawing() {
if (creatingShapeType && dragStartWorld && shapeEndWorld) {
finishShapeCreation();
}
else if (currentStrokePoints.length > 1) {
const style = state.getDefaultStyle();
const stroke = {
id: genId(), type: 'pen', points: [...currentStrokePoints],
strokeColor: style.strokeColor, fillColor: style.fillColor, strokeWidth: style.strokeWidth,
};
const before = history.beginEdit(state.getShapes());
state.addShape(stroke);
history.commitEdit(before, state.getShapes());
}
currentStrokePoints = [];
}
function finishShapeCreation() {
if (!creatingShapeType || !dragStartWorld || !shapeEndWorld)
return;
const style = state.getDefaultStyle();
const s = dragStartWorld, e = shapeEndWorld;
let shape = null;
switch (creatingShapeType) {
case 'rectangle':
if (Math.abs(e.x - s.x) > 2 || Math.abs(e.y - s.y) > 2) {
shape = { id: genId(), type: 'rectangle', x: s.x, y: s.y, w: e.x - s.x, h: e.y - s.y, strokeColor: style.strokeColor, fillColor: style.fillColor, strokeWidth: style.strokeWidth };
}
break;
case 'ellipse': {
const rx = Math.abs(e.x - s.x) / 2, ry = Math.abs(e.y - s.y) / 2;
if (rx > 1 || ry > 1) {
shape = { id: genId(), type: 'ellipse', cx: (s.x + e.x) / 2, cy: (s.y + e.y) / 2, rx, ry, strokeColor: style.strokeColor, fillColor: style.fillColor, strokeWidth: style.strokeWidth };
}
break;
}
case 'line': {
const dist = Math.hypot(e.x - s.x, e.y - s.y);
if (dist > 2) {
shape = { id: genId(), type: 'line', x1: s.x, y1: s.y, x2: e.x, y2: e.y, arrowHead: false, strokeColor: style.strokeColor, fillColor: style.fillColor, strokeWidth: style.strokeWidth };
}
break;
}
}
if (shape) {
const before = history.beginEdit(state.getShapes());
state.addShape(shape);
history.commitEdit(before, state.getShapes());
}
creatingShapeType = null;
shapeEndWorld = null;
}
// ─── Shape Creation ────────────────────────────────────────────────────
let shapeEndWorld = null;
function startShapeCreation(type, wp) {
isDragging = true;
dragMode = 'draw';
creatingShapeType = type;
dragStartWorld = wp;
currentStrokePoints = [];
}
// ─── Eraser ────────────────────────────────────────────────────────────
function startErasing(wp) {
isDragging = true;
dragMode = 'erase';
editSnapshot = history.beginEdit(state.getShapes());
eraseAt(wp);
}
function eraseAt(wp) {
const shapes = state.getShapes();
for (let i = shapes.length - 1; i >= 0; i--) {
if (hit.hitTest(shapes[i], wp)) {
history.commitEdit(editSnapshot, shapes.filter((_, idx) => idx !== i));
editSnapshot = new Map();
for (const s of state.getShapes())
editSnapshot.set(s.id, structuredClone(s));
break;
}
}
}
// ─── Text Tool ────────────────────────────────────────────────────────
function placeText(wp) {
const style = state.getDefaultStyle();
const textShape = {
id: genId(), type: 'text', x: wp.x, y: wp.y, text: '',
strokeColor: style.strokeColor, fillColor: style.fillColor, strokeWidth: style.strokeWidth, fontSize: 20,
};
state.addShape(textShape);
const before = history.beginEdit(state.getShapes());
history.commitEdit(before, state.getShapes());
startTextEditing(textShape.id);
}
function startTextEditing(id) { textEditingId = id; emitEvent('text-edit-start', { id }); }
function commitTextEdit(id, text) {
const before = history.beginEdit(state.getShapes());
state.updateShape({ id, text });
history.commitEdit(before, state.getShapes());
textEditingId = null;
emitEvent('text-edit-end', {});
}
function cancelTextEdit() {
if (textEditingId) {
const before = history.beginEdit(state.getShapes());
state.removeShape(textEditingId);
history.commitEdit(before, state.getShapes());
textEditingId = null;
emitEvent('text-edit-end', {});
}
}
function getTextEditingId() { return textEditingId; }
// ─── Keyboard ──────────────────────────────────────────────────────────
function onKeyDown(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
doUndo();
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
e.preventDefault();
doRedo();
return;
}
if ((e.key === 'Delete' || e.key === 'Backspace') && !textEditingId) {
const sel = state.getSelectedIds();
if (sel.size > 0) {
e.preventDefault();
const before = history.beginEdit(state.getShapes());
for (const id of sel)
state.removeShape(id);
history.commitEdit(before, state.getShapes());
setSelected([]);
}
return;
}
if (!e.ctrlKey && !e.metaKey && !textEditingId) {
switch (e.key.toLowerCase()) {
case 'v':
state.setTool('select');
emitEvent('tool-change', {});
break;
case 'p':
state.setTool('pen');
emitEvent('tool-change', {});
break;
case 'r':
state.setTool('rectangle');
emitEvent('tool-change', {});
break;
case 'o':
state.setTool('ellipse');
emitEvent('tool-change', {});
break;
case 'l':
state.setTool('line');
emitEvent('tool-change', {});
break;
case 't':
state.setTool('text');
emitEvent('tool-change', {});
break;
case 'e':
state.setTool('eraser');
emitEvent('tool-change', {});
break;
}
}
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) && !textEditingId) {
const sel = state.getSelectedIds();
if (sel.size > 0) {
e.preventDefault();
const amt = e.shiftKey ? constants_1.SHIFT_NUDGE_AMOUNT : constants_1.NUDGE_AMOUNT;
let dx = 0, dy = 0;
switch (e.key) {
case 'ArrowUp':
dy = -amt;
break;
case 'ArrowDown':
dy = amt;
break;
case 'ArrowLeft':
dx = -amt;
break;
case 'ArrowRight':
dx = amt;
break;
}
const before = history.beginEdit(state.getShapes());
for (const s of state.getShapes()) {
if (sel.has(s.id))
moveShape(s, dx, dy);
}
history.commitEdit(before, state.getShapes());
}
}
}
// ─── Helpers ──────────────────────────────────────────────────────────
function moveShape(shape, dx, dy) {
switch (shape.type) {
case 'rectangle':
shape.x += dx;
shape.y += dy;
break;
case 'ellipse':
shape.cx += dx;
shape.cy += dy;
break;
case 'line':
shape.x1 += dx;
shape.y1 += dy;
shape.x2 += dx;
shape.y2 += dy;
break;
case 'pen':
for (const p of shape.points) {
p.x += dx;
p.y += dy;
}
break;
case 'text':
shape.x += dx;
shape.y += dy;
break;
}
}
function resizeShape(shape, handle, dx, dy, aspect) {
switch (shape.type) {
case 'rectangle':
resizeRect(shape, handle, dx, dy, aspect);
break;
case 'ellipse':
resizeEllipse(shape, handle, dx, dy, aspect);
break;
case 'line':
resizeLine(shape, handle, dx, dy);
break;
}
}
function resizeRect(s, h, dx, dy, asp) {
const orig = { w: s.w, h: s.h };
switch (h) {
case 0:
s.x += dx;
s.y += dy;
s.w -= dx;
s.h -= dy;
break;
case 1:
s.y += dy;
s.w += dx;
s.h -= dy;
break;
case 2:
s.x += dx;
s.w -= dx;
s.h += dy;
break;
case 3:
s.w += dx;
s.h += dy;
break;
}
if (asp && orig.w) {
const r = Math.abs(orig.w / orig.h);
if (Math.abs(s.w) > Math.abs(s.h) * r)
s.h = s.w / r * Math.sign(s.h || 1);
else
s.w = s.h * r * Math.sign(s.w || 1);
}
}
function resizeEllipse(s, h, dx, dy, asp) {
switch (h) {
case 0:
s.rx -= dx;
s.ry -= dy;
break;
case 1:
s.rx += dx;
s.ry -= dy;
break;
case 2:
s.rx -= dx;
s.ry += dy;
break;
case 3:
s.rx += dx;
s.ry += dy;
break;
}
if (asp && Math.abs(s.rx)) {
const r = Math.abs(s.rx / s.ry);
s.ry = s.rx / r * Math.sign(s.ry || 1);
}
}
function resizeLine(s, h, dx, dy) {
if (h === 0 || h === 2) {
s.x1 += dx;
s.y1 += dy;
}
else {
s.x2 += dx;
s.y2 += dy;
}
}
function hitResizeHandle(sp, shape) {
const box = hit.boundingBox([shape]);
if (!box)
return -1;
const v = state.getViewport();
const tl = vp.worldToScreen({ x: box.x, y: box.y }, v);
const br = vp.worldToScreen({ x: box.x + box.w, y: box.y + box.h }, v);
const hs = constants_1.HANDLE_SIZE * v.zoom;
const pad = 4 * v.zoom;
const corners = [
{ x: tl.x - pad, y: tl.y - pad }, { x: br.x + pad, y: tl.y - pad },
{ x: tl.x - pad, y: br.y + pad }, { x: br.x + pad, y: br.y + pad },
];
for (let i = 0; i < corners.length; i++) {
if (Math.abs(sp.x - corners[i].x) <= hs / 2 && Math.abs(sp.y - corners[i].y) <= hs / 2)
return i;
}
return -1;
}
function genId() { return 's' + Math.random().toString(36).slice(2, 10); }
// ─── Event System ──────────────────────────────────────────────────────
const listeners = new Map();
function on(event, fn) {
if (!listeners.has(event))
listeners.set(event, []);
listeners.get(event).push(fn);
}
function emitEvent(event, data) {
const fns = listeners.get(event);
if (fns)
for (const fn of fns)
fn(data);
}
// ─── Exports for rendering integration ────────────────────────────────
function getRubberRect() { return rubberRect; }
function getCurrentStrokePoints() { return currentStrokePoints; }
function isDragActive() { return isDragging; }
function getCreatingShapeType() { return creatingShapeType; }
function getShapeStartWorld() { return dragStartWorld; }
function getShapeEndWorld() { return shapeEndWorld; }
function setShapeEndWorld(p) { shapeEndWorld = p; }
// ─── Undo/Redo (direct, no double-commit) ──────────────────────────────
function doUndo() {
const newShapes = history.undo(state.getShapes());
state.setShapes(newShapes);
}
function doRedo() {
const newShapes = history.redo(state.getShapes());
state.setShapes(newShapes);
}
};
// ── module: src/ui.ts ──
__mods["src/ui.ts"] = function (exports, require, module) {
"use strict";
// ─── UI: Toolbar, Properties Bar, Zoom Indicator ──────────────────────
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.initUI = initUI;
exports.updateToolButtons = updateToolButtons;
exports.updateZoomDisplay = updateZoomDisplay;
const state = __importStar(require("./state"));
const input = __importStar(require("./input"));
const constants_1 = require("./constants");
let toolbarEl;
let propsBarEl;
let zoomIndicatorEl;
let textEditorEl = null;
const TOOL_DEFS = [
{ id: 'select', label: 'V', icon: '◇', tip: 'Select (V)' },
{ id: 'pen', label: 'P', icon: '✎', tip: 'Pen (P)' },
{ id: 'rectangle', label: 'R', icon: '▭', tip: 'Rectangle (R)' },
{ id: 'ellipse', label: 'O', icon: '◯', tip: 'Ellipse (O)' },
{ id: 'line', label: 'L', icon: '╱', tip: 'Line/Arrow (L)' },
{ id: 'text', label: 'T', icon: 'T', tip: 'Text (T)' },
{ id: 'eraser', label: 'E', icon: '✕', tip: 'Eraser (E)' },
];
function initUI() {
toolbarEl = document.getElementById('toolbar');
propsBarEl = document.getElementById('props-bar');
zoomIndicatorEl = document.getElementById('zoom-indicator');
buildToolbar();
buildPropsBar();
updateZoomDisplay();
updateToolButtons();
// Listen for tool changes from keyboard shortcuts
input.on('tool-change', () => { updateToolButtons(); });
input.on('text-edit-start', (data) => showTextEditor(data.id));
input.on('text-edit-end', () => hideTextEditor());
}
// ─── Toolbar ──────────────────────────────────────────────────────────
function buildToolbar() {
toolbarEl.innerHTML = '';
// Undo / Redo buttons
const undoBtn = makeToolButton('↶', 'Undo (Ctrl+Z)', () => input.doUndo());
const redoBtn = makeToolButton('↷', 'Redo (Ctrl+Shift+Z)', () => input.doRedo());
toolbarEl.appendChild(undoBtn);
toolbarEl.appendChild(redoBtn);
// Separator
toolbarEl.appendChild(makeSeparator());
// Tool buttons
for (const def of TOOL_DEFS) {
const btn = makeToolButton(def.icon, def.tip, () => {
state.setTool(def.id);
updateToolButtons();
});
btn.dataset.tool = def.id;
toolbarEl.appendChild(btn);
}
// Separator
toolbarEl.appendChild(makeSeparator());
// Reset view button
const resetBtn = makeToolButton('⊞', 'Reset View', () => {
state.setViewport({ offsetX: 0, offsetY: 0, zoom: 1 });
});
toolbarEl.appendChild(resetBtn);
}
function makeToolButton(icon, title, onClick) {
const btn = document.createElement('button');
btn.className = 'tool-btn';
btn.textContent = icon;
btn.title = title;
btn.addEventListener('click', onClick);
return btn;
}
function makeSeparator() {
const sep = document.createElement('div');
sep.className = 'toolbar-sep';
return sep;
}
function updateToolButtons() {
const tool = state.getTool();
toolbarEl.querySelectorAll('[data-tool]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tool === tool);
});
}
// ─── Properties Bar ────────────────────────────────────────────────────
function buildPropsBar() {
propsBarEl.innerHTML = '';
// Stroke color
const strokeLabel = document.createElement('span');
strokeLabel.className = 'props-label';
strokeLabel.textContent = 'Stroke';
propsBarEl.appendChild(strokeLabel);
for (const c of constants_1.PALETTE) {
const swatch = makeColorSwatch(c, () => {
updateStyle('strokeColor', c);
});
propsBarEl.appendChild(swatch);
}
// Fill color
const fillLabel = document.createElement('span');
fillLabel.className = 'props-label';
fillLabel.textContent = 'Fill';
propsBarEl.appendChild(fillLabel);
for (const c of constants_1.FILL_PALETTE) {
const swatch = makeColorSwatch(c, () => {
updateStyle('fillColor', c);
});
if (c === 'transparent') {
swatch.classList.add('transparent-swatch');
}
propsBarEl.appendChild(swatch);
}
// Stroke width
const widthLabel = document.createElement('span');
widthLabel.className = 'props-label';
widthLabel.textContent = 'Width';
propsBarEl.appendChild(widthLabel);
for (const w of constants_1.STROKE_WIDTHS) {
const btn = makeWidthButton(w, () => {
updateStyle('strokeWidth', w);
});
propsBarEl.appendChild(btn);
}
updatePropsHighlight();
}
function makeColorSwatch(color, onClick) {
const btn = document.createElement('button');
btn.className = 'color-swatch';
btn.style.backgroundColor = color;
if (color === 'transparent') {
// Draw X through transparent swatch
btn.style.backgroundImage = 'linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%)';
btn.style.backgroundSize = '8px 8px';
btn.style.backgroundPosition = '0 0, 8px 8px';
}
btn.addEventListener('click', onClick);
return btn;
}
function makeWidthButton(width, onClick) {
const btn = document.createElement('button');
btn.className = 'width-btn';
btn.style.borderBottomWidth = width + 'px';
btn.addEventListener('click', onClick);
return btn;
}
function updateStyle(prop, value) {
const style = state.getDefaultStyle();
style[prop] = value;
state.setDefaultStyle(style);
// Also apply to selected shapes
for (const s of state.getShapes()) {
if (state.getSelectedIds().has(s.id)) {
state.updateShape({ id: s.id, [prop]: value });
}
}
updatePropsHighlight();
}
function updatePropsHighlight() {
const style = state.getDefaultStyle();
propsBarEl.querySelectorAll('.color-swatch').forEach(btn => {
btn.classList.remove('active');
});
propsBarEl.querySelectorAll('.width-btn').forEach(btn => {
btn.classList.remove('active');
});
}
// ─── Zoom Indicator ────────────────────────────────────────────────────
function updateZoomDisplay() {
const zoom = state.getViewport().zoom;
zoomIndicatorEl.textContent = Math.round(zoom * 100) + '%';
}
// ─── Text Editor Overlay ──────────────────────────────────────────────
function showTextEditor(shapeId) {
if (textEditorEl)
return; // already showing
const shape = state.getShapes().find(s => s.id === shapeId);
if (!shape || shape.type !== 'text')
return;
textEditorEl = document.createElement('textarea');
textEditorEl.className = 'text-editor';
textEditorEl.value = shape.text;
textEditorEl.rows = Math.max(1, (shape.text?.length || 0) / 40 + 1);
const v = state.getViewport();
const sx = shape.x * v.zoom + v.offsetX;
const sy = shape.y * v.zoom + v.offsetY;
textEditorEl.style.left = sx + 'px';
textEditorEl.style.top = (sy - 24) + 'px';
textEditorEl.style.fontSize = (shape.fontSize || 20) * v.zoom + 'px';
textEditorEl.style.color = shape.strokeColor;
document.body.appendChild(textEditorEl);
textEditorEl.focus();
const commit = () => {
input.commitTextEdit(shapeId, textEditorEl.value);
};
textEditorEl.addEventListener('blur', commit);
textEditorEl.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
input.cancelTextEdit();
}
});
}
function hideTextEditor() {
if (textEditorEl) {
textEditorEl.remove();
textEditorEl = null;
}
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"typescript","sessionId":"99648545-fd14-4253-9b99-469793600ece","tokensIn":1351993,"tokensOut":35266,"tokensTotal":1387259,"cost":0,"turns":43,"toolCalls":42,"failedToolCalls":4,"timestamp":"2026-07-31T20:31:36.399Z"} -->