Whiteboard
ThinkingCap Qwen3.6 27B GGUF · typescript

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 69,947 bytes · SHA-256 b765fafe3535
<!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: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f1f3; }
#app { width: 100%; height: 100%; position: relative; }
#canvas-container { width: 100%; height: 100%; overflow: hidden; }
#whiteboard { display: block; width: 100%; height: 100%; }
/* Toolbar */
#toolbar {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
background: #ffffff;
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.05);
padding: 4px 6px;
z-index: 100;
gap: 2px;
}
.tool-btn, #toolbar button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
color: #5f6368;
transition: background 0.15s, color 0.15s;
}
.tool-btn:hover, #toolbar button:hover { background: #f1f3f4; color: #202124; }
.tool-btn.active { background: #e8f0fe; color: #1a73e8; }
.tool-btn:active, #toolbar button:active { background: #dadce0; }
.divider {
width: 1px;
height: 24px;
background: #e0e0e0;
margin: 0 4px;
}
/* Properties bar */
#properties-bar {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
background: #ffffff;
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.05);
padding: 6px 12px;
z-index: 100;
gap: 16px;
}
.prop-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.prop-group label {
font-size: 10px;
font-weight: 600;
color: #5f6368;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.color-palette {
display: flex;
gap: 3px;
}
.color-swatch {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: border-color 0.15s, transform 0.1s;
}
.color-swatch:hover { transform: scale(1.15); }
.color-swatch.active { border-color: #1a73e8; }
#stroke-width {
font-size: 12px;
padding: 2px 6px;
border: 1px solid #dee2e6;
border-radius: 4px;
background: #fff;
color: #333;
cursor: pointer;
}
/* Zoom control */
#zoom-control {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
align-items: center;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
padding: 4px 6px;
z-index: 100;
gap: 4px;
}
#zoom-control button {
width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: #5f6368;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 400;
}
#zoom-control button:hover { background: #f1f3f4; }
#zoom-level {
font-size: 12px;
color: #5f6368;
min-width: 42px;
text-align: center;
user-select: none;
}
</style>
</head>
<body>
<div id="app">
<div id="toolbar">
<div class="tool-group">
<button class="tool-btn active" data-tool="select" title="Select (V)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"></path><path d="M13 13l6 6"></path></svg>
</button>
<button class="tool-btn" data-tool="freehand" title="Pen (P)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19l7-7 3 3-7 7-3-3z"></path><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"></path><path d="M2 2l7.586 7.586"></path><circle cx="11" cy="11" r="2"></circle></svg>
</button>
<button class="tool-btn" data-tool="rectangle" title="Rectangle (R)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"></rect></svg>
</button>
<button class="tool-btn" data-tool="ellipse" title="Ellipse (O)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="10" ry="8"></ellipse></svg>
</button>
<button class="tool-btn" data-tool="line" title="Line (L)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="19" x2="19" y2="5"></line></svg>
</button>
<button class="tool-btn" data-tool="text" title="Text (T)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7V4h16v3"></path><line x1="12" y1="4" x2="12" y2="20"></line><path d="M8 20h8"></path></svg>
</button>
<button class="tool-btn" data-tool="eraser" title="Eraser (E)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 20H7L3 16l9-9 8 8-4 4z"></path><path d="M6 11l8 8"></path></svg>
</button>
</div>
<div class="divider"></div>
<div class="tool-group">
<button id="btn-undo" title="Undo (Ctrl+Z)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>
</button>
<button id="btn-redo" title="Redo (Ctrl+Shift+Z)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.13-9.36L23 10"></path></svg>
</button>
</div>
</div>
<div id="properties-bar">
<div class="prop-group">
<label>Stroke</label>
<div id="stroke-colors" class="color-palette"></div>
</div>
<div class="prop-group">
<label>Fill</label>
<div id="fill-colors" class="color-palette"></div>
</div>
<div class="prop-group">
<label>Width</label>
<select id="stroke-width"></select>
</div>
</div>
<div id="canvas-container">
<canvas id="whiteboard"></canvas>
</div>
<div id="zoom-control">
<button id="btn-zoom-out">−</button>
<span id="zoom-level">100%</span>
<button id="btn-zoom-in">+</button>
<button id="btn-reset-view" title="Reset view">⟲</button>
</div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./state":"src/state.ts","./interaction":"src/interaction.ts","./constants":"src/constants.ts"},"src/state.ts":{"./types":"src/types.ts","./constants":"src/constants.ts"},"src/coords.ts":{"./types":"src/types.ts"},"src/hittest.ts":{"./types":"src/types.ts","./constants":"src/constants.ts"},"src/render.ts":{"./types":"src/types.ts","./constants":"src/constants.ts","./hittest":"src/hittest.ts"},"src/interaction.ts":{"./types":"src/types.ts","./state":"src/state.ts","./coords":"src/coords.ts","./hittest":"src/hittest.ts","./render":"src/render.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 — wire up the UI and start the whiteboard
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 interaction = __importStar(require("./interaction"));
const constants_1 = require("./constants");
function main() {
const canvas = document.getElementById('whiteboard');
if (!canvas)
return;
// Load and init
state.loadFromStorage();
state.initHistory();
// Start interaction
interaction.init(canvas);
interaction.updateToolIndicators();
// --- Tool buttons ---
document.querySelectorAll('.tool-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tool = btn.getAttribute('data-tool');
if (tool) {
state.setTool(tool);
interaction.updateToolIndicators();
}
});
});
// --- Undo/Redo ---
const btnUndo = document.getElementById('btn-undo');
const btnRedo = document.getElementById('btn-redo');
if (btnUndo)
btnUndo.addEventListener('click', () => { state.undo(); });
if (btnRedo)
btnRedo.addEventListener('click', () => { state.redo(); });
// --- Stroke colors ---
const strokePalette = document.getElementById('stroke-colors');
if (strokePalette) {
for (const color of constants_1.PALETTE.strokes) {
const swatch = document.createElement('div');
swatch.className = 'color-swatch';
swatch.style.background = color;
if (color === '#f5f5f5')
swatch.style.border = '2px solid #ccc';
swatch.addEventListener('click', () => {
state.setStyle({ strokeColor: color });
updateSwatchActive(strokePalette, color);
applyStyleToSelection();
});
strokePalette.appendChild(swatch);
}
updateSwatchActive(strokePalette, state.getStyle().strokeColor);
}
// --- Fill colors ---
const fillPalette = document.getElementById('fill-colors');
if (fillPalette) {
for (const color of constants_1.PALETTE.fills) {
const swatch = document.createElement('div');
swatch.className = 'color-swatch';
swatch.style.background = color;
if (color === '#ffffff')
swatch.style.border = '2px solid #ccc';
swatch.addEventListener('click', () => {
state.setStyle({ fillColor: color });
updateSwatchActive(fillPalette, color);
applyStyleToSelection();
});
fillPalette.appendChild(swatch);
}
updateSwatchActive(fillPalette, state.getStyle().fillColor);
}
// --- Stroke width ---
const widthSelect = document.getElementById('stroke-width');
if (widthSelect) {
for (let w = constants_1.MIN_STROKE_WIDTH; w <= constants_1.MAX_STROKE_WIDTH; w += 1) {
const opt = document.createElement('option');
opt.value = String(w);
opt.textContent = String(w);
if (w === state.getStyle().strokeWidth)
opt.selected = true;
widthSelect.appendChild(opt);
}
widthSelect.addEventListener('change', () => {
const w = parseInt(widthSelect.value, 10);
state.setStyle({ strokeWidth: w });
applyStyleToSelection();
});
}
// --- Zoom controls ---
const zoomLevel = document.getElementById('zoom-level');
// Zoom render update
const origFrame = window._wbFrame;
function updateZoomDisplay() {
if (zoomLevel) {
const pct = Math.round(state.getViewport().zoom * 100);
zoomLevel.textContent = pct + '%';
}
}
setInterval(updateZoomDisplay, 500);
updateZoomDisplay();
const btnZoomIn = document.getElementById('btn-zoom-in');
const btnZoomOut = document.getElementById('btn-zoom-out');
const btnReset = document.getElementById('btn-reset-view');
if (btnZoomIn)
btnZoomIn.addEventListener('click', () => zoomBy(1.25));
if (btnZoomOut)
btnZoomOut.addEventListener('click', () => zoomBy(0.8));
if (btnReset)
btnReset.addEventListener('click', () => {
state.setViewport({ offsetX: canvas.clientWidth / 2, offsetY: canvas.clientHeight / 2, zoom: 1 });
});
// Set initial viewport center
state.setViewport({
offsetX: canvas.clientWidth / 2,
offsetY: canvas.clientHeight / 2,
zoom: 1,
});
}
function updateSwatchActive(container, activeColor) {
container.querySelectorAll('.color-swatch').forEach(swatch => {
const bg = swatch.style.background;
swatch.classList.toggle('active', bg === activeColor);
});
}
function applyStyleToSelection() {
const style = state.getStyle();
const shapes = state.getShapes();
const ids = state.getSelectedIds();
if (ids.length === 0)
return;
state.pushHistory();
for (const s of shapes) {
if (ids.includes(s.id)) {
s.strokeColor = style.strokeColor;
s.fillColor = style.fillColor;
s.strokeWidth = style.strokeWidth;
}
}
state.persist();
}
function zoomBy(factor) {
const canvas = document.getElementById('whiteboard');
const vp = state.getViewport();
const cx = canvas.clientWidth / 2;
const cy = canvas.clientHeight / 2;
const wx = (cx - vp.offsetX) / vp.zoom;
const wy = (cy - vp.offsetY) / vp.zoom;
const newZoom = Math.max(0.25, Math.min(4, vp.zoom * factor));
state.setViewport({
offsetX: cx - wx * newZoom,
offsetY: cy - wy * newZoom,
zoom: newZoom,
});
}
main();
};
// ── module: src/constants.ts ──
__mods["src/constants.ts"] = function (exports, require, module) {
"use strict";
// Tunable constants
Object.defineProperty(exports, "__esModule", { value: true });
exports.MAX_STROKE_WIDTH = exports.MIN_STROKE_WIDTH = exports.ARROW_HEAD_SIZE = exports.HANDLE_HIT_TOLERANCE = exports.HIT_TOLERANCE = exports.HANDLE_SIZE = exports.ZOOM_STEP = exports.ZOOM_MAX = exports.ZOOM_MIN = exports.PALETTE = exports.COLORS = void 0;
exports.COLORS = {
bg: '#f8f9fa',
grid: '#e2e5e9',
chrome: '#ffffff',
chromeBorder: '#dee2e6',
toolbarShadow: 'rgba(0,0,0,0.12)',
selected: '#4285f4',
preview: '#9aa0a6',
placeholder: '#adb5bd',
};
exports.PALETTE = {
strokes: ['#1e1e1e', '#e74c3c', '#e67e22', '#27ae60', '#2980b9', '#8e44ad', '#34495e', '#f5f5f5'],
fills: ['#ffffff', '#ffcccc', '#ffe0cc', '#ccffcc', '#cce5ff', '#f0ccff', '#ddd', '#cccccc'],
};
exports.ZOOM_MIN = 0.25;
exports.ZOOM_MAX = 4.0;
exports.ZOOM_STEP = 1.12;
exports.HANDLE_SIZE = 8;
exports.HIT_TOLERANCE = 8;
exports.HANDLE_HIT_TOLERANCE = 5;
exports.ARROW_HEAD_SIZE = 12;
exports.MIN_STROKE_WIDTH = 1;
exports.MAX_STROKE_WIDTH = 20;
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// Core types for the whiteboard
Object.defineProperty(exports, "__esModule", { value: true });
};
// ── module: src/state.ts ──
__mods["src/state.ts"] = function (exports, require, module) {
"use strict";
// Scene state management with undo/redo and localStorage persistence
Object.defineProperty(exports, "__esModule", { value: true });
exports.getShapes = getShapes;
exports.getSelectedIds = getSelectedIds;
exports.getTool = getTool;
exports.setTool = setTool;
exports.getViewport = getViewport;
exports.getStyle = getStyle;
exports.setViewport = setViewport;
exports.setStyle = setStyle;
exports.select = select;
exports.deselectAll = deselectAll;
exports.setSelection = setSelection;
exports.isSelected = isSelected;
exports.getSelected = getSelected;
exports.createShape = createShape;
exports.deleteSelected = deleteSelected;
exports.deleteShape = deleteShape;
exports.pushHistory = pushHistory;
exports.undo = undo;
exports.redo = redo;
exports.canUndo = canUndo;
exports.canRedo = canRedo;
exports.pushHistoryEntry = pushHistoryEntry;
exports.persist = persist;
exports.loadFromStorage = loadFromStorage;
exports.initHistory = initHistory;
const constants_1 = require("./constants");
const STORAGE_KEY = 'whiteboard-shapes';
let shapes = [];
let selectedIds = [];
let tool = 'select';
let viewport = { offsetX: 0, offsetY: 0, zoom: 1 };
let style = {
strokeColor: constants_1.PALETTE.strokes[0],
fillColor: constants_1.PALETTE.fills[0],
strokeWidth: 2,
};
let history = [];
let historyIndex = -1;
let nextId = 1;
function genId() {
return String(nextId++);
}
// --- Public API ---
function getShapes() { return shapes; }
function getSelectedIds() { return selectedIds; }
function getTool() { return tool; }
function setTool(t) { tool = t; }
function getViewport() { return { ...viewport }; }
function getStyle() { return { ...style }; }
function setViewport(vp) {
viewport.offsetX = vp.offsetX;
viewport.offsetY = vp.offsetY;
viewport.zoom = Math.max(constants_1.ZOOM_MIN, Math.min(constants_1.ZOOM_MAX, vp.zoom));
}
function setStyle(s) {
if (s.strokeColor !== undefined)
style.strokeColor = s.strokeColor;
if (s.fillColor !== undefined)
style.fillColor = s.fillColor;
if (s.strokeWidth !== undefined)
style.strokeWidth = s.strokeWidth;
}
function select(id, add = false) {
if (add) {
if (!selectedIds.includes(id))
selectedIds.push(id);
}
else {
selectedIds = [id];
}
}
function deselectAll() {
selectedIds = [];
}
function setSelection(ids) {
selectedIds = ids;
}
function isSelected(id) {
return selectedIds.includes(id);
}
function getSelected() {
return shapes.filter(s => selectedIds.includes(s.id));
}
// --- Shape operations ---
function createShape(partial) {
const shape = {
id: genId(),
type: partial.type,
strokeColor: style.strokeColor,
fillColor: style.fillColor,
strokeWidth: style.strokeWidth,
opacity: 1,
...partial,
};
shapes.push(shape);
return shape;
}
function deleteSelected() {
if (selectedIds.length === 0)
return;
pushHistory();
shapes = shapes.filter(s => !selectedIds.includes(s.id));
selectedIds = [];
}
function deleteShape(id) {
const exists = shapes.find(s => s.id === id);
if (!exists)
return;
pushHistory();
shapes = shapes.filter(s => s.id !== id);
if (selectedIds.includes(id))
selectedIds = selectedIds.filter(i => i !== id);
}
// --- Undo / Redo ---
function pushHistory() {
history = history.slice(0, historyIndex + 1);
history.push({
shapesBefore: JSON.parse(JSON.stringify(shapes)),
shapesAfter: JSON.parse(JSON.stringify(shapes)),
});
historyIndex++;
if (history.length > 100) {
history.shift();
historyIndex--;
}
}
function undo() {
if (historyIndex < 0)
return;
history[historyIndex].shapesAfter = JSON.parse(JSON.stringify(shapes));
shapes = JSON.parse(JSON.stringify(history[historyIndex].shapesBefore));
historyIndex--;
selectedIds = [];
persist();
}
function redo() {
if (historyIndex >= history.length - 1)
return;
historyIndex++;
shapes = JSON.parse(JSON.stringify(history[historyIndex].shapesAfter));
selectedIds = [];
persist();
}
function canUndo() { return historyIndex >= 0; }
function canRedo() { return historyIndex < history.length - 1; }
// Push a pre-recorded entry (for multi-step ops like drag)
function pushHistoryEntry(entry) {
history = history.slice(0, historyIndex + 1);
history.push(entry);
historyIndex++;
if (history.length > 100) {
history.shift();
historyIndex--;
}
}
// --- Persistence ---
function persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(shapes));
}
catch (_) { /* quota exceeded */ }
}
function loadFromStorage() {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
shapes = JSON.parse(data);
let maxId = 0;
for (const s of shapes) {
const n = parseInt(s.id, 10);
if (n > maxId)
maxId = n;
}
nextId = maxId + 1;
}
}
catch (_) { /* corrupted data */ }
}
function initHistory() {
history = [{
shapesBefore: JSON.parse(JSON.stringify(shapes)),
shapesAfter: JSON.parse(JSON.stringify(shapes)),
}];
historyIndex = 0;
}
};
// ── module: src/coords.ts ──
__mods["src/coords.ts"] = function (exports, require, module) {
"use strict";
// Coordinate transforms between screen and world
Object.defineProperty(exports, "__esModule", { value: true });
exports.screenToWorld = screenToWorld;
exports.worldToScreen = worldToScreen;
exports.zoomAtPoint = zoomAtPoint;
function screenToWorld(px, py, vp) {
return {
x: (px - vp.offsetX) / vp.zoom,
y: (py - vp.offsetY) / vp.zoom,
};
}
function worldToScreen(wx, wy, vp) {
return {
x: wx * vp.zoom + vp.offsetX,
y: wy * vp.zoom + vp.offsetY,
};
}
function zoomAtPoint(newZoom, screenX, screenY, vp) {
const world = screenToWorld(screenX, screenY, vp);
return {
offsetX: screenX - world.x * newZoom,
offsetY: screenY - world.y * newZoom,
zoom: newZoom,
};
}
};
// ── module: src/hittest.ts ──
__mods["src/hittest.ts"] = function (exports, require, module) {
"use strict";
// Hit testing — all in world coordinates
Object.defineProperty(exports, "__esModule", { value: true });
exports.hitTest = hitTest;
exports.getBounds = getBounds;
exports.getCombinedBounds = getCombinedBounds;
exports.hitHandle = hitHandle;
const constants_1 = require("./constants");
// Check if a point is near a line segment (within tolerance)
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));
}
// Check if point is inside a rectangle
function hitRectangle(shape, wx, wy) {
if (shape.x === undefined || shape.y === undefined || shape.width === undefined || shape.height === undefined)
return false;
const halfW = shape.width / 2, halfH = shape.height / 2;
const left = shape.x - halfW, right = shape.x + halfW;
const top = shape.y - halfH, bottom = shape.y + halfH;
// Check interior
if (wx >= left && wx <= right && wy >= top && wy <= bottom)
return true;
// Check stroke border
const tol = (shape.strokeWidth || 0) / 2 + constants_1.HIT_TOLERANCE;
return (wx >= left - tol && wx <= right + tol && wy >= top - tol && wy <= bottom + tol &&
(wx < left || wx > right || wy < top || wy > bottom));
}
// Check if point is inside an ellipse
function hitEllipse(shape, wx, wy) {
if (shape.cx === undefined || shape.cy === undefined || shape.rx === undefined || shape.ry === undefined)
return false;
const dx = (wx - shape.cx) / (shape.rx + (shape.strokeWidth || 0) / 2 + constants_1.HIT_TOLERANCE / 2);
const dy = (wy - shape.cy) / (shape.ry + (shape.strokeWidth || 0) / 2 + constants_1.HIT_TOLERANCE / 2);
return dx * dx + dy * dy <= 1;
}
// Check if point is near a polyline
function hitFreehand(shape, wx, wy) {
if (!shape.points || shape.points.length < 2)
return false;
const tol = (shape.strokeWidth || 0) / 2 + constants_1.HIT_TOLERANCE;
for (let i = 0; i < shape.points.length - 1; i++) {
if (distPointToSegment(wx, wy, shape.points[i].x, shape.points[i].y, shape.points[i + 1].x, shape.points[i + 1].y) <= tol) {
return true;
}
}
// Also check distance to each point
for (const p of shape.points) {
if (Math.hypot(wx - p.x, wy - p.y) <= tol)
return true;
}
return false;
}
// Check if point is near a line/arrow
function hitLine(shape, wx, wy) {
if (shape.x1 === undefined || shape.y1 === undefined || shape.x2 === undefined || shape.y2 === undefined)
return false;
const tol = (shape.strokeWidth || 0) / 2 + constants_1.HIT_TOLERANCE;
return distPointToSegment(wx, wy, shape.x1, shape.y1, shape.x2, shape.y2) <= tol;
}
// Check if point is near text
function hitText(shape, wx, wy, ctx) {
if (shape.x === undefined || shape.y === undefined)
return false;
const fontSize = shape.fontSize || 16;
ctx.font = `${fontSize}px sans-serif`;
const lines = (shape.text || '').split('\n');
const lineHeight = fontSize * 1.2;
let maxWidth = 0;
for (const line of lines) {
const w = ctx.measureText(line).width;
if (w > maxWidth)
maxWidth = w;
}
const totalH = lines.length * lineHeight;
const padding = 4;
const left = shape.x - padding;
const top = shape.y - fontSize - padding;
return wx >= left && wx <= left + maxWidth + padding * 2 && wy >= top && wy <= top + totalH + padding * 2;
}
function hitTest(shape, wx, wy, ctx) {
switch (shape.type) {
case 'rectangle': return hitRectangle(shape, wx, wy);
case 'ellipse': return hitEllipse(shape, wx, wy);
case 'freehand': return hitFreehand(shape, wx, wy);
case 'line':
case 'arrow': return hitLine(shape, wx, wy);
case 'text': return hitText(shape, wx, wy, ctx);
default: return false;
}
}
// Get bounding box in world coordinates
function getBounds(shape) {
switch (shape.type) {
case 'rectangle':
if (shape.x !== undefined && shape.y !== undefined && shape.width !== undefined && shape.height !== undefined) {
return { x: shape.x - shape.width / 2, y: shape.y - shape.height / 2, width: shape.width, height: shape.height };
}
break;
case 'ellipse':
if (shape.cx !== undefined && shape.cy !== undefined && shape.rx !== undefined && shape.ry !== undefined) {
return { x: shape.cx - shape.rx, y: shape.cy - shape.ry, width: shape.rx * 2, height: shape.ry * 2 };
}
break;
case 'freehand':
if (shape.points && shape.points.length > 0) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of shape.points) {
if (p.x < minX)
minX = p.x;
if (p.y < minY)
minY = p.y;
if (p.x > maxX)
maxX = p.x;
if (p.y > maxY)
maxY = p.y;
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
break;
case 'line':
case 'arrow':
if (shape.x1 !== undefined && shape.y1 !== undefined && shape.x2 !== undefined && shape.y2 !== undefined) {
return {
x: Math.min(shape.x1, shape.x2),
y: Math.min(shape.y1, shape.y2),
width: Math.abs(shape.x2 - shape.x1),
height: Math.abs(shape.y2 - shape.y1),
};
}
break;
case 'text':
if (shape.x !== undefined && shape.y !== undefined) {
return { x: shape.x, y: shape.y - (shape.fontSize || 16), width: 100, height: shape.fontSize || 16 };
}
break;
}
return { x: 0, y: 0, width: 0, height: 0 };
}
// Combined bounding box for multiple shapes
function getCombinedBounds(shapes) {
if (shapes.length === 0)
return { x: 0, y: 0, width: 0, height: 0 };
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const s of shapes) {
const b = getBounds(s);
if (b.x < minX)
minX = b.x;
if (b.y < minY)
minY = b.y;
if (b.x + b.width > maxX)
maxX = b.x + b.width;
if (b.y + b.height > maxY)
maxY = b.y + b.height;
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
// Check if a world point is near a handle position
function hitHandle(handleWorldPos, testWorldPos, zoom) {
const threshold = (constants_1.HANDLE_SIZE + constants_1.HANDLE_SIZE) / (2 * zoom);
return Math.abs(handleWorldPos.x - testWorldPos.x) <= threshold &&
Math.abs(handleWorldPos.y - testWorldPos.y) <= threshold;
}
};
// ── module: src/render.ts ──
__mods["src/render.ts"] = function (exports, require, module) {
"use strict";
// Canvas rendering — all shapes, grid, selection, handles
Object.defineProperty(exports, "__esModule", { value: true });
exports.render = render;
const constants_1 = require("./constants");
const hittest_1 = require("./hittest");
function drawGrid(ctx, w, h, vp) {
if (vp.zoom < 0.3)
return; // too zoomed out
const alpha = Math.min(1, (vp.zoom - 0.3) * 2);
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = constants_1.COLORS.grid;
const dotSpacing = 24;
// World-space positions visible in viewport
const topLeft = { x: -vp.offsetX / vp.zoom, y: -vp.offsetY / vp.zoom };
const botRight = {
x: (w - vp.offsetX) / vp.zoom,
y: (h - vp.offsetY) / vp.zoom,
};
const startX = Math.floor(topLeft.x / dotSpacing) * dotSpacing;
const startY = Math.floor(topLeft.y / dotSpacing) * dotSpacing;
for (let wx = startX; wx < botRight.x; wx += dotSpacing) {
for (let wy = startY; wy < botRight.y; wy += dotSpacing) {
const sx = wx * vp.zoom + vp.offsetX;
const sy = wy * vp.zoom + vp.offsetY;
ctx.beginPath();
ctx.arc(sx, sy, 1.2, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
}
function drawFreehand(ctx, shape) {
if (!shape.points || shape.points.length < 2)
return;
ctx.save();
ctx.globalAlpha = shape.opacity;
ctx.strokeStyle = shape.strokeColor;
ctx.lineWidth = shape.strokeWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(shape.points[0].x, shape.points[0].y);
// Simple smoothing: use quadratic curves between midpoint pairs
if (shape.points.length === 2) {
ctx.lineTo(shape.points[1].x, shape.points[1].y);
}
else {
for (let i = 1; i < shape.points.length - 1; i++) {
const mx = (shape.points[i].x + shape.points[i + 1].x) / 2;
const my = (shape.points[i].y + shape.points[i + 1].y) / 2;
ctx.quadraticCurveTo(shape.points[i].x, shape.points[i].y, mx, my);
}
const last = shape.points[shape.points.length - 1];
ctx.lineTo(last.x, last.y);
}
ctx.stroke();
ctx.restore();
}
function drawRectangle(ctx, shape) {
if (shape.x === undefined || shape.y === undefined || shape.width === undefined || shape.height === undefined)
return;
ctx.save();
ctx.globalAlpha = shape.opacity;
const x = shape.x - shape.width / 2;
const y = shape.y - shape.height / 2;
if (shape.fillColor && shape.fillColor !== '#ffffff') {
ctx.fillStyle = shape.fillColor;
ctx.fillRect(x, y, shape.width, shape.height);
}
ctx.strokeStyle = shape.strokeColor;
ctx.lineWidth = shape.strokeWidth;
ctx.strokeRect(x, y, shape.width, shape.height);
ctx.restore();
}
function drawEllipse(ctx, shape) {
if (shape.cx === undefined || shape.cy === undefined || shape.rx === undefined || shape.ry === undefined)
return;
ctx.save();
ctx.globalAlpha = shape.opacity;
ctx.beginPath();
ctx.ellipse(shape.cx, shape.cy, shape.rx, shape.ry, 0, 0, Math.PI * 2);
if (shape.fillColor && shape.fillColor !== '#ffffff') {
ctx.fillStyle = shape.fillColor;
ctx.fill();
}
ctx.strokeStyle = shape.strokeColor;
ctx.lineWidth = shape.strokeWidth;
ctx.stroke();
ctx.restore();
}
function drawLine(ctx, shape) {
if (shape.x1 === undefined || shape.y1 === undefined || shape.x2 === undefined || shape.y2 === undefined)
return;
ctx.save();
ctx.globalAlpha = shape.opacity;
ctx.strokeStyle = shape.strokeColor;
ctx.lineWidth = shape.strokeWidth;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(shape.x1, shape.y1);
ctx.lineTo(shape.x2, shape.y2);
ctx.stroke();
// Arrow head
if (shape.type === 'arrow') {
const angle = Math.atan2(shape.y2 - shape.y1, shape.x2 - shape.x1);
const headLen = constants_1.ARROW_HEAD_SIZE;
ctx.fillStyle = shape.strokeColor;
ctx.beginPath();
ctx.moveTo(shape.x2, shape.y2);
ctx.lineTo(shape.x2 - headLen * Math.cos(angle - Math.PI / 6), shape.y2 - headLen * Math.sin(angle - Math.PI / 6));
ctx.lineTo(shape.x2 - headLen * Math.cos(angle + Math.PI / 6), shape.y2 - headLen * Math.sin(angle + Math.PI / 6));
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
function drawText(ctx, shape) {
if (shape.x === undefined || shape.y === undefined)
return;
ctx.save();
ctx.globalAlpha = shape.opacity;
const fontSize = shape.fontSize || 16;
ctx.font = `${fontSize}px sans-serif`;
ctx.fillStyle = shape.strokeColor;
const lines = (shape.text || '').split('\n');
const lineHeight = fontSize * 1.2;
ctx.textBaseline = 'top';
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], shape.x, shape.y + i * lineHeight);
}
ctx.restore();
}
function drawShape(ctx, shape) {
switch (shape.type) {
case 'freehand':
drawFreehand(ctx, shape);
break;
case 'rectangle':
drawRectangle(ctx, shape);
break;
case 'ellipse':
drawEllipse(ctx, shape);
break;
case 'line':
case 'arrow':
drawLine(ctx, shape);
break;
case 'text':
drawText(ctx, shape);
break;
}
}
function drawSelectionBox(ctx, bounds) {
ctx.save();
ctx.strokeStyle = constants_1.COLORS.selected;
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 3]);
ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
ctx.setLineDash([]);
ctx.restore();
}
function drawHandles(ctx, bounds) {
const handles = [
{ x: bounds.x, y: bounds.y }, // top-left
{ x: bounds.x + bounds.width, y: bounds.y }, // top-right
{ x: bounds.x, y: bounds.y + bounds.height }, // bottom-left
{ x: bounds.x + bounds.width, y: bounds.y + bounds.height }, // bottom-right
];
ctx.save();
ctx.fillStyle = '#fff';
ctx.strokeStyle = constants_1.COLORS.selected;
ctx.lineWidth = 2;
for (const h of handles) {
ctx.fillRect(h.x - constants_1.HANDLE_SIZE / 2, h.y - constants_1.HANDLE_SIZE / 2, constants_1.HANDLE_SIZE, constants_1.HANDLE_SIZE);
ctx.strokeRect(h.x - constants_1.HANDLE_SIZE / 2, h.y - constants_1.HANDLE_SIZE / 2, constants_1.HANDLE_SIZE, constants_1.HANDLE_SIZE);
}
ctx.restore();
}
function drawRubberBand(ctx, x1, y1, x2, y2) {
ctx.save();
ctx.strokeStyle = constants_1.COLORS.selected;
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
ctx.fillStyle = 'rgba(66,133,244,0.08)';
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
ctx.setLineDash([]);
ctx.restore();
}
// --- Main render ---
function render(ctx, shapes, vp, selectedIds, rubberBand, w, h) {
ctx.save();
ctx.clearRect(0, 0, w, h);
// Background
ctx.fillStyle = constants_1.COLORS.bg;
ctx.fillRect(0, 0, w, h);
// Transform to world
ctx.translate(vp.offsetX, vp.offsetY);
ctx.scale(vp.zoom, vp.zoom);
// Grid in world
drawGrid(ctx, w, h, vp);
// All shapes
for (const shape of shapes) {
drawShape(ctx, shape);
}
// Selection indicators (in world coords)
if (selectedIds.length > 0) {
const selected = shapes.filter(s => selectedIds.includes(s.id));
if (selected.length > 0) {
const bounds = (0, hittest_1.getCombinedBounds)(selected);
drawSelectionBox(ctx, bounds);
drawHandles(ctx, bounds);
}
}
ctx.restore();
// Rubber band (in screen coords, not transformed)
if (rubberBand) {
const x1 = Math.min(rubberBand.x1, rubberBand.x2);
const y1 = Math.min(rubberBand.y1, rubberBand.y2);
const x2 = Math.max(rubberBand.x1, rubberBand.x2);
const y2 = Math.max(rubberBand.y1, rubberBand.y2);
drawRubberBand(ctx, x1, y1, x2, y2);
}
}
};
// ── module: src/interaction.ts ──
__mods["src/interaction.ts"] = function (exports, require, module) {
"use strict";
// Interaction handler — all mouse/keyboard logic, tool behaviors
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.init = init;
exports.updateToolIndicators = updateToolIndicators;
exports.finalizeCreate = finalizeCreate;
const state = __importStar(require("./state"));
const coords_1 = require("./coords");
const hittest_1 = require("./hittest");
const render_1 = require("./render");
const constants_1 = require("./constants");
let canvas;
let ctx;
let dpr = 1;
// Interaction state
let isPanning = false;
let isDrawing = false;
let isShapeDrag = false;
let isResizing = false;
let isRubberBand = false;
let isErasing = false;
let spaceDown = false;
let lastMouseScreen = { x: 0, y: 0 };
let lastMouseWorld = { x: 0, y: 0 };
let drawPoints = [];
let shapeStart = { x: 0, y: 0 };
let rubberBandStart = null;
let resizeHandle = -1;
let shiftActive = false;
// History snapshots for drag/resize
let dragOrigShapes = [];
let dragStartWorld = { x: 0, y: 0 };
let resizeOrigBounds = { x: 0, y: 0, width: 0, height: 0 };
let resizeStartWorld = { x: 0, y: 0 };
let eraseEntry = null;
// Text editing
let textEditing = false;
function init(el) {
canvas = el;
ctx = canvas.getContext('2d');
dpr = window.devicePixelRatio || 1;
resize();
window.addEventListener('resize', resize);
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);
window.addEventListener('keyup', onKeyUp);
// Start render loop
requestAnimationFrame(frame);
}
function resize() {
dpr = window.devicePixelRatio || 1;
canvas.width = canvas.clientWidth * dpr;
canvas.height = canvas.clientHeight * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// --- Render loop ---
function frame() {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
const vp = state.getViewport();
const shapes = state.getShapes();
const selectedIds = state.getSelectedIds();
let rubberBand = null;
if (isRubberBand && rubberBandStart) {
rubberBand = {
x1: rubberBandStart.x, y1: rubberBandStart.y,
x2: lastMouseScreen.x, y2: lastMouseScreen.y,
};
}
// Draw preview shape by temporarily adding it
const preview = getPreviewShape();
const renderShapes = preview ? [...shapes, preview] : shapes;
(0, render_1.render)(ctx, renderShapes, vp, selectedIds, rubberBand, w, h);
// Draw freehand preview
if (isDrawing && drawPoints.length > 0) {
ctx.save();
ctx.translate(vp.offsetX, vp.offsetY);
ctx.scale(vp.zoom, vp.zoom);
ctx.strokeStyle = state.getStyle().strokeColor;
ctx.lineWidth = state.getStyle().strokeWidth;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.globalAlpha = 0.7;
ctx.beginPath();
ctx.moveTo(drawPoints[0].x, drawPoints[0].y);
for (let i = 1; i < drawPoints.length - 1; i++) {
const mx = (drawPoints[i].x + drawPoints[i + 1].x) / 2;
const my = (drawPoints[i].y + drawPoints[i + 1].y) / 2;
ctx.quadraticCurveTo(drawPoints[i].x, drawPoints[i].y, mx, my);
}
const last = drawPoints[drawPoints.length - 1];
if (drawPoints.length > 2) {
const prev = drawPoints[drawPoints.length - 2];
const mx = (prev.x + last.x) / 2;
const my = (prev.y + last.y) / 2;
ctx.quadraticCurveTo(prev.x, prev.y, mx, my);
}
ctx.lineTo(last.x, last.y);
ctx.stroke();
ctx.restore();
}
requestAnimationFrame(frame);
}
// --- Mouse handlers ---
function onMouseDown(e) {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const vp = state.getViewport();
const wx = (sx - vp.offsetX) / vp.zoom;
const wy = (sy - vp.offsetY) / vp.zoom;
shiftActive = e.shiftKey;
lastMouseScreen = { x: sx, y: sy };
lastMouseWorld = { x: wx, y: wy };
const tool = state.getTool();
// Middle mouse or Space+drag = pan
if (e.button === 1 || (e.button === 0 && spaceDown)) {
isPanning = true;
canvas.style.cursor = 'grabbing';
e.preventDefault();
return;
}
if (e.button !== 0)
return;
switch (tool) {
case 'select':
handleSelectDown(sx, sy, wx, wy);
break;
case 'eraser':
handleEraserDown(wx, wy);
break;
case 'text':
handleTextDown(wx, wy);
break;
default:
handleCreateDown(sx, sy, wx, wy);
break;
}
}
function onMouseMove(e) {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const vp = state.getViewport();
const wx = (sx - vp.offsetX) / vp.zoom;
const wy = (sy - vp.offsetY) / vp.zoom;
lastMouseScreen = { x: sx, y: sy };
lastMouseWorld = { x: wx, y: wy };
if (isPanning) {
// lastMouseScreen still has the previous frame value at this point
const dx = sx - lastMouseScreen.x;
const dy = sy - lastMouseScreen.y;
state.setViewport({ ...vp, offsetX: vp.offsetX + dx, offsetY: vp.offsetY + dy });
lastMouseScreen = { x: sx, y: sy };
return;
}
if (isDrawing) {
// Only add point if far enough from last point (thinning)
const last = drawPoints[drawPoints.length - 1];
if (Math.hypot(wx - last.x, wy - last.y) > 1 / Math.max(vp.zoom, 0.5)) {
drawPoints.push({ x: wx, y: wy });
}
return;
}
if (isShapeDrag) {
handleMoveDrag(wx, wy);
return;
}
if (isResizing) {
handleResizeDrag(wx, wy);
return;
}
if (isErasing) {
eraseAt(wx, wy);
return;
}
}
function onMouseUp(e) {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const vp = state.getViewport();
const wx = (sx - vp.offsetX) / vp.zoom;
const wy = (sy - vp.offsetY) / vp.zoom;
lastMouseScreen = { x: sx, y: sy };
lastMouseWorld = { x: wx, y: wy };
if (isPanning) {
isPanning = false;
canvas.style.cursor = '';
return;
}
if (isDrawing) {
finishDrawing();
return;
}
if (isShapeDrag) {
state.pushHistoryEntry({
shapesBefore: JSON.parse(JSON.stringify(dragOrigShapes)),
shapesAfter: JSON.parse(JSON.stringify(state.getShapes())),
});
state.persist();
isShapeDrag = false;
return;
}
if (isResizing) {
state.pushHistoryEntry({
shapesBefore: JSON.parse(JSON.stringify(dragOrigShapes)),
shapesAfter: JSON.parse(JSON.stringify(state.getShapes())),
});
state.persist();
isResizing = false;
resizeHandle = -1;
return;
}
if (isRubberBand) {
finishRubberBand();
isRubberBand = false;
rubberBandStart = null;
return;
}
if (isErasing) {
if (eraseEntry) {
eraseEntry.shapesAfter = JSON.parse(JSON.stringify(state.getShapes()));
state.pushHistoryEntry(eraseEntry);
state.persist();
eraseEntry = null;
}
isErasing = false;
return;
}
// Check if we did a shape creation drag (not drawing, not selecting, not erasing)
const tool = state.getTool();
if (tool === 'rectangle' || tool === 'ellipse' || tool === 'line') {
finalizeCreate();
state.setTool('select');
updateToolIndicators();
updateCursor();
}
}
function onWheel(e) {
e.preventDefault();
const vp = state.getViewport();
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const factor = e.deltaY < 0 ? constants_1.ZOOM_STEP : 1 / constants_1.ZOOM_STEP;
const newZoom = Math.max(constants_1.ZOOM_MIN, Math.min(constants_1.ZOOM_MAX, vp.zoom * factor));
// Zoom toward cursor
const worldX = (sx - vp.offsetX) / vp.zoom;
const worldY = (sy - vp.offsetY) / vp.zoom;
state.setViewport({
offsetX: sx - worldX * newZoom,
offsetY: sy - worldY * newZoom,
zoom: newZoom,
});
}
// --- Keyboard ---
function onKeyDown(e) {
if (textEditing)
return;
if (e.code === 'Space' && !e.repeat) {
spaceDown = true;
if (!isPanning)
canvas.style.cursor = 'grab';
e.preventDefault();
}
if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault();
state.undo();
updateUndoRedoButtons();
}
if ((e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey) ||
(e.key === 'y' && (e.ctrlKey || e.metaKey))) {
e.preventDefault();
state.redo();
updateUndoRedoButtons();
}
if ((e.key === 'Delete' || e.key === 'Backspace') && state.getSelectedIds().length > 0) {
e.preventDefault();
state.deleteSelected();
state.persist();
}
if (!e.ctrlKey && !e.metaKey && state.getSelectedIds().length === 0) {
switch (e.key.toLowerCase()) {
case 'v':
state.setTool('select');
break;
case 'p':
state.setTool('freehand');
break;
case 'r':
state.setTool('rectangle');
break;
case 'o':
state.setTool('ellipse');
break;
case 'l':
state.setTool('line');
break;
case 't':
state.setTool('text');
break;
case 'e':
state.setTool('eraser');
break;
}
updateToolIndicators();
updateCursor();
}
// Arrow key nudging
if (state.getSelectedIds().length > 0 && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
e.preventDefault();
const nudge = e.shiftKey ? 10 : 1;
let dx = 0, dy = 0;
if (e.key === 'ArrowUp')
dy = -nudge;
if (e.key === 'ArrowDown')
dy = nudge;
if (e.key === 'ArrowLeft')
dx = -nudge;
if (e.key === 'ArrowRight')
dx = nudge;
nudgeSelection(dx, dy);
}
}
function onKeyUp(e) {
if (e.code === 'Space') {
spaceDown = false;
canvas.style.cursor = '';
updateCursor();
}
}
function nudgeSelection(dx, dy) {
const shapes = state.getShapes();
const ids = state.getSelectedIds();
if (ids.length === 0)
return;
const entry = {
shapesBefore: JSON.parse(JSON.stringify(shapes)),
shapesAfter: JSON.parse(JSON.stringify(shapes)),
};
for (const s of shapes) {
if (!ids.includes(s.id))
continue;
nudgeShape(s, dx, dy);
}
entry.shapesAfter = JSON.parse(JSON.stringify(shapes));
state.pushHistoryEntry(entry);
state.persist();
}
function nudgeShape(s, dx, dy) {
switch (s.type) {
case 'rectangle':
if (s.x !== undefined && s.y !== undefined) {
s.x += dx;
s.y += dy;
}
break;
case 'ellipse':
if (s.cx !== undefined && s.cy !== undefined) {
s.cx += dx;
s.cy += dy;
}
break;
case 'freehand':
if (s.points) {
for (const p of s.points) {
p.x += dx;
p.y += dy;
}
}
break;
case 'line':
case 'arrow':
if (s.x1 !== undefined && s.y1 !== undefined && s.x2 !== undefined && s.y2 !== undefined) {
s.x1 += dx;
s.y1 += dy;
s.x2 += dx;
s.y2 += dy;
}
break;
case 'text':
if (s.x !== undefined && s.y !== undefined) {
s.x += dx;
s.y += dy;
}
break;
}
}
// --- Select tool ---
function handleSelectDown(sx, sy, wx, wy) {
const shapes = state.getShapes();
const vp = state.getViewport();
// Check handles first
const selectedIds = state.getSelectedIds();
if (selectedIds.length > 0) {
const selected = shapes.filter(s => selectedIds.includes(s.id));
const bounds = (0, hittest_1.getCombinedBounds)(selected);
const handles = [
{ x: bounds.x, y: bounds.y },
{ x: bounds.x + bounds.width, y: bounds.y },
{ x: bounds.x, y: bounds.y + bounds.height },
{ x: bounds.x + bounds.width, y: bounds.y + bounds.height },
];
for (let i = 0; i < handles.length; i++) {
if ((0, hittest_1.hitHandle)(handles[i], { x: wx, y: wy }, vp.zoom)) {
isResizing = true;
resizeHandle = i;
resizeOrigBounds = JSON.parse(JSON.stringify(bounds));
resizeStartWorld = { x: wx, y: wy };
dragOrigShapes = JSON.parse(JSON.stringify(shapes));
return;
}
}
}
// Hit test shapes (top first)
for (let i = shapes.length - 1; i >= 0; i--) {
if ((0, hittest_1.hitTest)(shapes[i], wx, wy, ctx)) {
isShapeDrag = true;
if (shiftActive) {
state.select(shapes[i].id, true);
}
else {
state.select(shapes[i].id, false);
}
dragStartWorld = { x: wx, y: wy };
dragOrigShapes = JSON.parse(JSON.stringify(shapes));
return;
}
}
// Empty space — rubber band or deselect
isRubberBand = true;
rubberBandStart = { x: sx, y: sy };
if (!shiftActive) {
state.deselectAll();
}
}
function handleMoveDrag(wx, wy) {
const dwx = wx - dragStartWorld.x;
const dwy = wy - dragStartWorld.y;
const shapes = state.getShapes();
const ids = state.getSelectedIds();
for (let i = 0; i < dragOrigShapes.length; i++) {
if (!ids.includes(dragOrigShapes[i].id))
continue;
const current = shapes.find(s => s.id === dragOrigShapes[i].id);
if (!current)
continue;
const orig = dragOrigShapes[i];
moveShapeTo(current, orig, dwx, dwy);
}
}
function moveShapeTo(current, orig, dx, dy) {
switch (current.type) {
case 'rectangle':
if (current.x !== undefined && current.y !== undefined) {
current.x = orig.x + dx;
current.y = orig.y + dy;
}
break;
case 'ellipse':
if (current.cx !== undefined && current.cy !== undefined) {
current.cx = orig.cx + dx;
current.cy = orig.cy + dy;
}
break;
case 'freehand':
if (current.points && orig.points) {
for (let i = 0; i < orig.points.length; i++) {
current.points[i].x = orig.points[i].x + dx;
current.points[i].y = orig.points[i].y + dy;
}
}
break;
case 'line':
case 'arrow':
if (current.x1 !== undefined && current.y1 !== undefined && current.x2 !== undefined && current.y2 !== undefined) {
current.x1 = orig.x1 + dx;
current.y1 = orig.y1 + dy;
current.x2 = orig.x2 + dx;
current.y2 = orig.y2 + dy;
}
break;
case 'text':
if (current.x !== undefined && current.y !== undefined) {
current.x = orig.x + dx;
current.y = orig.y + dy;
}
break;
}
}
function handleResizeDrag(wx, wy) {
const shapes = state.getShapes();
const ids = state.getSelectedIds();
let newX = resizeOrigBounds.x;
let newY = resizeOrigBounds.y;
let newW = resizeOrigBounds.width;
let newH = resizeOrigBounds.height;
const dx = wx - resizeStartWorld.x;
const dy = wy - resizeStartWorld.y;
switch (resizeHandle) {
case 0: // TL
newX = resizeOrigBounds.x + dx;
newY = resizeOrigBounds.y + dy;
newW = resizeOrigBounds.width - dx;
newH = resizeOrigBounds.height - dy;
break;
case 1: // TR
newY = resizeOrigBounds.y + dy;
newW = resizeOrigBounds.width + dx;
newH = resizeOrigBounds.height - dy;
break;
case 2: // BL
newX = resizeOrigBounds.x + dx;
newW = resizeOrigBounds.width - dx;
newH = resizeOrigBounds.height + dy;
break;
case 3: // BR
newW = resizeOrigBounds.width + dx;
newH = resizeOrigBounds.height + dy;
break;
}
const scaleX = resizeOrigBounds.width !== 0 ? newW / resizeOrigBounds.width : 1;
const scaleY = resizeOrigBounds.height !== 0 ? newH / resizeOrigBounds.height : 1;
for (const orig of dragOrigShapes) {
if (!ids.includes(orig.id))
continue;
const current = shapes.find(s => s.id === orig.id);
if (!current)
continue;
resizeShape(current, orig, resizeOrigBounds, scaleX, scaleY);
}
}
function resizeShape(current, orig, bounds, sx, sy) {
switch (current.type) {
case 'rectangle': {
if (orig.x !== undefined && orig.width !== undefined && orig.height !== undefined) {
const cx = orig.x - bounds.x;
const cy = orig.y - bounds.y;
current.width = orig.width * sx;
current.height = orig.height * sy;
current.x = bounds.x + cx;
current.y = bounds.y + cy;
}
break;
}
case 'ellipse': {
if (orig.cx !== undefined && orig.rx !== undefined && orig.ry !== undefined) {
const cx = orig.cx - bounds.x;
const cy = orig.cy - bounds.y;
current.rx = orig.rx * sx;
current.ry = orig.ry * sy;
current.cx = bounds.x + cx;
current.cy = bounds.y + cy;
}
break;
}
case 'freehand': {
if (current.points && orig.points) {
for (let i = 0; i < orig.points.length; i++) {
const lx = orig.points[i].x - bounds.x;
const ly = orig.points[i].y - bounds.y;
current.points[i].x = bounds.x + lx * sx;
current.points[i].y = bounds.y + ly * sy;
}
}
break;
}
case 'line':
case 'arrow': {
if (orig.x1 !== undefined && orig.y1 !== undefined && orig.x2 !== undefined && orig.y2 !== undefined) {
const lx1 = orig.x1 - bounds.x;
const ly1 = orig.y1 - bounds.y;
const lx2 = orig.x2 - bounds.x;
const ly2 = orig.y2 - bounds.y;
current.x1 = bounds.x + lx1 * sx;
current.y1 = bounds.y + ly1 * sy;
current.x2 = bounds.x + lx2 * sx;
current.y2 = bounds.y + ly2 * sy;
}
break;
}
case 'text': {
if (orig.x !== undefined && orig.y !== undefined) {
const lx = orig.x - bounds.x;
const ly = orig.y - bounds.y;
current.x = bounds.x + lx * sx;
current.y = bounds.y + ly * sy;
}
break;
}
}
}
function finishRubberBand() {
if (!rubberBandStart)
return;
const shapes = state.getShapes();
const x1 = Math.min(rubberBandStart.x, lastMouseScreen.x);
const y1 = Math.min(rubberBandStart.y, lastMouseScreen.y);
const x2 = Math.max(rubberBandStart.x, lastMouseScreen.x);
const y2 = Math.max(rubberBandStart.y, lastMouseScreen.y);
const vp = state.getViewport();
const w1 = (x1 - vp.offsetX) / vp.zoom;
const t1 = (y1 - vp.offsetY) / vp.zoom;
const w2 = (x2 - vp.offsetX) / vp.zoom;
const t2 = (y2 - vp.offsetY) / vp.zoom;
const ids = [];
for (const s of shapes) {
const b = (0, hittest_1.getCombinedBounds)([s]);
const cx = b.x + b.width / 2;
const cy = b.y + b.height / 2;
if (cx >= w1 && cx <= w2 && cy >= t1 && cy <= t2) {
ids.push(s.id);
}
}
state.setSelection(ids);
}
// --- Create tools ---
function handleCreateDown(sx, sy, wx, wy) {
const tool = state.getTool();
if (tool === 'freehand') {
isDrawing = true;
drawPoints = [{ x: wx, y: wy }];
}
else {
shapeStart = { x: wx, y: wy };
}
}
function finishDrawing() {
if (drawPoints.length < 2) {
isDrawing = false;
drawPoints = [];
return;
}
const shapes = state.getShapes();
const shape = state.createShape({ type: 'freehand', points: [...drawPoints] });
state.select(shape.id, false);
state.pushHistoryEntry({
shapesBefore: JSON.parse(JSON.stringify(shapes)),
shapesAfter: JSON.parse(JSON.stringify(state.getShapes())),
});
state.persist();
isDrawing = false;
drawPoints = [];
}
function getPreviewShape() {
const tool = state.getTool();
if (tool === 'select' || tool === 'eraser' || tool === 'text')
return null;
if (tool === 'freehand')
return null;
if (isDrawing || isShapeDrag || isResizing)
return null;
const sx = lastMouseWorld.x;
const sy = lastMouseWorld.y;
const sx0 = shapeStart.x;
const sy0 = shapeStart.y;
const style = state.getStyle();
const partial = {
strokeColor: style.strokeColor,
fillColor: style.fillColor,
strokeWidth: style.strokeWidth,
opacity: 0.5,
};
if (tool === 'rectangle') {
return {
...partial,
id: '__preview__',
type: 'rectangle',
x: (sx0 + sx) / 2,
y: (sy0 + sy) / 2,
width: sx - sx0,
height: sy - sy0,
};
}
else if (tool === 'ellipse') {
return {
...partial,
id: '__preview__',
type: 'ellipse',
cx: (sx0 + sx) / 2,
cy: (sy0 + sy) / 2,
rx: Math.abs(sx - sx0) / 2,
ry: Math.abs(sy - sy0) / 2,
};
}
else if (tool === 'line') {
return {
...partial,
id: '__preview__',
type: 'line',
x1: sx0, y1: sy0, x2: sx, y2: sy,
};
}
return null;
}
// Finalize shape creation on mouse up
const origOnMouseUp = onMouseUp;
// --- Eraser ---
function handleEraserDown(wx, wy) {
isErasing = true;
eraseEntry = null;
eraseAt(wx, wy);
}
function eraseAt(wx, wy) {
const shapes = state.getShapes();
const vp = state.getViewport();
// Eraser has wider hit tolerance scaled by zoom
const eraserRadius = 12 / vp.zoom;
for (let i = shapes.length - 1; i >= 0; i--) {
// Simple distance check to shape center + extended bounds
const b = (0, hittest_1.getCombinedBounds)([shapes[i]]);
const cx = b.x + b.width / 2;
const cy = b.y + b.height / 2;
const maxDim = Math.max(b.width, b.height) / 2 + eraserRadius;
if (Math.abs(wx - cx) > maxDim && Math.abs(wy - cy) > maxDim)
continue;
if ((0, hittest_1.hitTest)(shapes[i], wx, wy, ctx)) {
if (!eraseEntry) {
eraseEntry = {
shapesBefore: JSON.parse(JSON.stringify(state.getShapes())),
shapesAfter: [],
};
}
state.deleteShape(shapes[i].id);
break;
}
}
}
// --- Text tool ---
function handleTextDown(wx, wy) {
state.setTool('select');
updateToolIndicators();
updateCursor();
const style = state.getStyle();
const shapes = state.getShapes();
const shape = state.createShape({
type: 'text',
x: wx, y: wy,
text: '',
fontSize: 16,
strokeColor: style.strokeColor,
fillColor: '#ffffff',
strokeWidth: 0,
});
showTextInput(shape);
// Commit creation to history when text is done
shape._commitFn = () => {
state.pushHistoryEntry({
shapesBefore: [],
shapesAfter: [shape],
});
state.persist();
};
}
function showTextInput(shape) {
textEditing = true;
const vp = state.getViewport();
const sp = (0, coords_1.worldToScreen)(shape.x || 0, shape.y || 0, vp);
const textarea = document.createElement('textarea');
textarea.value = shape.text || '';
Object.assign(textarea.style, {
position: 'fixed',
left: sp.x + 'px',
top: sp.y + 'px',
fontSize: (shape.fontSize || 16) + 'px',
color: shape.strokeColor,
border: `2px solid ${constants_1.COLORS.selected}`,
borderRadius: '4px',
padding: '4px 8px',
minWidth: '120px',
background: '#fff',
outline: 'none',
zIndex: '1000',
fontFamily: 'sans-serif',
lineHeight: '1.2',
resize: 'none',
});
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const commit = () => {
if (document.body.contains(textarea)) {
document.body.removeChild(textarea);
}
textEditing = false;
if (!textarea.value.trim()) {
state.deleteShape(shape.id);
}
else {
shape.text = textarea.value;
if (typeof shape._commitFn === 'function') {
shape._commitFn();
}
}
};
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
commit();
e.preventDefault();
e.stopPropagation();
}
});
textarea.addEventListener('blur', commit);
textarea.addEventListener('input', () => {
shape.text = textarea.value;
});
}
// --- UI helpers ---
function updateCursor() {
if (spaceDown) {
canvas.style.cursor = 'grab';
return;
}
const tool = state.getTool();
switch (tool) {
case 'select':
canvas.style.cursor = 'default';
break;
case 'freehand':
canvas.style.cursor = 'crosshair';
break;
case 'rectangle':
case 'ellipse':
case 'line':
canvas.style.cursor = 'crosshair';
break;
case 'text':
canvas.style.cursor = 'text';
break;
case 'eraser':
canvas.style.cursor = 'pointer';
break;
}
}
function updateToolIndicators() {
const tool = state.getTool();
document.querySelectorAll('.tool-btn').forEach(btn => {
const t = btn.getAttribute('data-tool');
btn.classList.toggle('active', t === tool);
});
}
function updateUndoRedoButtons() {
const undoBtn = document.getElementById('btn-undo');
const redoBtn = document.getElementById('btn-redo');
if (undoBtn)
undoBtn.disabled = !state.canUndo();
if (redoBtn)
redoBtn.disabled = !state.canRedo();
}
// Expose shape creation finalization — called from render preview
function finalizeCreate() {
const tool = state.getTool();
if (tool === 'select' || tool === 'freehand' || tool === 'eraser' || tool === 'text')
return;
const sx = lastMouseWorld.x;
const sy = lastMouseWorld.y;
const sx0 = shapeStart.x;
const sy0 = shapeStart.y;
if (tool === 'rectangle') {
const shapes = state.getShapes();
const shape = state.createShape({
type: 'rectangle',
x: (sx0 + sx) / 2,
y: (sy0 + sy) / 2,
width: sx - sx0,
height: sy - sy0,
});
state.select(shape.id, false);
state.pushHistoryEntry({
shapesBefore: JSON.parse(JSON.stringify(shapes)),
shapesAfter: JSON.parse(JSON.stringify(state.getShapes())),
});
state.persist();
}
else if (tool === 'ellipse') {
const shapes = state.getShapes();
const shape = state.createShape({
type: 'ellipse',
cx: (sx0 + sx) / 2,
cy: (sy0 + sy) / 2,
rx: Math.abs(sx - sx0) / 2,
ry: Math.abs(sy - sy0) / 2,
});
state.select(shape.id, false);
state.pushHistoryEntry({
shapesBefore: JSON.parse(JSON.stringify(shapes)),
shapesAfter: JSON.parse(JSON.stringify(state.getShapes())),
});
state.persist();
}
else if (tool === 'line') {
const shapes = state.getShapes();
const shape = state.createShape({
type: 'line',
x1: sx0, y1: sy0, x2: sx, y2: sy,
});
state.select(shape.id, false);
state.pushHistoryEntry({
shapesBefore: JSON.parse(JSON.stringify(shapes)),
shapesAfter: JSON.parse(JSON.stringify(state.getShapes())),
});
state.persist();
}
}
// We need finalizeCreate to run on mouse up for create tools
// Let me patch onMouseUp — actually it already falls through, I need to add the call
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"ThinkingCap-Qwen3.6-27B-GGUF","provider":"brigid","persona":"typescript","sessionId":"e01825c1-5f6b-44f6-8e67-cccfa49bf9fd","tokensIn":1590074,"tokensOut":42274,"tokensTotal":1632348,"cost":0,"turns":40,"toolCalls":44,"failedToolCalls":1,"timestamp":"2026-08-03T21:58:37.223Z"} -->