viteboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/bin/viteboard.mjs +59 -0
  4. package/content/docs/assets/image-mqjpqlyw.png +0 -0
  5. package/content/docs/assets/image-mqjsmenr.png +0 -0
  6. package/content/docs/boards/product-roadmap.board.json +2130 -0
  7. package/content/docs/capabilities.md +89 -0
  8. package/content/docs/docs.md +84 -0
  9. package/content/docs/guide/boards-in-markdown.md +48 -0
  10. package/content/docs/guide/boards.md +92 -0
  11. package/content/docs/guide/getting-started.md +71 -0
  12. package/content/docs/guide/test.md +6 -0
  13. package/content/docs/index.md +47 -0
  14. package/content/docs/product-roadmap.board.json +2130 -0
  15. package/content/docs/test.md +219 -0
  16. package/content/docs/tetst-dir/test-board.board.json +15 -0
  17. package/content/docs/tetst-dir/test-test.md +1 -0
  18. package/content/docs/workspace.md +73 -0
  19. package/dist/assets/index-CEpxLM2o.css +1 -0
  20. package/dist/assets/index-D4xvJdEQ.js +60 -0
  21. package/dist/index.html +13 -0
  22. package/index.html +13 -0
  23. package/package.json +52 -0
  24. package/src/app/AppController.ts +955 -0
  25. package/src/app/BoardState.ts +130 -0
  26. package/src/app/ClipboardService.ts +159 -0
  27. package/src/app/CommandManager.ts +66 -0
  28. package/src/app/ElementActions.ts +152 -0
  29. package/src/app/EmbedRegionSelector.ts +103 -0
  30. package/src/app/ExportPng.ts +107 -0
  31. package/src/app/ImageInsertService.ts +141 -0
  32. package/src/app/KeyboardShortcuts.ts +124 -0
  33. package/src/app/main.ts +6 -0
  34. package/src/board/BoardPreview.ts +189 -0
  35. package/src/board/BoardService.ts +222 -0
  36. package/src/canvas/CanvasRenderer.ts +253 -0
  37. package/src/canvas/CanvasSurface.ts +70 -0
  38. package/src/canvas/HitTester.ts +110 -0
  39. package/src/canvas/ImageCache.ts +123 -0
  40. package/src/canvas/PerformanceMonitor.ts +31 -0
  41. package/src/canvas/RenderScheduler.ts +26 -0
  42. package/src/canvas/Viewport.ts +77 -0
  43. package/src/content/ContentApi.ts +258 -0
  44. package/src/content/Markdown.ts +431 -0
  45. package/src/content/MarkdownView.ts +70 -0
  46. package/src/editor/DocEditor.ts +799 -0
  47. package/src/editor/htmlToMarkdown.ts +333 -0
  48. package/src/elements/renderElement.ts +509 -0
  49. package/src/elements/types.ts +118 -0
  50. package/src/shell/Shell.ts +2950 -0
  51. package/src/shell/Sidebar.ts +1352 -0
  52. package/src/storage/AssetStore.ts +86 -0
  53. package/src/storage/BoardSerializer.ts +114 -0
  54. package/src/storage/ImportExportService.ts +153 -0
  55. package/src/storage/IndexedDbStore.ts +92 -0
  56. package/src/storage/LocalBoardStore.ts +104 -0
  57. package/src/styles.css +3257 -0
  58. package/src/templates/helpers.ts +124 -0
  59. package/src/templates/index.ts +65 -0
  60. package/src/templates/journeyMapTemplate.ts +52 -0
  61. package/src/templates/opportunityTreeTemplate.ts +45 -0
  62. package/src/templates/personaTemplate.ts +41 -0
  63. package/src/templates/prioritizationMatrixTemplate.ts +44 -0
  64. package/src/templates/requirementsFlowTemplate.ts +45 -0
  65. package/src/templates/screenshotReviewTemplate.ts +52 -0
  66. package/src/templates/systemDiagramTemplate.ts +41 -0
  67. package/src/testing/StressTestGenerator.ts +134 -0
  68. package/src/testing/StressTestPanel.ts +64 -0
  69. package/src/tools/ArrowTool.ts +87 -0
  70. package/src/tools/ImageTool.ts +39 -0
  71. package/src/tools/PanTool.ts +34 -0
  72. package/src/tools/SelectTool.ts +377 -0
  73. package/src/tools/ShapeTool.ts +106 -0
  74. package/src/tools/StickyTool.ts +69 -0
  75. package/src/tools/TaskTool.ts +52 -0
  76. package/src/tools/TextTool.ts +45 -0
  77. package/src/tools/ToolContext.ts +44 -0
  78. package/src/tools/ToolController.ts +178 -0
  79. package/src/ui/Modal.ts +58 -0
  80. package/src/ui/PerformanceOverlay.ts +75 -0
  81. package/src/ui/StorageManager.ts +94 -0
  82. package/src/ui/TemplatePicker.ts +67 -0
  83. package/src/ui/TextEditorOverlay.ts +137 -0
  84. package/src/ui/Toolbar.ts +151 -0
  85. package/src/ui/TopControls.ts +137 -0
  86. package/src/ui/icons.ts +50 -0
  87. package/tsconfig.json +19 -0
  88. package/vite.config.ts +694 -0
@@ -0,0 +1,222 @@
1
+ import { ContentApi, normalizeRelativePath, type TreeNode } from "../content/ContentApi";
2
+ import { validateBoardDocument } from "../storage/BoardSerializer";
3
+ import type { BoardDocument } from "../elements/types";
4
+
5
+ export type BoardSummary = {
6
+ id: string;
7
+ title: string;
8
+ elementCount: number;
9
+ updatedAt: number;
10
+ filePath: string;
11
+ };
12
+
13
+ const SUFFIX = ".board.json";
14
+
15
+ export function boardIdFromFilePath(filePath: string): string {
16
+ return filePath.replace(/\.board\.json$/, "");
17
+ }
18
+
19
+ export function normalizeBoardId(input: string): string {
20
+ return input
21
+ .trim()
22
+ .replace(/^\/+/, "")
23
+ .replace(/\.board\.json$/, "");
24
+ }
25
+
26
+ export function resolveBoardIdFromDoc(src: string, docPath: string): string {
27
+ return resolveBoardIdCandidatesFromDoc(src, docPath)[0] ?? "";
28
+ }
29
+
30
+ export function resolveBoardIdCandidatesFromDoc(src: string, docPath: string): string[] {
31
+ const raw = src.trim();
32
+ const id = normalizeBoardId(src);
33
+ if (!id) return [];
34
+
35
+ // Explicit relative paths (./foo or ../foo) - resolve relative to doc's directory
36
+ if (raw.startsWith("./") || raw.startsWith("../")) {
37
+ const idx = docPath.lastIndexOf("/");
38
+ const dir = idx === -1 ? "" : docPath.slice(0, idx);
39
+ return [normalizeRelativePath(dir ? `${dir}/${id}` : id)];
40
+ }
41
+
42
+ // Absolute paths starting with / - resolve from root, with sibling fallback
43
+ if (raw.startsWith("/")) {
44
+ const idx = docPath.lastIndexOf("/");
45
+ const dir = idx === -1 ? "" : docPath.slice(0, idx);
46
+ const relative = normalizeRelativePath(dir ? `${dir}/${id}` : id);
47
+ // Try: 1) absolute path, 2) relative from doc's directory, 3) parent directory
48
+ const candidates = [id];
49
+ if (relative && relative !== id) candidates.push(relative);
50
+ if (dir) {
51
+ const parentDir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
52
+ const parentRelative = normalizeRelativePath(parentDir ? `${parentDir}/${id}` : id);
53
+ if (parentRelative && !candidates.includes(parentRelative)) {
54
+ candidates.push(parentRelative);
55
+ }
56
+ }
57
+ return candidates;
58
+ }
59
+
60
+ // Bare paths (no prefix) - smart resolution
61
+ const idx = docPath.lastIndexOf("/");
62
+ const dir = idx === -1 ? "" : docPath.slice(0, idx);
63
+ const relative = normalizeRelativePath(dir ? `${dir}/${id}` : id);
64
+ // Try: 1) same directory as doc, 2) absolute from root
65
+ return relative && relative !== id ? [relative, id] : [id];
66
+ }
67
+
68
+ function slugify(text: string): string {
69
+ return (
70
+ text
71
+ .toLowerCase()
72
+ .replace(/[^\w\s-]/g, "")
73
+ .trim()
74
+ .replace(/\s+/g, "-")
75
+ .slice(0, 48) || "board"
76
+ );
77
+ }
78
+
79
+ function blobToBase64(blob: Blob): Promise<string> {
80
+ return new Promise((resolve, reject) => {
81
+ const reader = new FileReader();
82
+ reader.onload = () => {
83
+ const result = reader.result as string;
84
+ resolve(result.slice(result.indexOf(",") + 1));
85
+ };
86
+ reader.onerror = () => reject(reader.error);
87
+ reader.readAsDataURL(blob);
88
+ });
89
+ }
90
+
91
+ /** File-backed board operations used by the Docs shell. */
92
+ export const BoardService = {
93
+ async listIds(rootId: string): Promise<string[]> {
94
+ const tree = await ContentApi.getTree(rootId);
95
+ return allBoardFiles(tree)
96
+ .map((f) => boardIdFromFilePath(f.path));
97
+ },
98
+
99
+ async list(rootId: string): Promise<BoardSummary[]> {
100
+ const ids = await this.listIds(rootId);
101
+ const out: BoardSummary[] = [];
102
+ for (const id of ids) {
103
+ const doc = await this.load(rootId, id);
104
+ if (doc) {
105
+ out.push({
106
+ id,
107
+ title: doc.title,
108
+ elementCount: doc.elements.length,
109
+ updatedAt: doc.updatedAt,
110
+ filePath: `${id}.board.json`,
111
+ });
112
+ }
113
+ }
114
+ out.sort((a, b) => b.updatedAt - a.updatedAt);
115
+ return out;
116
+ },
117
+
118
+ async load(rootId: string, id: string): Promise<BoardDocument | undefined> {
119
+ const raw = await ContentApi.getBoard(rootId, id);
120
+ if (!raw) return undefined;
121
+ return validateBoardDocument(raw) ?? undefined;
122
+ },
123
+
124
+ async save(rootId: string, doc: BoardDocument): Promise<void> {
125
+ await ContentApi.saveBoard(rootId, doc.id, doc);
126
+ },
127
+
128
+ async delete(rootId: string, id: string): Promise<void> {
129
+ await ContentApi.deleteBoard(rootId, id);
130
+ },
131
+
132
+ async move(rootId: string, id: string, targetDir: string): Promise<string> {
133
+ const doc = await this.load(rootId, id);
134
+ if (!doc) throw new Error("Board not found");
135
+ const name = id.split("/").pop() ?? id;
136
+ const cleanTarget = targetDir.replace(/^\/+|\/+$/g, "");
137
+ const newId = cleanTarget ? `${cleanTarget}/${name}` : name;
138
+ if (newId === id) return id;
139
+ const existing = new Set(await this.listIds(rootId));
140
+ if (existing.has(newId)) throw new Error("A board already exists there");
141
+ const now = Date.now();
142
+ await ContentApi.saveBoard(rootId, newId, { ...doc, id: newId, updatedAt: now });
143
+ await ContentApi.deleteBoard(rootId, id);
144
+ return newId;
145
+ },
146
+
147
+ async rename(rootId: string, id: string, title: string): Promise<string> {
148
+ const doc = await this.load(rootId, id);
149
+ if (!doc) throw new Error("Board not found");
150
+ const dir = id.includes("/") ? id.slice(0, id.lastIndexOf("/")) : "";
151
+ const slug = slugify(title);
152
+ const newId = dir ? `${dir}/${slug}` : slug;
153
+ const now = Date.now();
154
+ if (newId === id) {
155
+ await ContentApi.saveBoard(rootId, id, { ...doc, title: title.trim() || doc.title, updatedAt: now });
156
+ return id;
157
+ }
158
+ const existing = new Set(await this.listIds(rootId));
159
+ if (existing.has(newId)) throw new Error("A board already exists with that name");
160
+ await ContentApi.saveBoard(rootId, newId, {
161
+ ...doc,
162
+ id: newId,
163
+ title: title.trim() || doc.title,
164
+ updatedAt: now,
165
+ });
166
+ await ContentApi.deleteBoard(rootId, id);
167
+ return newId;
168
+ },
169
+
170
+ /**
171
+ * Creates a new board file. If `dirPath` is provided (e.g. "backend/api"),
172
+ * the board is placed at `<dirPath>/<slug>.board.json` relative to the docs
173
+ * root; otherwise it lands at `<slug>.board.json`.
174
+ */
175
+ async create(
176
+ rootId: string,
177
+ title: string,
178
+ theme: "light" | "dark",
179
+ dirPath = ""
180
+ ): Promise<string> {
181
+ const existing = new Set(await this.listIds(rootId));
182
+ const slug = slugify(title);
183
+ // Prefix slug with dirPath so the file lands in the right subdir.
184
+ const prefix = dirPath ? `${dirPath.replace(/^\/+|\/+$/g, "")}/` : "";
185
+ let id = `${prefix}${slug}`;
186
+ if (existing.has(id)) id = `${id}-${Date.now().toString(36)}`;
187
+ const now = Date.now();
188
+ const doc: BoardDocument = {
189
+ version: 1,
190
+ id,
191
+ title: title.trim() || "Untitled Board",
192
+ createdAt: now,
193
+ updatedAt: now,
194
+ viewport: { offsetX: 0, offsetY: 0, scale: 1 },
195
+ theme,
196
+ elements: [],
197
+ assetIds: [],
198
+ };
199
+ await ContentApi.saveBoard(rootId, id, doc);
200
+ return id;
201
+ },
202
+
203
+ async uploadImage(rootId: string, blob: Blob, name?: string): Promise<string> {
204
+ const base64 = await blobToBase64(blob);
205
+ return ContentApi.uploadAsset(rootId, name ?? "image.png", base64);
206
+ },
207
+ };
208
+
209
+ function allBoardFiles(tree: TreeNode[]): Array<Extract<TreeNode, { type: "file" }>> {
210
+ const out: Array<Extract<TreeNode, { type: "file" }>> = [];
211
+ const walk = (nodes: TreeNode[]) => {
212
+ for (const node of nodes) {
213
+ if (node.type === "file") {
214
+ if (node.name.endsWith(SUFFIX)) out.push(node);
215
+ } else {
216
+ walk(node.children);
217
+ }
218
+ }
219
+ };
220
+ walk(tree);
221
+ return out;
222
+ }
@@ -0,0 +1,253 @@
1
+ import type { CanvasSurface } from "./CanvasSurface";
2
+ import type { BoardState } from "../app/BoardState";
3
+ import type { ImageCache } from "./ImageCache";
4
+ import { getViewportBounds, worldToScreen } from "./Viewport";
5
+ import { renderElement, type RenderContext } from "../elements/renderElement";
6
+ import type { BoardElement } from "../elements/types";
7
+
8
+ export type OverlayState = {
9
+ marquee: { x: number; y: number; w: number; h: number } | null;
10
+ preview: BoardElement | null;
11
+ };
12
+
13
+ export class CanvasRenderer {
14
+ surface: CanvasSurface;
15
+ state: BoardState;
16
+ imageCache: ImageCache;
17
+ overlay: OverlayState = { marquee: null, preview: null };
18
+
19
+ lastRenderMs = 0;
20
+ visibleCount = 0;
21
+ cullingEnabled = true;
22
+ lodEnabled = true;
23
+
24
+ constructor(surface: CanvasSurface, state: BoardState, imageCache: ImageCache) {
25
+ this.surface = surface;
26
+ this.state = state;
27
+ this.imageCache = imageCache;
28
+ }
29
+
30
+ private getLod(): 0 | 1 | 2 {
31
+ if (!this.lodEnabled) return 2;
32
+ const s = this.state.viewport.scale;
33
+ if (s < 0.1) return 0;
34
+ if (s < 0.25) return 1;
35
+ return 2;
36
+ }
37
+
38
+ render(): void {
39
+ const start = performance.now();
40
+ this.renderGrid();
41
+ this.renderObjects();
42
+ this.renderOverlay();
43
+ this.lastRenderMs = performance.now() - start;
44
+ }
45
+
46
+ // ---------- Layer 0: background grid ----------
47
+
48
+ private renderGrid(): void {
49
+ const { surface, state } = this;
50
+ const ctx = surface.beginLayer(0);
51
+ const vp = state.viewport;
52
+
53
+ const bgColor = getComputedStyle(document.documentElement)
54
+ .getPropertyValue("--bg-canvas")
55
+ .trim();
56
+ const dotColor = getComputedStyle(document.documentElement)
57
+ .getPropertyValue("--grid-dot")
58
+ .trim();
59
+
60
+ ctx.fillStyle = bgColor || "#f3f2f1";
61
+ ctx.fillRect(0, 0, surface.width, surface.height);
62
+
63
+ // adaptive grid spacing: keep dots between 16-64 screen px
64
+ const baseSpacing = 32;
65
+ let spacing = baseSpacing * vp.scale;
66
+ // Guard against a corrupt/degenerate scale (0, negative, NaN, Infinity).
67
+ // Without this, the normalization loops below would never terminate and
68
+ // would freeze the whole tab with no error.
69
+ if (!Number.isFinite(spacing) || spacing <= 0) return;
70
+ while (spacing < 16) spacing *= 2;
71
+ while (spacing > 64) spacing /= 2;
72
+ if (spacing < 4) return; // too dense, skip
73
+
74
+ const offsetX = ((vp.offsetX % spacing) + spacing) % spacing;
75
+ const offsetY = ((vp.offsetY % spacing) + spacing) % spacing;
76
+ const dotRadius = Math.min(1.5, Math.max(0.75, vp.scale * 0.9));
77
+
78
+ ctx.fillStyle = dotColor || "rgba(0,0,0,0.14)";
79
+ ctx.beginPath();
80
+ for (let x = offsetX; x < surface.width; x += spacing) {
81
+ for (let y = offsetY; y < surface.height; y += spacing) {
82
+ ctx.moveTo(x + dotRadius, y);
83
+ ctx.arc(x, y, dotRadius, 0, Math.PI * 2);
84
+ }
85
+ }
86
+ ctx.fill();
87
+ }
88
+
89
+ // ---------- Layer 1: board objects ----------
90
+
91
+ private renderObjects(): void {
92
+ const { surface, state } = this;
93
+ const ctx = surface.beginLayer(1);
94
+ const vp = state.viewport;
95
+
96
+ ctx.translate(vp.offsetX, vp.offsetY);
97
+ ctx.scale(vp.scale, vp.scale);
98
+
99
+ const bounds = getViewportBounds(vp, surface.width, surface.height);
100
+ const elements = this.cullingEnabled
101
+ ? state.getVisibleElements(bounds)
102
+ : state.getSorted().filter((e) => !e.hidden);
103
+ this.visibleCount = elements.length;
104
+
105
+ const rc: RenderContext = {
106
+ ctx,
107
+ scale: vp.scale,
108
+ lod: this.getLod(),
109
+ theme: state.theme,
110
+ imageCache: this.imageCache,
111
+ };
112
+
113
+ for (const el of elements) {
114
+ renderElement(el, rc);
115
+ }
116
+ }
117
+
118
+ // ---------- Layer 2: selection, hover, previews ----------
119
+
120
+ private renderOverlay(): void {
121
+ const { surface, state } = this;
122
+ const ctx = surface.beginLayer(2);
123
+ const vp = state.viewport;
124
+
125
+ const selectionColor = getComputedStyle(document.documentElement)
126
+ .getPropertyValue("--selection")
127
+ .trim() || "#6e6e6e";
128
+
129
+ // hover highlight
130
+ if (state.hoveredId && !state.selectedIds.has(state.hoveredId)) {
131
+ const el = state.elementsById.get(state.hoveredId);
132
+ if (el) {
133
+ const bbox = this.elementScreenBBox(el);
134
+ ctx.strokeStyle = selectionColor;
135
+ ctx.globalAlpha = 0.45;
136
+ ctx.lineWidth = 1.5;
137
+ ctx.strokeRect(bbox.x, bbox.y, bbox.w, bbox.h);
138
+ ctx.globalAlpha = 1;
139
+ }
140
+ }
141
+
142
+ // selection
143
+ const selected = state.getSelected();
144
+ if (selected.length > 0) {
145
+ ctx.strokeStyle = selectionColor;
146
+ ctx.lineWidth = 1.5;
147
+
148
+ let minX = Infinity,
149
+ minY = Infinity,
150
+ maxX = -Infinity,
151
+ maxY = -Infinity;
152
+
153
+ for (const el of selected) {
154
+ const bbox = this.elementScreenBBox(el);
155
+ ctx.strokeRect(bbox.x, bbox.y, bbox.w, bbox.h);
156
+ minX = Math.min(minX, bbox.x);
157
+ minY = Math.min(minY, bbox.y);
158
+ maxX = Math.max(maxX, bbox.x + bbox.w);
159
+ maxY = Math.max(maxY, bbox.y + bbox.h);
160
+ }
161
+
162
+ // group bounding box for multi-select
163
+ if (selected.length > 1) {
164
+ ctx.setLineDash([4, 4]);
165
+ ctx.strokeRect(minX - 4, minY - 4, maxX - minX + 8, maxY - minY + 8);
166
+ ctx.setLineDash([]);
167
+ }
168
+
169
+ // resize handles (single selection or group box)
170
+ const hx = selected.length === 1 ? this.elementScreenBBox(selected[0]) : { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
171
+ this.drawHandles(ctx, hx, selectionColor);
172
+ }
173
+
174
+ // marquee
175
+ if (this.overlay.marquee) {
176
+ const m = this.overlay.marquee;
177
+ ctx.fillStyle = selectionColor + "22";
178
+ ctx.strokeStyle = selectionColor;
179
+ ctx.lineWidth = 1;
180
+ ctx.fillRect(m.x, m.y, m.w, m.h);
181
+ ctx.strokeRect(m.x, m.y, m.w, m.h);
182
+ }
183
+
184
+ // tool preview (drawing in progress)
185
+ if (this.overlay.preview) {
186
+ ctx.save();
187
+ ctx.translate(vp.offsetX, vp.offsetY);
188
+ ctx.scale(vp.scale, vp.scale);
189
+ ctx.globalAlpha = 0.8;
190
+ renderElement(this.overlay.preview, {
191
+ ctx,
192
+ scale: vp.scale,
193
+ lod: 2,
194
+ theme: state.theme,
195
+ imageCache: this.imageCache,
196
+ });
197
+ ctx.restore();
198
+ }
199
+ }
200
+
201
+ elementScreenBBox(el: BoardElement): { x: number; y: number; w: number; h: number } {
202
+ const vp = this.state.viewport;
203
+ const x1 = Math.min(el.x, el.x + el.width);
204
+ const y1 = Math.min(el.y, el.y + el.height);
205
+ const x2 = Math.max(el.x, el.x + el.width);
206
+ const y2 = Math.max(el.y, el.y + el.height);
207
+ const tl = worldToScreen(x1, y1, vp);
208
+ const br = worldToScreen(x2, y2, vp);
209
+ return { x: tl.x, y: tl.y, w: br.x - tl.x, h: br.y - tl.y };
210
+ }
211
+
212
+ private drawHandles(
213
+ ctx: CanvasRenderingContext2D,
214
+ bbox: { x: number; y: number; w: number; h: number },
215
+ color: string
216
+ ): void {
217
+ const size = 8;
218
+ const half = size / 2;
219
+ const points = handlePoints(bbox);
220
+ ctx.fillStyle = getComputedStyle(document.documentElement)
221
+ .getPropertyValue("--bg-panel")
222
+ .trim() || "#fff";
223
+ ctx.strokeStyle = color;
224
+ ctx.lineWidth = 1.5;
225
+ for (const p of points) {
226
+ ctx.beginPath();
227
+ ctx.rect(p.x - half, p.y - half, size, size);
228
+ ctx.fill();
229
+ ctx.stroke();
230
+ }
231
+ }
232
+ }
233
+
234
+ export type HandleId = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
235
+
236
+ export function handlePoints(bbox: {
237
+ x: number;
238
+ y: number;
239
+ w: number;
240
+ h: number;
241
+ }): { id: HandleId; x: number; y: number }[] {
242
+ const { x, y, w, h } = bbox;
243
+ return [
244
+ { id: "nw", x, y },
245
+ { id: "n", x: x + w / 2, y },
246
+ { id: "ne", x: x + w, y },
247
+ { id: "e", x: x + w, y: y + h / 2 },
248
+ { id: "se", x: x + w, y: y + h },
249
+ { id: "s", x: x + w / 2, y: y + h },
250
+ { id: "sw", x, y: y + h },
251
+ { id: "w", x, y: y + h / 2 },
252
+ ];
253
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Manages stacked canvas layers with devicePixelRatio handling.
3
+ * Layer 0: background grid
4
+ * Layer 1: board objects
5
+ * Layer 2: selection/handles/previews
6
+ */
7
+ export class CanvasSurface {
8
+ container: HTMLDivElement;
9
+ layers: HTMLCanvasElement[] = [];
10
+ contexts: CanvasRenderingContext2D[] = [];
11
+ width = 0;
12
+ height = 0;
13
+ dpr = 1;
14
+
15
+ private resizeListeners: (() => void)[] = [];
16
+
17
+ constructor(parent: HTMLElement, layerCount = 3, signal?: AbortSignal) {
18
+ this.container = document.createElement("div");
19
+ this.container.className = "canvas-stack";
20
+ parent.appendChild(this.container);
21
+
22
+ for (let i = 0; i < layerCount; i++) {
23
+ const canvas = document.createElement("canvas");
24
+ const ctx = canvas.getContext("2d", {
25
+ alpha: i !== 0 ? true : false,
26
+ }) as CanvasRenderingContext2D;
27
+ this.container.appendChild(canvas);
28
+ this.layers.push(canvas);
29
+ this.contexts.push(ctx);
30
+ }
31
+
32
+ this.resize();
33
+ window.addEventListener(
34
+ "resize",
35
+ () => {
36
+ this.resize();
37
+ for (const fn of this.resizeListeners) fn();
38
+ },
39
+ { signal }
40
+ );
41
+ }
42
+
43
+ get interactionLayer(): HTMLCanvasElement {
44
+ return this.layers[this.layers.length - 1];
45
+ }
46
+
47
+ onResize(fn: () => void): void {
48
+ this.resizeListeners.push(fn);
49
+ }
50
+
51
+ resize(): void {
52
+ this.dpr = Math.max(1, window.devicePixelRatio || 1);
53
+ this.width = window.innerWidth;
54
+ this.height = window.innerHeight;
55
+ for (const canvas of this.layers) {
56
+ canvas.width = Math.round(this.width * this.dpr);
57
+ canvas.height = Math.round(this.height * this.dpr);
58
+ canvas.style.width = this.width + "px";
59
+ canvas.style.height = this.height + "px";
60
+ }
61
+ }
62
+
63
+ /** Reset transform to identity * dpr and clear the layer. */
64
+ beginLayer(index: number): CanvasRenderingContext2D {
65
+ const ctx = this.contexts[index];
66
+ ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
67
+ ctx.clearRect(0, 0, this.width, this.height);
68
+ return ctx;
69
+ }
70
+ }
@@ -0,0 +1,110 @@
1
+ import type { BoardElement } from "../elements/types";
2
+ import type { BoardState } from "../app/BoardState";
3
+
4
+ /**
5
+ * Distance from a point to a line segment, in world units.
6
+ */
7
+ function distToSegment(
8
+ px: number,
9
+ py: number,
10
+ x1: number,
11
+ y1: number,
12
+ x2: number,
13
+ y2: number
14
+ ): number {
15
+ const dx = x2 - x1;
16
+ const dy = y2 - y1;
17
+ const lenSq = dx * dx + dy * dy;
18
+ if (lenSq === 0) return Math.hypot(px - x1, py - y1);
19
+ let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
20
+ t = Math.max(0, Math.min(1, t));
21
+ return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
22
+ }
23
+
24
+ export class HitTester {
25
+ private state: BoardState;
26
+ lastHitTestMs = 0;
27
+
28
+ constructor(state: BoardState) {
29
+ this.state = state;
30
+ }
31
+
32
+ /**
33
+ * Returns the topmost element at the given world point, or null.
34
+ */
35
+ hitTest(worldX: number, worldY: number, scale: number): BoardElement | null {
36
+ const start = performance.now();
37
+ const sorted = this.state.getSorted();
38
+ const tolerance = 6 / scale;
39
+ let result: BoardElement | null = null;
40
+
41
+ for (let i = sorted.length - 1; i >= 0; i--) {
42
+ const el = sorted[i];
43
+ if (el.hidden || el.locked) continue;
44
+ if (this.hitsElement(el, worldX, worldY, tolerance)) {
45
+ result = el;
46
+ break;
47
+ }
48
+ }
49
+
50
+ this.lastHitTestMs = performance.now() - start;
51
+ return result;
52
+ }
53
+
54
+ hitsElement(
55
+ el: BoardElement,
56
+ wx: number,
57
+ wy: number,
58
+ tolerance: number
59
+ ): boolean {
60
+ if (el.type === "line" || el.type === "arrow" || el.type === "connector") {
61
+ return (
62
+ distToSegment(wx, wy, el.x, el.y, el.x + el.width, el.y + el.height) <=
63
+ Math.max(tolerance, (el.style.strokeWidth ?? 2) / 2 + tolerance)
64
+ );
65
+ }
66
+
67
+ const x1 = Math.min(el.x, el.x + el.width);
68
+ const x2 = Math.max(el.x, el.x + el.width);
69
+ const y1 = Math.min(el.y, el.y + el.height);
70
+ const y2 = Math.max(el.y, el.y + el.height);
71
+
72
+ if (wx < x1 || wx > x2 || wy < y1 || wy > y2) return false;
73
+
74
+ if (el.type === "ellipse") {
75
+ const cx = (x1 + x2) / 2;
76
+ const cy = (y1 + y2) / 2;
77
+ const rx = (x2 - x1) / 2;
78
+ const ry = (y2 - y1) / 2;
79
+ if (rx === 0 || ry === 0) return false;
80
+ const nx = (wx - cx) / rx;
81
+ const ny = (wy - cy) / ry;
82
+ return nx * nx + ny * ny <= 1;
83
+ }
84
+
85
+ return true;
86
+ }
87
+
88
+ /** Elements fully or partially inside a world-space rect (for marquee). */
89
+ elementsInRect(
90
+ rx: number,
91
+ ry: number,
92
+ rw: number,
93
+ rh: number
94
+ ): BoardElement[] {
95
+ const x1 = Math.min(rx, rx + rw);
96
+ const x2 = Math.max(rx, rx + rw);
97
+ const y1 = Math.min(ry, ry + rh);
98
+ const y2 = Math.max(ry, ry + rh);
99
+ const out: BoardElement[] = [];
100
+ for (const el of this.state.elements) {
101
+ if (el.hidden || el.locked) continue;
102
+ const ex1 = Math.min(el.x, el.x + el.width);
103
+ const ex2 = Math.max(el.x, el.x + el.width);
104
+ const ey1 = Math.min(el.y, el.y + el.height);
105
+ const ey2 = Math.max(el.y, el.y + el.height);
106
+ if (ex1 >= x1 && ex2 <= x2 && ey1 >= y1 && ey2 <= y2) out.push(el);
107
+ }
108
+ return out;
109
+ }
110
+ }