Whiteboard
Qwen 3.6 27B MTP 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,228 bytes · SHA-256 88fccda89aba
<!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 ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
overflow: hidden;
background: #e8eaed;
color: #1e1e1e;
user-select: none;
-webkit-user-select: none;
}
#canvas-container {
position: fixed;
top: 0; left: 0;
width: 100vw; height: 100vh;
}
#whiteboard {
display: block;
width: 100%; height: 100%;
}
/* Toolbar */
.floating-toolbar {
position: fixed;
top: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 2px;
padding: 6px 10px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.05);
z-index: 100;
white-space: nowrap;
}
.tool-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: transparent;
color: #5f6368;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.tool-btn:hover { background: #f1f3f4; color: #1e1e1e; }
.tool-btn.active { background: #e8f0fe; color: #1967d2; }
.tool-btn svg { pointer-events: none; }
.tool-sep {
width: 1px;
height: 24px;
background: #e2e5e9;
margin: 0 4px;
}
/* Color pickers */
#color-pick {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 4px;
}
.color-label {
font-size: 9px;
font-weight: 600;
color: #999;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.color-row {
display: flex;
gap: 3px;
}
.color-dot {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: border-color 0.15s, transform 0.15s;
}
.color-dot:hover { transform: scale(1.2); }
.color-dot.active { border-color: #1967d2; transform: scale(1.15); }
/* Stroke widths */
.sw-row {
display: flex;
gap: 4px;
align-items: center;
}
.sw-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
cursor: pointer;
transition: border-color 0.15s;
}
.sw-btn:hover { border-color: #ddd; }
.sw-btn.active { border-color: #1967d2; background: #e8f0fe; }
.sw-btn .dot {
border-radius: 50%;
background: #1e1e1e;
}
/* Zoom controls */
#zoom-controls {
position: fixed;
bottom: 16px;
right: 16px;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: #ffffff;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1), 0 0 0 1px rgba(0,0,0,0.05);
z-index: 100;
}
#zoom-controls button {
width: 30px;
height: 30px;
border: none;
border-radius: 6px;
background: transparent;
color: #5f6368;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
#zoom-controls button:hover { background: #f1f3f4; color: #1e1e1e; }
#zoom-level {
font-size: 12px;
font-weight: 600;
color: #5f6368;
min-width: 42px;
text-align: center;
}
/* Hint bar */
#hint-bar {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
color: #aaa;
background: rgba(255,255,255,0.85);
padding: 6px 14px;
border-radius: 8px;
backdrop-filter: blur(4px);
z-index: 100;
pointer-events: none;
transition: opacity 0.5s;
}
</style>
</head>
<body>
<div id="toolbar" class="floating-toolbar">
<button data-tool="select" class="tool-btn active" title="Select (V)">
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M4 4l8 16 3-7 7-3z"></path></svg>
</button>
<button data-tool="pen" class="tool-btn" title="Pen (P)">
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25z"></path></svg>
</button>
<button data-tool="rect" class="tool-btn" title="Rectangle (R)">
<svg viewBox="0 0 24 24" width="20" height="20"><rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2"></rect></svg>
</button>
<button data-tool="ellipse" class="tool-btn" title="Ellipse (O)">
<svg viewBox="0 0 24 24" width="20" height="20"><ellipse cx="12" cy="12" rx="9" ry="9" fill="none" stroke="currentColor" stroke-width="2"></ellipse></svg>
</button>
<button data-tool="line" class="tool-btn" title="Line (L)">
<svg viewBox="0 0 24 24" width="20" height="20"><line x1="4" y1="20" x2="20" y2="4" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line></svg>
</button>
<button data-tool="text" class="tool-btn" title="Text (T)">
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M5 4v3h5.5v12h3V7H19V4z"></path></svg>
</button>
<button data-tool="eraser" class="tool-btn" title="Eraser (E)">
<svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M16.24 3.56l4.95 4.94c.78.79.78 2.05 0 2.84L12 20.41a4.01 4.01 0 01-5.66 0L2.81 16.88c-.78-.78-.78-2.05 0-2.83l8.49-8.49c.78-.78 2.05-.78 2.84 0l2.1 2.1z"></path></svg>
</button>
<div class="tool-sep"></div>
<button id="undo-btn" title="Undo (Ctrl+Z)">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M12.5 8c-2.65 0-5.05 1.04-6.83 2.73L2 7.14V16h8.86l-3.67-3.67A7.46 7.46 0 0112.5 10c3.04 0 5.64 1.85 6.67 4.48l1.95-.83C20.09 10.25 16.6 8 12.5 8z"></path></svg>
</button>
<button id="redo-btn" title="Redo (Ctrl+Shift+Z)">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M18.4 10.03C16.57 8.36 14.18 7.19 11.5 7.19V4.16l5.71 5.71-.81.16zM12.5 8c-3.04 0-5.64 1.85-6.67 4.48l-1.95-.83C4.91 8.25 8.4 6 12.5 6c2.65 0 5.05 1.04 6.83 2.73l2.67-2.67V16h-8.86l3.67-3.67A7.46 7.46 0 0112.5 10z"></path></svg>
</button>
<div class="tool-sep"></div>
<div id="color-pick">
<label class="color-label" title="Stroke color">Stroke</label>
<div id="stroke-colors" class="color-row"></div>
<label class="color-label" title="Fill color">Fill</label>
<div id="fill-colors" class="color-row"></div>
</div>
<div class="tool-sep"></div>
<div id="stroke-widths" class="sw-row"></div>
</div>
<div id="canvas-container">
<canvas id="whiteboard"></canvas>
</div>
<div id="zoom-controls">
<button id="zoom-out-btn" title="Zoom Out">−</button>
<span id="zoom-level">100%</span>
<button id="zoom-in-btn" title="Zoom In">+</button>
<button id="reset-view-btn" title="Reset View">⌂</button>
</div>
<div id="hint-bar">Space+drag to pan · Scroll to zoom · Delete to remove · V/P/R/O/L/T/E for tools</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./constants":"src/constants.ts","./state":"src/state.ts","./renderer":"src/renderer.ts","./interactions":"src/interactions.ts","./types":"src/types.ts"},"src/state.ts":{"./constants":"src/constants.ts","./types":"src/types.ts"},"src/hitTest.ts":{"./constants":"src/constants.ts","./types":"src/types.ts","./state":"src/state.ts"},"src/renderer.ts":{"./constants":"src/constants.ts","./types":"src/types.ts","./state":"src/state.ts","./hitTest":"src/hitTest.ts"},"src/interactions.ts":{"./constants":"src/constants.ts","./types":"src/types.ts","./hitTest":"src/hitTest.ts","./state":"src/state.ts","./renderer":"src/renderer.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";
// ===== Main Entry =====
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 constants_1 = require("./constants");
const state_1 = require("./state");
const renderer_1 = require("./renderer");
const interactions_1 = require("./interactions");
function start() {
(0, state_1.loadState)();
const canvas = document.getElementById('whiteboard');
(0, renderer_1.initRenderer)(canvas);
(0, interactions_1.initInteractions)(canvas);
// Center viewport
state_1.viewport.x = canvas.clientWidth / 2;
state_1.viewport.y = canvas.clientHeight / 2;
buildColorPalette('stroke-colors', constants_1.STROKE_COLORS, constants_1.DEFAULT_STROKE_COLOR, (color) => {
(0, state_1.setStrokeColor)(color);
updateActiveColorDots('stroke-colors', color);
});
buildColorPalette('fill-colors', constants_1.FILL_COLORS, constants_1.DEFAULT_FILL_COLOR, (color) => {
(0, state_1.setFillColor)(color);
updateActiveColorDots('fill-colors', color);
});
buildStrokeWidths(constants_1.DEFAULT_STROKE_WIDTH);
// Tool buttons
document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => {
btn.addEventListener('click', () => {
const tool = btn.dataset.tool;
(0, interactions_1.setTool)(tool);
updateActiveToolBtn(tool);
});
});
// Undo / Redo
document.getElementById('undo-btn')?.addEventListener('click', () => {
Promise.resolve().then(() => __importStar(require('./state'))).then(m => { m.undo(); (0, renderer_1.render)(null); });
});
document.getElementById('redo-btn')?.addEventListener('click', () => {
Promise.resolve().then(() => __importStar(require('./state'))).then(m => { m.redo(); (0, renderer_1.render)(null); });
});
// Zoom
document.getElementById('zoom-in-btn')?.addEventListener('click', () => zoomBy(1.25));
document.getElementById('zoom-out-btn')?.addEventListener('click', () => zoomBy(0.8));
document.getElementById('reset-view-btn')?.addEventListener('click', interactions_1.resetView);
window.addEventListener('resize', () => { (0, renderer_1.resize)(); (0, renderer_1.render)((0, interactions_1.getPreview)()); });
// Hide hint
setTimeout(() => {
const hint = document.getElementById('hint-bar');
if (hint)
hint.style.opacity = '0';
}, 5000);
(0, interactions_1.updateZoomIndicator)();
(0, renderer_1.render)(null);
}
function buildColorPalette(containerId, colors, defaultColor, onPick) {
const container = document.getElementById(containerId);
if (!container)
return;
for (const color of colors) {
const dot = document.createElement('div');
dot.className = 'color-dot' + (color === defaultColor ? ' active' : '');
dot.dataset.color = color;
if (color === 'none') {
dot.style.background = 'transparent';
dot.style.border = '2px solid #ccc';
dot.style.position = 'relative';
dot.innerHTML = '<svg style="position:absolute;top:2px;left:3px" width="10" height="10" viewBox="0 0 10 10"><line x1="1" y1="1" x2="9" y2="9" stroke="#999" stroke-width="1.5"/></svg>';
}
else {
dot.style.background = color;
}
dot.addEventListener('click', () => onPick(color));
container.appendChild(dot);
}
}
function updateActiveColorDots(containerId, activeColor) {
const container = document.getElementById(containerId);
if (!container)
return;
container.querySelectorAll('.color-dot').forEach(dot => {
dot.classList.toggle('active', dot.dataset.color === activeColor);
});
}
function buildStrokeWidths(defaultWidth) {
const container = document.getElementById('stroke-widths');
if (!container)
return;
for (const w of constants_1.STROKE_WIDTHS) {
const btn = document.createElement('button');
btn.className = 'sw-btn' + (w === defaultWidth ? ' active' : '');
btn.dataset.width = String(w);
const dot = document.createElement('span');
dot.className = 'dot';
const size = Math.max(4, w * 2);
dot.style.width = `${size}px`;
dot.style.height = `${size}px`;
btn.appendChild(dot);
btn.addEventListener('click', () => {
(0, state_1.setStrokeWidth)(w);
container.querySelectorAll('.sw-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
container.appendChild(btn);
}
}
function updateActiveToolBtn(tool) {
document.querySelectorAll('.tool-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tool === tool);
});
}
function zoomBy(factor) {
const canvas = document.getElementById('whiteboard');
const cx = canvas.clientWidth / 2;
const cy = canvas.clientHeight / 2;
const oldZoom = state_1.viewport.zoom;
let newZoom = oldZoom * factor;
newZoom = Math.max(0.25, Math.min(4, newZoom));
const worldX = (cx - state_1.viewport.x) / oldZoom;
const worldY = (cy - state_1.viewport.y) / oldZoom;
state_1.viewport.x = cx - worldX * newZoom;
state_1.viewport.y = cy - worldY * newZoom;
state_1.viewport.zoom = newZoom;
(0, renderer_1.render)((0, interactions_1.getPreview)());
(0, interactions_1.updateZoomIndicator)();
}
start();
};
// ── module: src/constants.ts ──
__mods["src/constants.ts"] = function (exports, require, module) {
"use strict";
// ===== Tunable Constants =====
Object.defineProperty(exports, "__esModule", { value: true });
exports.LOCAL_STORAGE_KEY = exports.DEFAULT_STROKE_WIDTH = exports.DEFAULT_FILL_COLOR = exports.DEFAULT_STROKE_COLOR = exports.NUDGE_SHIFT_AMOUNT = exports.NUDGE_AMOUNT = exports.SELECTION_BORDER = exports.HANDLE_SIZE = exports.HIT_TOLERANCE = exports.ZOOM_STEP = exports.ZOOM_FACTOR = exports.ZOOM_MAX = exports.ZOOM_MIN = exports.STROKE_WIDTHS = exports.FILL_COLORS = exports.STROKE_COLORS = exports.COLORS = void 0;
exports.COLORS = {
canvasBg: '#f8f9fa',
chromeBg: '#ffffff',
chromeBorder: '#e2e5e9',
toolbarShadow: 'rgba(0,0,0,0.12)',
gridDot: '#d4d7dc',
selectionBox: '#2970ff',
selectionHandle: '#ffffff',
previewShape: '#2970ff',
activeToolBg: '#e8f0fe',
activeToolBorder: '#2970ff',
textOverlay: '#ffffff',
textCaret: '#2970ff',
rubberBand: 'rgba(41,112,255,0.1)',
rubberBandBorder: '#2970ff',
eraserCursor: '#ff6b6b',
};
exports.STROKE_COLORS = [
'#1e1e1e', '#e74c3c', '#e67e22', '#f1c40f', '#27ae60',
'#2980b9', '#8e44ad', '#2c3e50', '#7f8c8d', '#ecf0f1',
];
exports.FILL_COLORS = [
'none', '#fee2e2', '#fef3c7', '#d1fae5', '#dbeafe',
'#ede9fe', '#fce7f3', '#e0e7ff', '#f3e8ff', '#fff7ed',
];
exports.STROKE_WIDTHS = [1, 2, 3, 4, 6, 8];
exports.ZOOM_MIN = 0.25;
exports.ZOOM_MAX = 4;
exports.ZOOM_FACTOR = 1.001; // per scroll tick multiplier
exports.ZOOM_STEP = 1.25; // for buttons
exports.HIT_TOLERANCE = 8; // px for hit testing strokes
exports.HANDLE_SIZE = 8; // corner handle size in world coords
exports.SELECTION_BORDER = 2;
exports.NUDGE_AMOUNT = 1;
exports.NUDGE_SHIFT_AMOUNT = 10;
exports.DEFAULT_STROKE_COLOR = '#1e1e1e';
exports.DEFAULT_FILL_COLOR = 'none';
exports.DEFAULT_STROKE_WIDTH = 2;
exports.LOCAL_STORAGE_KEY = 'whiteboard-data';
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// ===== Types & Data Model =====
Object.defineProperty(exports, "__esModule", { value: true });
};
// ── module: src/state.ts ──
__mods["src/state.ts"] = function (exports, require, module) {
"use strict";
// ===== State Management: scene, viewport, tools, selection, history =====
Object.defineProperty(exports, "__esModule", { value: true });
exports.textInputObjectId = exports.textInputActive = exports.currentTool = exports.viewport = exports.selectedIds = exports.scene = void 0;
exports.nextId = nextId;
exports.getStrokeColor = getStrokeColor;
exports.getFillColor = getFillColor;
exports.getStrokeWidth = getStrokeWidth;
exports.setStrokeColor = setStrokeColor;
exports.setFillColor = setFillColor;
exports.setStrokeWidth = setStrokeWidth;
exports.pushHistory = pushHistory;
exports.undo = undo;
exports.redo = redo;
exports.selectObject = selectObject;
exports.deselectAll = deselectAll;
exports.getSelectedObjects = getSelectedObjects;
exports.isSelected = isSelected;
exports.saveState = saveState;
exports.loadState = loadState;
exports.addObject = addObject;
exports.removeObject = removeObject;
exports.removeSelected = removeSelected;
exports.modifyObjects = modifyObjects;
exports.moveObjects = moveObjects;
const constants_1 = require("./constants");
let _idCounter = 0;
function nextId() {
return `obj_${++_idCounter}_${Date.now()}`;
}
// --- Scene ---
exports.scene = [];
exports.selectedIds = [];
// --- Viewport ---
exports.viewport = { x: 0, y: 0, zoom: 1 };
// --- Tools & Style ---
exports.currentTool = 'select';
let _strokeColor = constants_1.DEFAULT_STROKE_COLOR;
let _fillColor = constants_1.DEFAULT_FILL_COLOR;
let _strokeWidth = constants_1.DEFAULT_STROKE_WIDTH;
function getStrokeColor() { return _strokeColor; }
function getFillColor() { return _fillColor; }
function getStrokeWidth() { return _strokeWidth; }
function setStrokeColor(c) { _strokeColor = c; }
function setFillColor(c) { _fillColor = c; }
function setStrokeWidth(w) { _strokeWidth = w; }
// --- Text input ---
exports.textInputActive = false;
exports.textInputObjectId = null;
// --- History ---
const past = [];
const future = [];
function pushHistory(entry) {
past.push(entry);
future.length = 0; // discard future on new action
}
function undo() {
if (past.length === 0)
return;
const entry = past.pop();
// Restore before state
for (const obj of entry.before) {
const idx = exports.scene.findIndex(o => o.id === obj.id);
if (idx >= 0) {
exports.scene[idx] = obj;
}
else {
exports.scene.push(obj);
}
}
// Remove objects that weren't in before state but are in scene
const beforeIds = new Set(entry.before.map(o => o.id));
for (let i = exports.scene.length - 1; i >= 0; i--) {
if (entry.objectIds.includes(exports.scene[i].id) && !beforeIds.has(exports.scene[i].id)) {
exports.scene.splice(i, 1);
}
}
future.push(entry);
exports.selectedIds.length = 0;
}
function redo() {
if (future.length === 0)
return;
const entry = future.pop();
// Apply after state
for (const obj of entry.after) {
const idx = exports.scene.findIndex(o => o.id === obj.id);
if (idx >= 0) {
exports.scene[idx] = obj;
}
else {
exports.scene.push(obj);
}
}
const afterIds = new Set(entry.after.map(o => o.id));
for (let i = exports.scene.length - 1; i >= 0; i--) {
if (entry.objectIds.includes(exports.scene[i].id) && !afterIds.has(exports.scene[i].id)) {
exports.scene.splice(i, 1);
}
}
past.push(entry);
exports.selectedIds.length = 0;
}
// --- Selection helpers ---
function selectObject(id, additive) {
if (additive) {
if (!exports.selectedIds.includes(id))
exports.selectedIds.push(id);
}
else {
exports.selectedIds.length = 0;
exports.selectedIds.push(id);
}
}
function deselectAll() {
exports.selectedIds.length = 0;
}
function getSelectedObjects() {
return exports.scene.filter(o => exports.selectedIds.includes(o.id));
}
function isSelected(id) {
return exports.selectedIds.includes(id);
}
// --- Persistence ---
function saveState() {
const data = {
objects: exports.scene,
idCounter: _idCounter,
};
try {
localStorage.setItem(constants_1.LOCAL_STORAGE_KEY, JSON.stringify(data));
}
catch (_e) { /* quota exceeded */ }
}
function loadState() {
try {
const raw = localStorage.getItem(constants_1.LOCAL_STORAGE_KEY);
if (!raw)
return false;
const data = JSON.parse(raw);
exports.scene.length = 0;
if (Array.isArray(data.objects)) {
exports.scene.push(...data.objects);
}
if (typeof data.idCounter === 'number') {
_idCounter = data.idCounter;
}
exports.selectedIds.length = 0;
return true;
}
catch (_e) {
return false;
}
}
// --- History-aware scene mutations ---
function addObject(obj) {
exports.scene.push(obj);
pushHistory({ action: 'create', objectIds: [obj.id], before: [], after: [obj] });
saveState();
}
function removeObject(id) {
const idx = exports.scene.findIndex(o => o.id === id);
if (idx < 0)
return;
const obj = exports.scene[idx];
exports.scene.splice(idx, 1);
pushHistory({ action: 'delete', objectIds: [id], before: [obj], after: [] });
saveState();
}
function removeSelected() {
if (exports.selectedIds.length === 0)
return;
const objects = getSelectedObjects();
const ids = [...exports.selectedIds];
for (const obj of objects) {
const idx = exports.scene.findIndex(o => o.id === obj.id);
if (idx >= 0)
exports.scene.splice(idx, 1);
}
pushHistory({ action: 'delete', objectIds: ids, before: objects, after: [] });
exports.selectedIds.length = 0;
saveState();
}
function modifyObjects(before, after) {
const ids = after.map(o => o.id);
pushHistory({ action: 'modify', objectIds: ids, before, after });
// Update scene
for (const obj of after) {
const idx = exports.scene.findIndex(o => o.id === obj.id);
if (idx >= 0)
exports.scene[idx] = obj;
}
saveState();
}
function moveObjects(before, after) {
const ids = after.map(o => o.id);
pushHistory({ action: 'move', objectIds: ids, before, after });
for (const obj of after) {
const idx = exports.scene.findIndex(o => o.id === obj.id);
if (idx >= 0)
exports.scene[idx] = obj;
}
saveState();
}
};
// ── module: src/hitTest.ts ──
__mods["src/hitTest.ts"] = function (exports, require, module) {
"use strict";
// ===== Hit Testing =====
Object.defineProperty(exports, "__esModule", { value: true });
exports.screenToWorld = screenToWorld;
exports.worldToScreen = worldToScreen;
exports.hitTestObject = hitTestObject;
exports.hitTestAll = hitTestAll;
exports.getSelectionBounds = getSelectionBounds;
exports.hitTestHandle = hitTestHandle;
exports.rubberBandSelect = rubberBandSelect;
const constants_1 = require("./constants");
const state_1 = require("./state");
// Transform screen coordinates to world coordinates
function screenToWorld(sx, sy) {
return {
x: (sx - state_1.viewport.x) / state_1.viewport.zoom,
y: (sy - state_1.viewport.y) / state_1.viewport.zoom,
};
}
// Transform world coordinates to screen coordinates
function worldToScreen(wx, wy) {
return {
x: wx * state_1.viewport.zoom + state_1.viewport.x,
y: wy * state_1.viewport.zoom + state_1.viewport.y,
};
}
// --- Point-to-segment distance ---
function pointToSegDist(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0)
return Math.hypot(px - ax, py - ay);
let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
}
// --- Hit test a single object ---
function hitTestObject(obj, wx, wy) {
const tol = constants_1.HIT_TOLERANCE / state_1.viewport.zoom;
switch (obj.type) {
case 'rect': {
// Check if point is inside rect (filled) or near border
const x = Math.min(obj.x, obj.x + obj.w);
const y = Math.min(obj.y, obj.y + obj.h);
const bw = Math.abs(obj.w);
const bh = Math.abs(obj.h);
const inside = wx >= x - tol && wx <= x + bw + tol && wy >= y - tol && wy <= y + bh + tol;
if (inside)
return true;
// Border proximity
const borderDist = Math.min(pointToSegDist(wx, wy, x, y, x + bw, y), pointToSegDist(wx, wy, x + bw, y, x + bw, y + bh), pointToSegDist(wx, wy, x + bw, y + bh, x, y + bh), pointToSegDist(wx, wy, x, y + bh, x, y));
return borderDist <= tol + obj.strokeWidth / 2;
}
case 'ellipse': {
// Point inside ellipse: ((x-cx)/rx)^2 + ((y-cy)/ry)^2 <= 1
const rx = Math.abs(obj.rx) || 0.01;
const ry = Math.abs(obj.ry) || 0.01;
const dx = (wx - obj.cx) / rx;
const dy = (wy - obj.cy) / ry;
const inside = (dx * dx + dy * dy) <= 1 + (tol / Math.max(rx, ry));
if (inside)
return true;
// Border check
const dist = Math.sqrt(dx * dx + dy * dy) - 1;
return dist * Math.max(rx, ry) <= tol + obj.strokeWidth / 2;
}
case 'line':
case 'arrow': {
return pointToSegDist(wx, wy, obj.x1, obj.y1, obj.x2, obj.y2) <= tol + obj.strokeWidth / 2;
}
case 'freehand': {
if (!obj.points || obj.points.length < 2)
return false;
for (let i = 1; i < obj.points.length; i++) {
if (pointToSegDist(wx, wy, obj.points[i - 1].x, obj.points[i - 1].y, obj.points[i].x, obj.points[i].y) <= tol + obj.strokeWidth / 2) {
return true;
}
}
return false;
}
case 'text': {
const fs = obj.fontSize || 20;
// Approximate text bounding box
const textW = (obj.text || '').length * fs * 0.55;
const textH = fs * 1.2;
return wx >= obj.x - tol && wx <= obj.x + textW + tol && wy >= obj.y - textH - tol && wy <= obj.y + tol;
}
default:
return false;
}
}
// --- Hit test all objects (top-to-bottom order) ---
function hitTestAll(objs, wx, wy) {
// Test in reverse order (topmost first)
for (let i = objs.length - 1; i >= 0; i--) {
if (hitTestObject(objs[i], wx, wy))
return objs[i];
}
return null;
}
// --- Get bounding box of objects ---
function getSelectionBounds(objs) {
if (objs.length === 0)
return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const obj of objs) {
const b = getBounds(obj);
if (b) {
minX = Math.min(minX, b.x);
minY = Math.min(minY, b.y);
maxX = Math.max(maxX, b.x + b.w);
maxY = Math.max(maxY, b.y + b.h);
}
}
if (minX > maxX)
return null;
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
function getBounds(obj) {
switch (obj.type) {
case 'rect':
return {
x: obj.w >= 0 ? obj.x : obj.x + obj.w,
y: obj.h >= 0 ? obj.y : obj.y + obj.h,
w: Math.abs(obj.w),
h: Math.abs(obj.h),
};
case 'ellipse':
return { x: obj.cx - Math.abs(obj.rx), y: obj.cy - Math.abs(obj.ry), w: Math.abs(obj.rx) * 2, h: Math.abs(obj.ry) * 2 };
case 'line':
case 'arrow':
return {
x: Math.min(obj.x1, obj.x2), y: Math.min(obj.y1, obj.y2),
w: Math.abs(obj.x2 - obj.x1), h: Math.abs(obj.y2 - obj.y1),
};
case 'freehand': {
if (!obj.points || obj.points.length === 0)
return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of obj.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);
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
case 'text': {
const fs = obj.fontSize || 20;
return { x: obj.x, y: obj.y - fs * 1.2, w: (obj.text || '').length * fs * 0.55, h: fs * 1.2 };
}
}
}
function hitTestHandle(objs, sx, sy) {
const bounds = getSelectionBounds(objs);
if (!bounds)
return null;
const hs = constants_1.HANDLE_SIZE / state_1.viewport.zoom;
const handles = [
{ h: 'nw', wx: bounds.x, wy: bounds.y },
{ h: 'ne', wx: bounds.x + bounds.w, wy: bounds.y },
{ h: 'sw', wx: bounds.x, wy: bounds.y + bounds.h },
{ h: 'se', wx: bounds.x + bounds.w, wy: bounds.y + bounds.h },
];
const world = screenToWorld(sx, sy);
for (const { h, wx, wy } of handles) {
if (Math.abs(world.x - wx) <= hs && Math.abs(world.y - wy) <= hs) {
// Return the first selected object (for single-object, this is the object itself)
return { obj: objs[0], handle: h };
}
}
return null;
}
// --- Rubber-band selection ---
function rubberBandSelect(objects, start, end) {
const sx = Math.min(start.x, end.x);
const sy = Math.min(start.y, end.y);
const sw = Math.abs(end.x - start.x);
const sh = Math.abs(end.y - start.y);
const result = [];
for (const obj of objects) {
const b = getBounds(obj);
if (!b)
continue;
// Check if bounding box overlaps with rubber band
if (b.x + b.w > sx && b.x < sx + sw && b.y + b.h > sy && b.y < sy + sh) {
result.push(obj.id);
}
}
return result;
}
};
// ── module: src/renderer.ts ──
__mods["src/renderer.ts"] = function (exports, require, module) {
"use strict";
// ===== Renderer: draws grid, shapes, selection, previews =====
Object.defineProperty(exports, "__esModule", { value: true });
exports.initRenderer = initRenderer;
exports.resize = resize;
exports.drawPreview = drawPreview;
exports.render = render;
const constants_1 = require("./constants");
const state_1 = require("./state");
const hitTest_1 = require("./hitTest");
let canvas;
let ctx;
function initRenderer(c) {
canvas = c;
ctx = c.getContext('2d');
resize();
}
function resize() {
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.clientWidth * dpr;
canvas.height = canvas.clientHeight * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// --- Grid ---
function drawGrid() {
const gridSize = 24;
// Only draw grid at reasonable zoom levels (too dense below 0.5, too sparse above 3)
if (state_1.viewport.zoom < 0.3 || state_1.viewport.zoom > 3)
return;
// Calculate visible world bounds
const topLeft = { x: -state_1.viewport.x / state_1.viewport.zoom, y: -state_1.viewport.y / state_1.viewport.zoom };
const botRight = {
x: (canvas.clientWidth - state_1.viewport.x) / state_1.viewport.zoom,
y: (canvas.clientHeight - state_1.viewport.y) / state_1.viewport.zoom,
};
// Snap to grid
const startX = Math.floor(topLeft.x / gridSize) * gridSize;
const startY = Math.floor(topLeft.y / gridSize) * gridSize;
// Estimate number of dots - skip if too many
const estCols = Math.ceil((botRight.x - topLeft.x) / gridSize);
const estRows = Math.ceil((botRight.y - topLeft.y) / gridSize);
if (estCols * estRows > 20000)
return; // bail out to avoid perf hit
ctx.save();
ctx.fillStyle = constants_1.COLORS.gridDot;
ctx.lineWidth = 1;
const dotRadius = Math.max(1, Math.min(2, 1.5 / state_1.viewport.zoom));
for (let x = startX; x <= botRight.x; x += gridSize) {
for (let y = startY; y <= botRight.y; y += gridSize) {
const screen = (0, hitTest_1.worldToScreen)(x, y);
// Only draw if in viewport
if (screen.x < 0 || screen.x > canvas.clientWidth || screen.y < 0 || screen.y > canvas.clientHeight)
continue;
ctx.beginPath();
ctx.arc(screen.x, screen.y, dotRadius, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
}
// --- Shape Rendering ---
function drawRect(obj) {
if (obj.type !== 'rect')
return;
const { x, y, w, h, strokeColor, fillColor, strokeWidth } = obj;
ctx.lineWidth = strokeWidth;
ctx.strokeStyle = strokeColor;
if (fillColor && fillColor !== 'none') {
ctx.fillStyle = fillColor;
ctx.fillRect(x, y, w, h);
}
ctx.strokeRect(x, y, w, h);
}
function drawEllipse(obj) {
if (obj.type !== 'ellipse')
return;
const { cx, cy, rx, ry, strokeColor, fillColor, strokeWidth } = obj;
ctx.lineWidth = strokeWidth;
ctx.strokeStyle = strokeColor;
ctx.beginPath();
ctx.ellipse(cx, cy, Math.abs(rx), Math.abs(ry), 0, 0, Math.PI * 2);
if (fillColor && fillColor !== 'none') {
ctx.fillStyle = fillColor;
ctx.fill();
}
ctx.stroke();
}
function drawLine(obj) {
if (obj.type !== 'line' && obj.type !== 'arrow')
return;
const { x1, y1, x2, y2, strokeColor, strokeWidth } = obj;
ctx.lineWidth = strokeWidth;
ctx.strokeStyle = strokeColor;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
// Arrowhead
if (obj.type === 'arrow') {
const angle = Math.atan2(y2 - y1, x2 - x1);
const headLen = 12;
ctx.fillStyle = strokeColor;
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.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6));
ctx.closePath();
ctx.fill();
}
}
function drawFreehand(obj) {
if (obj.type !== 'freehand' || !obj.points || obj.points.length < 2)
return;
const { points, strokeColor, strokeWidth } = obj;
ctx.lineWidth = strokeWidth;
ctx.strokeStyle = strokeColor;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
// Simple smoothing: use quadratic curves through midpoints
if (points.length === 2) {
ctx.lineTo(points[1].x, points[1].y);
}
else {
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);
}
const last = points[points.length - 1];
const prev = points[points.length - 2];
const mx = (prev.x + last.x) / 2;
const my = (prev.y + last.y) / 2;
ctx.quadraticCurveTo(prev.x, prev.y, last.x, last.y);
}
ctx.stroke();
}
function drawText(obj) {
if (obj.type !== 'text')
return;
const { x, y, text, strokeColor, fontSize } = obj;
ctx.font = `${fontSize}px "Inter", "Segoe UI", system-ui, sans-serif`;
ctx.fillStyle = strokeColor;
ctx.textBaseline = 'alphabetic';
ctx.fillText(text || '', x, y);
}
// --- Selection Rendering ---
function drawSelection() {
const selected = state_1.scene.filter(o => state_1.selectedIds.includes(o.id));
if (selected.length === 0)
return;
// Compute bounds
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const obj of selected) {
// Quick bounding
const pad = obj.strokeWidth / 2;
switch (obj.type) {
case 'rect': {
const x = Math.min(obj.x, obj.x + obj.w);
const y = Math.min(obj.y, obj.y + obj.h);
minX = Math.min(minX, x - pad);
minY = Math.min(minY, y - pad);
maxX = Math.max(maxX, x + Math.abs(obj.w) + pad);
maxY = Math.max(maxY, y + Math.abs(obj.h) + pad);
break;
}
case 'ellipse': {
minX = Math.min(minX, obj.cx - Math.abs(obj.rx) - pad);
minY = Math.min(minY, obj.cy - Math.abs(obj.ry) - pad);
maxX = Math.max(maxX, obj.cx + Math.abs(obj.rx) + pad);
maxY = Math.max(maxY, obj.cy + Math.abs(obj.ry) + pad);
break;
}
case 'line':
case 'arrow': {
minX = Math.min(minX, obj.x1 - pad, obj.x2 - pad);
minY = Math.min(minY, obj.y1 - pad, obj.y2 - pad);
maxX = Math.max(maxX, obj.x1 + pad, obj.x2 + pad);
maxY = Math.max(maxY, obj.y1 + pad, obj.y2 + pad);
break;
}
case 'freehand': {
if (obj.points) {
for (const p of obj.points) {
minX = Math.min(minX, p.x - pad);
minY = Math.min(minY, p.y - pad);
maxX = Math.max(maxX, p.x + pad);
maxY = Math.max(maxY, p.y + pad);
}
}
break;
}
case 'text': {
const fs = obj.fontSize || 20;
const tw = (obj.text || '').length * fs * 0.55;
minX = Math.min(minX, obj.x - pad);
minY = Math.min(minY, obj.y - fs * 1.2 - pad);
maxX = Math.max(maxX, obj.x + tw + pad);
maxY = Math.max(maxY, obj.y + pad);
break;
}
}
}
if (minX === Infinity)
return;
// Draw bounding box
const bw = 2; // screen-space border width
ctx.strokeStyle = constants_1.COLORS.selectionBox;
ctx.lineWidth = bw;
ctx.setLineDash([4, 4]);
ctx.strokeRect(minX, minY, maxX - minX, maxY - minY);
ctx.setLineDash([]);
// Draw handles
const hs = 6 / state_1.viewport.zoom; // handle half-size in world coords
const handleCorners = [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: minX, y: maxY },
{ x: maxX, y: maxY },
];
for (const c of handleCorners) {
ctx.fillStyle = constants_1.COLORS.selectionHandle;
ctx.strokeStyle = constants_1.COLORS.selectionBox;
ctx.lineWidth = 1.5;
ctx.fillRect(c.x - hs, c.y - hs, hs * 2, hs * 2);
ctx.strokeRect(c.x - hs, c.y - hs, hs * 2, hs * 2);
}
}
function drawPreview(preview) {
if (!preview)
return;
const alpha = 0.5;
switch (preview.type) {
case 'rect': {
ctx.globalAlpha = alpha;
ctx.strokeStyle = constants_1.COLORS.previewShape;
ctx.lineWidth = 2;
if (preview.w && preview.h) {
ctx.strokeRect(preview.x, preview.y, preview.w, preview.h);
}
ctx.globalAlpha = 1;
break;
}
case 'ellipse': {
ctx.globalAlpha = alpha;
ctx.strokeStyle = constants_1.COLORS.previewShape;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(preview.cx, preview.cy, Math.abs(preview.rx), Math.abs(preview.ry), 0, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
break;
}
case 'line':
case 'arrow': {
ctx.globalAlpha = alpha;
ctx.strokeStyle = constants_1.COLORS.previewShape;
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(preview.x1, preview.y1);
ctx.lineTo(preview.x2, preview.y2);
ctx.stroke();
ctx.globalAlpha = 1;
break;
}
case 'freehand': {
if (!preview.points || preview.points.length < 2)
return;
ctx.strokeStyle = constants_1.COLORS.previewShape;
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(preview.points[0].x, preview.points[0].y);
for (let i = 1; i < preview.points.length; i++) {
ctx.lineTo(preview.points[i].x, preview.points[i].y);
}
ctx.stroke();
break;
}
case 'rubberband': {
ctx.globalAlpha = 0.1;
ctx.fillStyle = constants_1.COLORS.rubberBand;
ctx.fillRect(preview.x, preview.y, preview.w, preview.h);
ctx.globalAlpha = 1;
ctx.strokeStyle = constants_1.COLORS.rubberBandBorder;
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.strokeRect(preview.x, preview.y, preview.w, preview.h);
ctx.setLineDash([]);
break;
}
case 'text': {
ctx.font = '20px "Inter", "Segoe UI", system-ui, sans-serif';
ctx.fillStyle = constants_1.COLORS.previewShape;
ctx.globalAlpha = alpha;
ctx.fillText(preview.text || 'Type here...', preview.x, preview.y);
ctx.globalAlpha = 1;
break;
}
}
}
// --- Main render ---
function render(preview = null) {
// Clear
ctx.fillStyle = constants_1.COLORS.canvasBg;
ctx.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight);
// Draw grid
drawGrid();
// Apply viewport transform
ctx.save();
ctx.translate(state_1.viewport.x, state_1.viewport.y);
ctx.scale(state_1.viewport.zoom, state_1.viewport.zoom);
// Draw all objects
for (const obj of state_1.scene) {
switch (obj.type) {
case 'rect':
drawRect(obj);
break;
case 'ellipse':
drawEllipse(obj);
break;
case 'line':
case 'arrow':
drawLine(obj);
break;
case 'freehand':
drawFreehand(obj);
break;
case 'text':
drawText(obj);
break;
}
}
ctx.restore();
// Draw preview (in world coords)
ctx.save();
ctx.translate(state_1.viewport.x, state_1.viewport.y);
ctx.scale(state_1.viewport.zoom, state_1.viewport.zoom);
drawPreview(preview);
ctx.restore();
// Draw selection (in world coords)
ctx.save();
ctx.translate(state_1.viewport.x, state_1.viewport.y);
ctx.scale(state_1.viewport.zoom, state_1.viewport.zoom);
drawSelection();
ctx.restore();
}
};
// ── module: src/interactions.ts ──
__mods["src/interactions.ts"] = function (exports, require, module) {
"use strict";
// ===== Interactions: input handling for all tools =====
Object.defineProperty(exports, "__esModule", { value: true });
exports.initInteractions = initInteractions;
exports.getPreview = getPreview;
exports.setTool = setTool;
exports.getCurrentTool = getCurrentTool;
exports.updateZoomIndicator = updateZoomIndicator;
exports.resetView = resetView;
const constants_1 = require("./constants");
const state_1 = require("./state");
const hitTest_1 = require("./hitTest");
const renderer_1 = require("./renderer");
let canvas;
let preview = null;
// --- Deep clone helper ---
function cloneObj(o) {
if (o.type === 'freehand') {
return { ...o, points: o.points.map(p => ({ ...p })) };
}
return { ...o };
}
function cloneSelected() {
return (0, state_1.getSelectedObjects)().map(cloneObj);
}
// Interaction state
let isPanning = false;
let panStart = { x: 0, y: 0 };
let spaceHeld = false;
// Drawing state
let isDrawing = false;
let drawStart = { x: 0, y: 0 };
let freehandPoints = [];
// Selection/move state
let isDragging = false;
let dragStartWorld = { x: 0, y: 0 };
let dragSnapshotBefore = [];
// Resize state
let isResizing = false;
let resizeHandle = 'se';
let resizeStartWorld = { x: 0, y: 0 };
let resizeSnapshotBefore = [];
// Rubber-band state
let isRubberBand = false;
let rubberStart = { x: 0, y: 0 };
// Eraser
let erasing = false;
function initInteractions(c) {
canvas = c;
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);
}
function getPreview() {
return preview;
}
function getMouseWorld(e) {
const rect = canvas.getBoundingClientRect();
return (0, hitTest_1.screenToWorld)(e.clientX - rect.left, e.clientY - rect.top);
}
function getMouseScreen(e) {
const rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
// ===== Mouse Events =====
function onMouseDown(e) {
if (textInputActive)
return;
const world = getMouseWorld(e);
const screen = getMouseScreen(e);
const shift = e.shiftKey;
// Middle mouse or space+left = pan
if (e.button === 1 || (e.button === 0 && spaceHeld)) {
isPanning = true;
panStart = { x: e.clientX - state_1.viewport.x, y: e.clientY - state_1.viewport.y };
canvas.style.cursor = 'grabbing';
e.preventDefault();
return;
}
if (e.button !== 0)
return;
switch (currentTool) {
case 'select':
handleSelectDown(world, screen, shift);
break;
case 'pen':
handlePenDown(world);
break;
case 'rect':
case 'ellipse':
case 'line':
handleShapeDown(world);
break;
case 'text':
handleTextDown(world);
break;
case 'eraser':
handleEraserDown(world);
break;
}
}
let currentTool = 'select';
function handleSelectDown(world, screen, shift) {
const selected = (0, state_1.getSelectedObjects)();
// Check handles first
if (selected.length > 0) {
const handleHit = (0, hitTest_1.hitTestHandle)(selected, screen.x, screen.y);
if (handleHit) {
isResizing = true;
resizeHandle = handleHit.handle;
resizeStartWorld = world;
resizeSnapshotBefore = cloneSelected();
return;
}
}
// Hit test objects
const hit = (0, hitTest_1.hitTestAll)(state_1.scene, world.x, world.y);
if (hit) {
(0, state_1.selectObject)(hit.id, shift);
isDragging = true;
dragStartWorld = world;
dragSnapshotBefore = cloneSelected();
return;
}
// Click empty space
if (!shift)
(0, state_1.deselectAll)();
isRubberBand = true;
rubberStart = world;
}
function handlePenDown(world) {
isDrawing = true;
freehandPoints = [world];
}
function handleShapeDown(world) {
isDrawing = true;
drawStart = world;
}
function handleTextDown(world) {
const screen = {
x: world.x * state_1.viewport.zoom + state_1.viewport.x,
y: world.y * state_1.viewport.zoom + state_1.viewport.y,
};
showTextInput(screen.x, screen.y, world.x, world.y);
}
function handleEraserDown(world) {
erasing = true;
eraseAt(world);
}
function eraseAt(world) {
const hit = (0, hitTest_1.hitTestAll)(state_1.scene, world.x, world.y);
if (hit) {
const idx = state_1.scene.findIndex(o => o.id === hit.id);
if (idx >= 0) {
const obj = state_1.scene[idx];
const before = [cloneObj(obj)];
state_1.scene.splice(idx, 1);
state_1.selectedIds.splice(state_1.selectedIds.indexOf(obj.id), 1);
(0, state_1.pushHistory)({ action: 'delete', objectIds: [obj.id], before, after: [] });
(0, state_1.saveState)();
}
}
}
function onMouseMove(e) {
if (textInputActive)
return;
const world = getMouseWorld(e);
const screen = getMouseScreen(e);
// Cursor updates (when not in an action)
if (!isPanning && !isDrawing && !isDragging && !isResizing && !isRubberBand) {
updateCursorFromState(world, screen);
}
if (isPanning) {
state_1.viewport.x = e.clientX - panStart.x;
state_1.viewport.y = e.clientY - panStart.y;
(0, renderer_1.render)(preview);
updateZoomIndicator();
return;
}
if (currentTool === 'eraser' && erasing) {
eraseAt(world);
(0, renderer_1.render)(preview);
return;
}
switch (currentTool) {
case 'select':
handleSelectMove(world);
break;
case 'pen':
handlePenMove(world);
break;
case 'rect':
case 'ellipse':
case 'line':
handleShapeMove(world);
break;
}
}
function handleSelectMove(world) {
if (isDragging) {
const dx = world.x - dragStartWorld.x;
const dy = world.y - dragStartWorld.y;
if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) {
moveObjectsInPlace((0, state_1.getSelectedObjects)(), dx, dy);
(0, renderer_1.render)(preview);
}
return;
}
if (isResizing) {
const dx = world.x - resizeStartWorld.x;
const dy = world.y - resizeStartWorld.y;
resizeSelected(dx, dy);
(0, renderer_1.render)(preview);
return;
}
if (isRubberBand) {
const sx = Math.min(rubberStart.x, world.x);
const sy = Math.min(rubberStart.y, world.y);
preview = { type: 'rubberband', x: sx, y: sy, w: Math.abs(world.x - rubberStart.x), h: Math.abs(world.y - rubberStart.y) };
(0, renderer_1.render)(preview);
}
}
function handlePenMove(world) {
if (!isDrawing || freehandPoints.length === 0)
return;
const last = freehandPoints[freehandPoints.length - 1];
if (Math.hypot(world.x - last.x, world.y - last.y) > 1 / state_1.viewport.zoom) {
freehandPoints.push(world);
}
preview = { type: 'freehand', points: [...freehandPoints] };
(0, renderer_1.render)(preview);
}
function handleShapeMove(world) {
if (!isDrawing)
return;
const dx = world.x - drawStart.x;
const dy = world.y - drawStart.y;
switch (currentTool) {
case 'rect':
preview = { type: 'rect', x: dx >= 0 ? drawStart.x : world.x, y: dy >= 0 ? drawStart.y : world.y, w: Math.abs(dx), h: Math.abs(dy) };
break;
case 'ellipse':
preview = { type: 'ellipse', cx: drawStart.x + dx / 2, cy: drawStart.y + dy / 2, rx: dx / 2, ry: dy / 2 };
break;
case 'line':
preview = { type: 'line', x1: drawStart.x, y1: drawStart.y, x2: world.x, y2: world.y };
break;
}
(0, renderer_1.render)(preview);
}
function onMouseUp(e) {
if (textInputActive)
return;
const world = getMouseWorld(e);
if (isPanning) {
isPanning = false;
updateCursorFromState(world, { x: 0, y: 0 });
(0, renderer_1.render)(preview);
return;
}
if (currentTool === 'eraser') {
erasing = false;
(0, renderer_1.render)(preview);
return;
}
switch (currentTool) {
case 'select':
handleSelectUp(world);
break;
case 'pen':
handlePenUp();
break;
case 'rect':
case 'ellipse':
case 'line':
handleShapeUp(world);
break;
}
}
function handleSelectUp(world) {
if (isDragging) {
isDragging = false;
const after = cloneSelected();
(0, state_1.moveObjects)(dragSnapshotBefore, after);
dragSnapshotBefore = [];
}
if (isResizing) {
isResizing = false;
const after = cloneSelected();
(0, state_1.modifyObjects)(resizeSnapshotBefore, after);
resizeSnapshotBefore = [];
}
if (isRubberBand) {
isRubberBand = false;
const ids = (0, hitTest_1.rubberBandSelect)(state_1.scene, rubberStart, world);
state_1.selectedIds.length = 0;
state_1.selectedIds.push(...ids);
preview = null;
(0, renderer_1.render)(null);
}
}
function handlePenUp() {
if (!isDrawing || freehandPoints.length < 2) {
isDrawing = false;
freehandPoints = [];
preview = null;
(0, renderer_1.render)(null);
return;
}
const obj = {
id: generateId(),
type: 'freehand',
strokeColor: (0, state_1.getStrokeColor)(),
fillColor: 'none',
strokeWidth: (0, state_1.getStrokeWidth)(),
points: [...freehandPoints],
};
state_1.scene.push(obj);
(0, state_1.pushHistory)({ action: 'create', objectIds: [obj.id], before: [], after: [cloneObj(obj)] });
(0, state_1.saveState)();
isDrawing = false;
freehandPoints = [];
preview = null;
(0, renderer_1.render)(null);
}
function handleShapeUp(world) {
if (!isDrawing)
return;
isDrawing = false;
preview = null;
const dx = world.x - drawStart.x;
const dy = world.y - drawStart.y;
if (Math.hypot(dx, dy) < 2 / state_1.viewport.zoom) {
(0, renderer_1.render)(null);
return;
}
let obj;
switch (currentTool) {
case 'rect':
obj = { id: generateId(), type: 'rect', x: dx >= 0 ? drawStart.x : world.x, y: dy >= 0 ? drawStart.y : world.y, w: Math.abs(dx), h: Math.abs(dy), strokeColor: (0, state_1.getStrokeColor)(), fillColor: (0, state_1.getFillColor)(), strokeWidth: (0, state_1.getStrokeWidth)() };
break;
case 'ellipse':
obj = { id: generateId(), type: 'ellipse', cx: drawStart.x + dx / 2, cy: drawStart.y + dy / 2, rx: dx / 2, ry: dy / 2, strokeColor: (0, state_1.getStrokeColor)(), fillColor: (0, state_1.getFillColor)(), strokeWidth: (0, state_1.getStrokeWidth)() };
break;
case 'line':
obj = { id: generateId(), type: 'line', x1: drawStart.x, y1: drawStart.y, x2: world.x, y2: world.y, strokeColor: (0, state_1.getStrokeColor)(), fillColor: 'none', strokeWidth: (0, state_1.getStrokeWidth)() };
break;
default:
(0, renderer_1.render)(null);
return;
}
state_1.scene.push(obj);
(0, state_1.pushHistory)({ action: 'create', objectIds: [obj.id], before: [], after: [cloneObj(obj)] });
(0, state_1.saveState)();
(0, renderer_1.render)(null);
}
// ===== Wheel =====
let zoomRaf = 0;
function onWheel(e) {
if (e.cancelable)
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const oldZoom = state_1.viewport.zoom;
// Scale zoom by delta magnitude; deltaMode 1 = lines (~100)
const delta = e.deltaY * 0.002; // tune: ~200 delta → factor ~1.45
const factor = Math.exp(-delta);
let newZoom = oldZoom * factor;
newZoom = Math.max(constants_1.ZOOM_MIN, Math.min(constants_1.ZOOM_MAX, newZoom));
// Zoom toward cursor
const worldX = (mx - state_1.viewport.x) / oldZoom;
const worldY = (my - state_1.viewport.y) / oldZoom;
state_1.viewport.x = mx - worldX * newZoom;
state_1.viewport.y = my - worldY * newZoom;
state_1.viewport.zoom = newZoom;
(0, renderer_1.render)(preview);
updateZoomIndicator();
}
// ===== Keyboard =====
function onKeyDown(e) {
if (textInputActive)
return;
if (e.code === 'Space' && !e.repeat) {
spaceHeld = true;
canvas.style.cursor = 'grab';
e.preventDefault();
}
if (e.key === 'Delete' || e.key === 'Backspace') {
if (state_1.selectedIds.length > 0) {
(0, state_1.removeSelected)();
(0, renderer_1.render)(null);
e.preventDefault();
}
}
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z' && !e.shiftKey) {
(0, state_1.undo)();
(0, renderer_1.render)(null);
e.preventDefault();
}
if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
(0, state_1.redo)();
(0, renderer_1.render)(null);
e.preventDefault();
}
return;
}
// Nudge
if (state_1.selectedIds.length > 0 && !e.ctrlKey && !e.metaKey) {
const amount = e.shiftKey ? constants_1.NUDGE_SHIFT_AMOUNT : constants_1.NUDGE_AMOUNT;
let dx = 0, dy = 0;
switch (e.key) {
case 'ArrowLeft':
dx = -amount;
break;
case 'ArrowRight':
dx = amount;
break;
case 'ArrowUp':
dy = -amount;
break;
case 'ArrowDown':
dy = amount;
break;
}
if (dx !== 0 || dy !== 0) {
const selected = (0, state_1.getSelectedObjects)();
const before = cloneSelected();
moveObjectsInPlace(selected, dx, dy);
(0, state_1.moveObjects)(before, cloneSelected());
(0, renderer_1.render)(preview);
e.preventDefault();
}
}
// Tool shortcuts
if (!e.ctrlKey && !e.metaKey && e.key.length === 1) {
const k = e.key.toLowerCase();
const toolMap = {
v: 'select', p: 'pen', r: 'rect', o: 'ellipse', l: 'line', t: 'text', e: 'eraser',
};
if (toolMap[k]) {
const newTool = toolMap[k];
if (newTool !== currentTool) {
setTool(newTool);
}
}
}
}
function onKeyUp(e) {
if (e.code === 'Space') {
spaceHeld = false;
updateCursor();
(0, renderer_1.render)(preview);
}
}
// ===== Helpers =====
function moveObjectsInPlace(objs, dx, dy) {
for (const obj of objs) {
switch (obj.type) {
case 'rect':
obj.x += dx;
obj.y += dy;
break;
case 'ellipse':
obj.cx += dx;
obj.cy += dy;
break;
case 'line':
case 'arrow':
obj.x1 += dx;
obj.y1 += dy;
obj.x2 += dx;
obj.y2 += dy;
break;
case 'freehand':
if (obj.points)
for (const p of obj.points) {
p.x += dx;
p.y += dy;
}
break;
case 'text':
obj.x += dx;
obj.y += dy;
break;
}
}
}
function getObjectBounds(obj) {
switch (obj.type) {
case 'rect': return { x: Math.min(obj.x, obj.x + obj.w), y: Math.min(obj.y, obj.y + obj.h), w: Math.abs(obj.w), h: Math.abs(obj.h) };
case 'ellipse': return { x: obj.cx - Math.abs(obj.rx), y: obj.cy - Math.abs(obj.ry), w: Math.abs(obj.rx) * 2, h: Math.abs(obj.ry) * 2 };
case 'line':
case 'arrow': return { x: Math.min(obj.x1, obj.x2), y: Math.min(obj.y1, obj.y2), w: Math.abs(obj.x2 - obj.x1), h: Math.abs(obj.y2 - obj.y1) };
case 'freehand': {
if (!obj.points || obj.points.length === 0)
return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of obj.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);
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
case 'text': {
const fs = obj.fontSize || 20;
return { x: obj.x, y: obj.y - fs * 1.2, w: (obj.text || '').length * fs * 0.55, h: fs * 1.2 };
}
}
}
function computeMultiBounds(objs) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const obj of objs) {
const b = getObjectBounds(obj);
if (b) {
minX = Math.min(minX, b.x);
minY = Math.min(minY, b.y);
maxX = Math.max(maxX, b.x + b.w);
maxY = Math.max(maxY, b.y + b.h);
}
}
if (minX > maxX)
return null;
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
function resizeSelected(dx, dy) {
const selected = (0, state_1.getSelectedObjects)();
if (selected.length === 0)
return;
const bounds = computeMultiBounds(selected);
if (!bounds || bounds.w === 0 || bounds.h === 0)
return;
// Determine the anchor corner (opposite of handle)
const originX = resizeHandle.includes('w') ? bounds.x : bounds.x + bounds.w;
const originY = resizeHandle.includes('n') ? bounds.y : bounds.y + bounds.h;
// New dimension from drag
const newW = bounds.w + (resizeHandle.includes('w') ? -dx : dx);
const newH = bounds.h + (resizeHandle.includes('n') ? -dy : dy);
const scaleX = newW / bounds.w;
const scaleY = newH / bounds.h;
for (const obj of selected) {
const ob = getObjectBounds(obj);
if (!ob)
continue;
// Relative position from origin
const relLeft = (ob.x - originX) / bounds.w;
const relTop = (ob.y - originY) / bounds.h;
const newOx = resizeHandle.includes('w') ? originX + newW : originX;
const newOy = resizeHandle.includes('n') ? originY + newH : originY;
const newX = originX + relLeft * newW;
const newY = originY + relTop * newH;
const newObW = ob.w * scaleX;
const newObH = ob.h * scaleY;
switch (obj.type) {
case 'rect':
obj.x = newX;
obj.y = newY;
obj.w = newObW;
obj.h = newObH;
break;
case 'ellipse':
obj.rx = (newObW / 2) * Math.sign(obj.rx || 1);
obj.ry = (newObH / 2) * Math.sign(obj.ry || 1);
obj.cx = newX + Math.abs(obj.rx);
obj.cy = newY + Math.abs(obj.ry);
break;
case 'line':
case 'arrow': {
const rX1 = (obj.x1 - originX) / bounds.w;
const rY1 = (obj.y1 - originY) / bounds.h;
const rX2 = (obj.x2 - originX) / bounds.w;
const rY2 = (obj.y2 - originY) / bounds.h;
obj.x1 = originX + rX1 * newW;
obj.y1 = originY + rY1 * newH;
obj.x2 = originX + rX2 * newW;
obj.y2 = originY + rY2 * newH;
break;
}
case 'freehand':
if (obj.points) {
for (const p of obj.points) {
const rx = (p.x - originX) / bounds.w;
const ry = (p.y - originY) / bounds.h;
p.x = originX + rx * newW;
p.y = originY + ry * newH;
}
}
break;
case 'text':
obj.x = newX;
obj.y = newY;
break;
}
}
}
// ===== Text Input =====
let textInputActive = false;
function showTextInput(screenX, screenY, worldX, worldY) {
textInputActive = true;
const textarea = document.createElement('textarea');
textarea.id = 'text-input';
Object.assign(textarea.style, {
position: 'fixed', left: `${screenX}px`, top: `${screenY - 20}px`,
fontSize: '20px', fontFamily: '"Inter","Segoe UI",system-ui,sans-serif',
border: '2px solid #2970ff', outline: 'none', padding: '4px 8px',
minWidth: '120px', backgroundColor: '#fff', zIndex: '1000',
color: (0, state_1.getStrokeColor)(), resize: 'none', boxSizing: 'border-box', lineHeight: '1.2',
});
textarea.value = '';
document.body.appendChild(textarea);
textarea.focus();
// Auto-resize
const measure = document.createElement('div');
measure.style.cssText = 'position:absolute;visibility:hidden;font-size:20px;font-family:"Inter","Segoe UI",system-ui,sans-serif;white-space:pre;pointer-events:none;';
document.body.appendChild(measure);
textarea.addEventListener('input', () => {
measure.textContent = textarea.value || 'W';
textarea.style.width = `${measure.offsetWidth + 24}px`;
});
const commit = () => {
const text = textarea.value || '';
textarea.remove();
measure.remove();
textInputActive = false;
if (text) {
const obj = {
id: generateId(), type: 'text', x: worldX, y: worldY,
text, fontSize: 20, strokeColor: (0, state_1.getStrokeColor)(), fillColor: 'none', strokeWidth: 0,
};
state_1.scene.push(obj);
(0, state_1.pushHistory)({ action: 'create', objectIds: [obj.id], before: [], after: [cloneObj(obj)] });
(0, state_1.saveState)();
}
(0, renderer_1.render)(null);
};
textarea.addEventListener('blur', commit);
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
textarea.blur();
}
if (e.key === 'Escape') {
textarea.value = '';
textarea.blur();
}
});
}
// ===== Tool / Cursor =====
function updateCursorFromState(world, screen) {
if (spaceHeld) {
canvas.style.cursor = 'grab';
return;
}
switch (currentTool) {
case 'select': {
const selected = (0, state_1.getSelectedObjects)();
if (selected.length > 0) {
const h = (0, hitTest_1.hitTestHandle)(selected, screen.x, screen.y);
if (h) {
canvas.style.cursor = (h.handle === 'nw' || h.handle === 'se') ? 'nwse-resize' : 'nesw-resize';
return;
}
}
canvas.style.cursor = (0, hitTest_1.hitTestAll)(state_1.scene, world.x, world.y) ? 'pointer' : 'default';
break;
}
case 'text':
canvas.style.cursor = 'text';
break;
case 'eraser':
canvas.style.cursor = 'crosshair';
break;
default:
canvas.style.cursor = 'crosshair';
break;
}
}
function updateCursor() {
updateCursorFromState(getMouseWorld({ clientX: 0, clientY: 0 }), { x: 0, y: 0 });
}
function setTool(tool) {
currentTool = tool;
updateToolButtons(tool);
updateCursor();
(0, renderer_1.render)(preview);
}
function getCurrentTool() {
return currentTool;
}
function updateToolButtons(tool) {
document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tool === tool);
});
}
// ===== Zoom =====
function updateZoomIndicator() {
const el = document.getElementById('zoom-level');
if (el)
el.textContent = `${Math.round(state_1.viewport.zoom * 100)}%`;
}
function resetView() {
state_1.viewport.x = canvas.clientWidth / 2;
state_1.viewport.y = canvas.clientHeight / 2;
state_1.viewport.zoom = 1;
(0, renderer_1.render)(preview);
updateZoomIndicator();
}
// ===== ID =====
let _idSeq = 0;
function generateId() {
return `w_${++_idSeq}_${Date.now()}`;
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.6-27B-MTP-GGUF","provider":"brigid","persona":"typescript","sessionId":"9b25e508-436f-4843-97ef-5f03cca09e6b","tokensIn":3571646,"tokensOut":46121,"tokensTotal":3617767,"cost":0,"turns":68,"toolCalls":68,"failedToolCalls":0,"timestamp":"2026-08-02T07:26:17.798Z"} -->