viteboard 0.1.1 → 0.1.2

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 (81) hide show
  1. package/content/docs/index.md +1 -1
  2. package/content/docs/product-roadmap.board.json +4 -4
  3. package/content/machine/board-v1.schema.json +138 -0
  4. package/content/machine/example.board.json +217 -0
  5. package/content/machine/guide.md +270 -0
  6. package/content/machine/llms.txt +31 -0
  7. package/content/machine/viteboard.json +62 -0
  8. package/dist/assets/index-D2W76uu9.js +96 -0
  9. package/dist/assets/index-rMkUjGXP.css +1 -0
  10. package/dist/index.html +4 -2
  11. package/package.json +10 -12
  12. package/vite.config.ts +619 -38
  13. package/dist/assets/index-C30zRPsW.js +0 -60
  14. package/dist/assets/index-e5aqtN8W.css +0 -1
  15. package/index.html +0 -13
  16. package/src/app/AppController.ts +0 -962
  17. package/src/app/BoardState.ts +0 -130
  18. package/src/app/ClipboardService.ts +0 -159
  19. package/src/app/CommandManager.ts +0 -66
  20. package/src/app/ElementActions.ts +0 -152
  21. package/src/app/EmbedRegionSelector.ts +0 -103
  22. package/src/app/ExportPng.ts +0 -107
  23. package/src/app/ImageInsertService.ts +0 -141
  24. package/src/app/KeyboardShortcuts.ts +0 -124
  25. package/src/app/main.ts +0 -6
  26. package/src/board/BoardPreview.ts +0 -189
  27. package/src/board/BoardService.ts +0 -222
  28. package/src/canvas/CanvasRenderer.ts +0 -253
  29. package/src/canvas/CanvasSurface.ts +0 -70
  30. package/src/canvas/HitTester.ts +0 -110
  31. package/src/canvas/ImageCache.ts +0 -123
  32. package/src/canvas/PerformanceMonitor.ts +0 -31
  33. package/src/canvas/RenderScheduler.ts +0 -26
  34. package/src/canvas/Viewport.ts +0 -77
  35. package/src/content/ContentApi.ts +0 -258
  36. package/src/content/Markdown.ts +0 -431
  37. package/src/content/MarkdownView.ts +0 -70
  38. package/src/editor/DocEditor.ts +0 -799
  39. package/src/editor/htmlToMarkdown.ts +0 -333
  40. package/src/elements/renderElement.ts +0 -598
  41. package/src/elements/types.ts +0 -119
  42. package/src/shell/Shell.ts +0 -2948
  43. package/src/shell/Sidebar.ts +0 -1651
  44. package/src/storage/AssetStore.ts +0 -86
  45. package/src/storage/BoardSerializer.ts +0 -114
  46. package/src/storage/ImportExportService.ts +0 -153
  47. package/src/storage/IndexedDbStore.ts +0 -92
  48. package/src/storage/LocalBoardStore.ts +0 -104
  49. package/src/styles.css +0 -3332
  50. package/src/templates/handoffTemplate.ts +0 -34
  51. package/src/templates/helpers.ts +0 -132
  52. package/src/templates/index.ts +0 -79
  53. package/src/templates/journeyMapTemplate.ts +0 -52
  54. package/src/templates/opportunityTreeTemplate.ts +0 -45
  55. package/src/templates/personaTemplate.ts +0 -41
  56. package/src/templates/prioritizationMatrixTemplate.ts +0 -44
  57. package/src/templates/requirementsFlowTemplate.ts +0 -45
  58. package/src/templates/screenshotReviewTemplate.ts +0 -52
  59. package/src/templates/systemDiagramTemplate.ts +0 -41
  60. package/src/templates/workflowTemplate.ts +0 -35
  61. package/src/testing/StressTestGenerator.ts +0 -134
  62. package/src/testing/StressTestPanel.ts +0 -64
  63. package/src/tools/ArrowTool.ts +0 -97
  64. package/src/tools/ImageTool.ts +0 -39
  65. package/src/tools/PanTool.ts +0 -34
  66. package/src/tools/SelectTool.ts +0 -377
  67. package/src/tools/ShapeTool.ts +0 -106
  68. package/src/tools/StickyTool.ts +0 -69
  69. package/src/tools/TaskTool.ts +0 -52
  70. package/src/tools/TextTool.ts +0 -45
  71. package/src/tools/ToolContext.ts +0 -51
  72. package/src/tools/ToolController.ts +0 -178
  73. package/src/ui/Modal.ts +0 -58
  74. package/src/ui/PerformanceOverlay.ts +0 -75
  75. package/src/ui/StorageManager.ts +0 -94
  76. package/src/ui/TemplatePicker.ts +0 -67
  77. package/src/ui/TextEditorOverlay.ts +0 -262
  78. package/src/ui/Toolbar.ts +0 -189
  79. package/src/ui/TopControls.ts +0 -137
  80. package/src/ui/icons.ts +0 -56
  81. package/tsconfig.json +0 -19
@@ -1,222 +0,0 @@
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
- }
@@ -1,253 +0,0 @@
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
- }
@@ -1,70 +0,0 @@
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
- }
@@ -1,110 +0,0 @@
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
- }