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,64 @@
1
+ import type { StressTestGenerator } from "./StressTestGenerator";
2
+ import type { CanvasRenderer } from "../canvas/CanvasRenderer";
3
+ import { showToast } from "../ui/Modal";
4
+
5
+ export class StressTestPanel {
6
+ element: HTMLDivElement;
7
+ visible = false;
8
+
9
+ constructor(parent: HTMLElement, generator: StressTestGenerator, renderer: CanvasRenderer) {
10
+ this.element = document.createElement("div");
11
+ this.element.className = "stress-panel";
12
+
13
+ const addBtn = (text: string, onClick: () => void, danger = false) => {
14
+ const btn = document.createElement("button");
15
+ btn.className = "btn" + (danger ? " danger" : "");
16
+ btn.textContent = text;
17
+ btn.addEventListener("click", onClick);
18
+ this.element.appendChild(btn);
19
+ };
20
+
21
+ const gen = (n: number) => () => {
22
+ generator.generate(n);
23
+ showToast(`Generated ${n.toLocaleString()} elements in ${generator.lastGenerateMs.toFixed(0)}ms`);
24
+ };
25
+
26
+ addBtn("+100", gen(100));
27
+ addBtn("+1,000", gen(1000));
28
+ addBtn("+5,000", gen(5000));
29
+ addBtn("+10,000", gen(10000));
30
+ addBtn("+20,000", gen(20000));
31
+ addBtn("+100 images", () => {
32
+ generator.generateImagePlaceholders(100);
33
+ showToast("Generated 100 image placeholders");
34
+ });
35
+ addBtn("Mixed PM board", () => {
36
+ generator.generateMixedPmBoard();
37
+ showToast("Generated mixed PM board");
38
+ });
39
+ addBtn("Shuffle positions", () => generator.randomizePositions());
40
+ addBtn("Shuffle colors", () => generator.randomizeColors());
41
+ addBtn("Shuffle z-index", () => generator.randomizeZIndex());
42
+ addBtn("Toggle culling", () => {
43
+ renderer.cullingEnabled = !renderer.cullingEnabled;
44
+ showToast(`Viewport culling ${renderer.cullingEnabled ? "enabled" : "disabled"}`);
45
+ renderer.state.requestRender();
46
+ });
47
+ addBtn("Toggle LOD", () => {
48
+ renderer.lodEnabled = !renderer.lodEnabled;
49
+ showToast(`Simplified rendering ${renderer.lodEnabled ? "enabled" : "disabled"}`);
50
+ renderer.state.requestRender();
51
+ });
52
+ addBtn("Clear generated", () => {
53
+ generator.clearGenerated();
54
+ showToast("Cleared generated elements");
55
+ }, true);
56
+
57
+ parent.appendChild(this.element);
58
+ }
59
+
60
+ toggle(): void {
61
+ this.visible = !this.visible;
62
+ this.element.classList.toggle("visible", this.visible);
63
+ }
64
+ }
@@ -0,0 +1,87 @@
1
+ import type { Tool, ToolContext } from "./ToolContext";
2
+ import { createElement, type BoardElement } from "../elements/types";
3
+ import { screenToWorld } from "../canvas/Viewport";
4
+
5
+ export class ArrowTool implements Tool {
6
+ name = "arrow" as const;
7
+ cursor = "crosshair";
8
+
9
+ private drawing = false;
10
+ private startWorld = { x: 0, y: 0 };
11
+ private preview: BoardElement | null = null;
12
+
13
+ onPointerDown(e: PointerEvent, ctx: ToolContext): void {
14
+ const { state } = ctx;
15
+ this.drawing = true;
16
+ this.startWorld = screenToWorld(e.clientX, e.clientY, state.viewport);
17
+ this.preview = createElement("arrow", {
18
+ x: this.startWorld.x,
19
+ y: this.startWorld.y,
20
+ width: 0,
21
+ height: 0,
22
+ zIndex: state.maxZIndex() + 1,
23
+ style: {
24
+ stroke: state.theme === "dark" ? "#d1d5db" : "#374151",
25
+ strokeWidth: 2,
26
+ arrowEnd: true,
27
+ },
28
+ });
29
+ ctx.renderer.overlay.preview = this.preview;
30
+ }
31
+
32
+ onPointerMove(e: PointerEvent, ctx: ToolContext): void {
33
+ if (!this.drawing || !this.preview) return;
34
+ const world = screenToWorld(e.clientX, e.clientY, ctx.state.viewport);
35
+ let dx = world.x - this.startWorld.x;
36
+ let dy = world.y - this.startWorld.y;
37
+ if (e.shiftKey) {
38
+ // snap to 45-degree increments
39
+ const angle = Math.atan2(dy, dx);
40
+ const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4);
41
+ const len = Math.hypot(dx, dy);
42
+ dx = Math.cos(snapped) * len;
43
+ dy = Math.sin(snapped) * len;
44
+ }
45
+ this.preview.width = dx;
46
+ this.preview.height = dy;
47
+ ctx.state.requestRender();
48
+ }
49
+
50
+ onPointerUp(_e: PointerEvent, ctx: ToolContext): void {
51
+ const { state, commands } = ctx;
52
+ if (!this.drawing || !this.preview) return;
53
+ this.drawing = false;
54
+ ctx.renderer.overlay.preview = null;
55
+
56
+ const el = this.preview;
57
+ this.preview = null;
58
+
59
+ if (Math.hypot(el.width, el.height) < 8) {
60
+ state.requestRender();
61
+ return;
62
+ }
63
+
64
+ commands.execute(
65
+ "create-arrow",
66
+ () => {
67
+ if (!state.elementsById.has(el.id)) state.addElement(el);
68
+ state.emitChange();
69
+ },
70
+ () => {
71
+ state.removeElement(el.id);
72
+ state.emitChange();
73
+ }
74
+ );
75
+
76
+ state.clearSelection();
77
+ state.selectedIds.add(el.id);
78
+ ctx.setTool("select");
79
+ }
80
+
81
+ onCancel(ctx: ToolContext): void {
82
+ this.drawing = false;
83
+ this.preview = null;
84
+ ctx.renderer.overlay.preview = null;
85
+ ctx.state.requestRender();
86
+ }
87
+ }
@@ -0,0 +1,39 @@
1
+ import type { Tool, ToolContext } from "./ToolContext";
2
+
3
+ /**
4
+ * The image "tool" opens a file picker; insertion logic lives in
5
+ * ImageInsertService which is shared with paste and drag-and-drop.
6
+ */
7
+ export class ImageTool implements Tool {
8
+ name = "image" as const;
9
+ cursor = "default";
10
+
11
+ private onInsert: (files: FileList, x: number, y: number) => void;
12
+
13
+ constructor(onInsert: (files: FileList, x: number, y: number) => void) {
14
+ this.onInsert = onInsert;
15
+ }
16
+
17
+ onActivate(ctx: ToolContext): void {
18
+ const input = document.createElement("input");
19
+ input.type = "file";
20
+ input.accept = "image/png,image/jpeg,image/webp,image/gif";
21
+ input.multiple = true;
22
+ input.onchange = () => {
23
+ if (input.files && input.files.length > 0) {
24
+ this.onInsert(
25
+ input.files,
26
+ window.innerWidth / 2,
27
+ window.innerHeight / 2
28
+ );
29
+ }
30
+ ctx.setTool("select");
31
+ };
32
+ input.oncancel = () => ctx.setTool("select");
33
+ input.click();
34
+ }
35
+
36
+ onPointerDown(_e: PointerEvent, _ctx: ToolContext): void {}
37
+ onPointerMove(_e: PointerEvent, _ctx: ToolContext): void {}
38
+ onPointerUp(_e: PointerEvent, _ctx: ToolContext): void {}
39
+ }
@@ -0,0 +1,34 @@
1
+ import type { Tool, ToolContext } from "./ToolContext";
2
+
3
+ export class PanTool implements Tool {
4
+ name = "hand" as const;
5
+ cursor = "grab";
6
+
7
+ private panning = false;
8
+ private last = { x: 0, y: 0 };
9
+
10
+ onPointerDown(e: PointerEvent, ctx: ToolContext): void {
11
+ this.panning = true;
12
+ this.last = { x: e.clientX, y: e.clientY };
13
+ ctx.setCursor("grabbing");
14
+ }
15
+
16
+ onPointerMove(e: PointerEvent, ctx: ToolContext): void {
17
+ if (!this.panning) return;
18
+ const vp = ctx.state.viewport;
19
+ vp.offsetX += e.clientX - this.last.x;
20
+ vp.offsetY += e.clientY - this.last.y;
21
+ this.last = { x: e.clientX, y: e.clientY };
22
+ ctx.state.requestRender();
23
+ }
24
+
25
+ onPointerUp(_e: PointerEvent, ctx: ToolContext): void {
26
+ this.panning = false;
27
+ ctx.setCursor("grab");
28
+ }
29
+
30
+ onCancel(ctx: ToolContext): void {
31
+ this.panning = false;
32
+ ctx.setCursor("grab");
33
+ }
34
+ }
@@ -0,0 +1,377 @@
1
+ import type { Tool, ToolContext } from "./ToolContext";
2
+ import type { BoardElement } from "../elements/types";
3
+ import { screenToWorld } from "../canvas/Viewport";
4
+ import { handlePoints, type HandleId } from "../canvas/CanvasRenderer";
5
+ import { invalidateTextCache } from "../elements/renderElement";
6
+
7
+ type DragMode = "none" | "move" | "marquee" | "resize" | "endpoint";
8
+
9
+ type Snapshot = { id: string; x: number; y: number; width: number; height: number };
10
+
11
+ const HANDLE_HIT_RADIUS = 7;
12
+
13
+ const HANDLE_CURSORS: Record<HandleId, string> = {
14
+ nw: "nwse-resize",
15
+ n: "ns-resize",
16
+ ne: "nesw-resize",
17
+ e: "ew-resize",
18
+ se: "nwse-resize",
19
+ s: "ns-resize",
20
+ sw: "nesw-resize",
21
+ w: "ew-resize",
22
+ };
23
+
24
+ export class SelectTool implements Tool {
25
+ name = "select" as const;
26
+ cursor = "default";
27
+
28
+ private mode: DragMode = "none";
29
+ private startScreen = { x: 0, y: 0 };
30
+ private startWorld = { x: 0, y: 0 };
31
+ private snapshots: Snapshot[] = [];
32
+ private activeHandle: HandleId | null = null;
33
+ private endpointIsStart = false;
34
+ private moved = false;
35
+ private pointerDownHitId: string | null = null;
36
+
37
+ onPointerDown(e: PointerEvent, ctx: ToolContext): void {
38
+ const { state, hitTester, renderer } = ctx;
39
+ this.startScreen = { x: e.clientX, y: e.clientY };
40
+ this.startWorld = screenToWorld(e.clientX, e.clientY, state.viewport);
41
+ this.moved = false;
42
+
43
+ // 1. resize handle on current selection?
44
+ const handle = this.hitHandle(e, ctx);
45
+ if (handle) {
46
+ this.mode = "resize";
47
+ this.activeHandle = handle;
48
+ this.takeSnapshots(state.getSelected());
49
+ return;
50
+ }
51
+
52
+ // 2. arrow/line endpoint?
53
+ const selected = state.getSelected();
54
+ if (selected.length === 1 && this.isLinear(selected[0])) {
55
+ const ep = this.hitEndpoint(e, selected[0], ctx);
56
+ if (ep !== null) {
57
+ this.mode = "endpoint";
58
+ this.endpointIsStart = ep;
59
+ this.takeSnapshots(selected);
60
+ return;
61
+ }
62
+ }
63
+
64
+ // 3. element under cursor?
65
+ const hit = hitTester.hitTest(this.startWorld.x, this.startWorld.y, state.viewport.scale);
66
+ if (hit) {
67
+ this.pointerDownHitId = hit.id;
68
+ if (e.shiftKey) {
69
+ if (state.selectedIds.has(hit.id)) state.selectedIds.delete(hit.id);
70
+ else state.selectedIds.add(hit.id);
71
+ } else if (!state.selectedIds.has(hit.id)) {
72
+ state.clearSelection();
73
+ state.selectedIds.add(hit.id);
74
+ }
75
+ this.mode = "move";
76
+ this.takeSnapshots(state.getSelected());
77
+ state.requestRender();
78
+ return;
79
+ }
80
+
81
+ // 4. empty canvas: marquee
82
+ if (!e.shiftKey) state.clearSelection();
83
+ this.mode = "marquee";
84
+ renderer.overlay.marquee = { x: e.clientX, y: e.clientY, w: 0, h: 0 };
85
+ state.requestRender();
86
+ }
87
+
88
+ onPointerMove(e: PointerEvent, ctx: ToolContext): void {
89
+ const { state, renderer, hitTester } = ctx;
90
+
91
+ if (this.mode === "none") {
92
+ // hover feedback + handle cursors
93
+ const handle = this.hitHandle(e, ctx);
94
+ if (handle) {
95
+ ctx.setCursor(HANDLE_CURSORS[handle]);
96
+ return;
97
+ }
98
+ const world = screenToWorld(e.clientX, e.clientY, state.viewport);
99
+ const hit = hitTester.hitTest(world.x, world.y, state.viewport.scale);
100
+ const newHover = hit ? hit.id : null;
101
+ ctx.setCursor(hit ? "move" : "default");
102
+ if (newHover !== state.hoveredId) {
103
+ state.hoveredId = newHover;
104
+ state.requestRender();
105
+ }
106
+ return;
107
+ }
108
+
109
+ const dxScreen = e.clientX - this.startScreen.x;
110
+ const dyScreen = e.clientY - this.startScreen.y;
111
+ if (Math.abs(dxScreen) + Math.abs(dyScreen) > 2) this.moved = true;
112
+
113
+ const dx = dxScreen / state.viewport.scale;
114
+ const dy = dyScreen / state.viewport.scale;
115
+
116
+ if (this.mode === "move") {
117
+ for (const snap of this.snapshots) {
118
+ const el = state.elementsById.get(snap.id);
119
+ if (!el || el.locked) continue;
120
+ el.x = snap.x + dx;
121
+ el.y = snap.y + dy;
122
+ }
123
+ state.requestRender();
124
+ } else if (this.mode === "marquee") {
125
+ renderer.overlay.marquee = {
126
+ x: this.startScreen.x,
127
+ y: this.startScreen.y,
128
+ w: dxScreen,
129
+ h: dyScreen,
130
+ };
131
+ state.requestRender();
132
+ } else if (this.mode === "resize" && this.activeHandle) {
133
+ this.applyResize(dx, dy, e.shiftKey, ctx);
134
+ state.requestRender();
135
+ } else if (this.mode === "endpoint") {
136
+ const snap = this.snapshots[0];
137
+ const el = state.elementsById.get(snap.id);
138
+ if (el) {
139
+ if (this.endpointIsStart) {
140
+ el.x = snap.x + dx;
141
+ el.y = snap.y + dy;
142
+ el.width = snap.width - dx;
143
+ el.height = snap.height - dy;
144
+ } else {
145
+ el.width = snap.width + dx;
146
+ el.height = snap.height + dy;
147
+ }
148
+ }
149
+ state.requestRender();
150
+ }
151
+ }
152
+
153
+ onPointerUp(e: PointerEvent, ctx: ToolContext): void {
154
+ const { state, renderer, hitTester, commands } = ctx;
155
+
156
+ if (this.mode === "marquee") {
157
+ const m = renderer.overlay.marquee;
158
+ renderer.overlay.marquee = null;
159
+ if (m && (Math.abs(m.w) > 3 || Math.abs(m.h) > 3)) {
160
+ const a = screenToWorld(m.x, m.y, state.viewport);
161
+ const b = screenToWorld(m.x + m.w, m.y + m.h, state.viewport);
162
+ const hits = hitTester.elementsInRect(a.x, a.y, b.x - a.x, b.y - a.y);
163
+ for (const el of hits) state.selectedIds.add(el.id);
164
+ }
165
+ state.requestRender();
166
+ } else if (
167
+ (this.mode === "move" || this.mode === "resize" || this.mode === "endpoint") &&
168
+ this.moved
169
+ ) {
170
+ // push undo command with before/after snapshots
171
+ const before = this.snapshots;
172
+ const after: Snapshot[] = before.map((s) => {
173
+ const el = state.elementsById.get(s.id)!;
174
+ return { id: s.id, x: el.x, y: el.y, width: el.width, height: el.height };
175
+ });
176
+ const apply = (snaps: Snapshot[]) => {
177
+ for (const s of snaps) {
178
+ const el = state.elementsById.get(s.id);
179
+ if (!el) continue;
180
+ el.x = s.x;
181
+ el.y = s.y;
182
+ el.width = s.width;
183
+ el.height = s.height;
184
+ el.updatedAt = Date.now();
185
+ invalidateTextCache(el.id);
186
+ }
187
+ state.emitChange();
188
+ };
189
+ for (const s of after) {
190
+ const el = state.elementsById.get(s.id);
191
+ if (el) {
192
+ el.updatedAt = Date.now();
193
+ invalidateTextCache(el.id);
194
+ }
195
+ }
196
+ commands.push(
197
+ this.mode,
198
+ () => apply(after),
199
+ () => apply(before)
200
+ );
201
+ state.emitChange();
202
+ } else if (this.mode === "move" && !this.moved) {
203
+ this.toggleTaskIfCheckboxClick(e, ctx);
204
+ }
205
+
206
+ this.mode = "none";
207
+ this.activeHandle = null;
208
+ this.snapshots = [];
209
+ this.pointerDownHitId = null;
210
+ }
211
+
212
+ onDoubleClick(e: PointerEvent, ctx: ToolContext): void {
213
+ const { state, hitTester } = ctx;
214
+ const world = screenToWorld(e.clientX, e.clientY, state.viewport);
215
+ const hit = hitTester.hitTest(world.x, world.y, state.viewport.scale);
216
+ if (hit && (hit.type === "sticky" || hit.type === "task" || hit.type === "text" || hit.type === "rectangle" || hit.type === "ellipse" || hit.type === "frame")) {
217
+ ctx.openTextEditor(hit);
218
+ }
219
+ }
220
+
221
+ onCancel(ctx: ToolContext): void {
222
+ // restore snapshots if mid-drag
223
+ if (this.mode !== "none" && this.snapshots.length > 0) {
224
+ for (const s of this.snapshots) {
225
+ const el = ctx.state.elementsById.get(s.id);
226
+ if (!el) continue;
227
+ el.x = s.x;
228
+ el.y = s.y;
229
+ el.width = s.width;
230
+ el.height = s.height;
231
+ }
232
+ }
233
+ ctx.renderer.overlay.marquee = null;
234
+ this.mode = "none";
235
+ ctx.state.clearSelection();
236
+ ctx.state.requestRender();
237
+ }
238
+
239
+ // ---------- helpers ----------
240
+
241
+ private isLinear(el: BoardElement): boolean {
242
+ return el.type === "line" || el.type === "arrow" || el.type === "connector";
243
+ }
244
+
245
+ private takeSnapshots(elements: BoardElement[]): void {
246
+ this.snapshots = elements.map((el) => ({
247
+ id: el.id,
248
+ x: el.x,
249
+ y: el.y,
250
+ width: el.width,
251
+ height: el.height,
252
+ }));
253
+ }
254
+
255
+ private toggleTaskIfCheckboxClick(e: PointerEvent, ctx: ToolContext): void {
256
+ const { state, hitTester, commands } = ctx;
257
+ const world = screenToWorld(e.clientX, e.clientY, state.viewport);
258
+ const hit = hitTester.hitTest(world.x, world.y, state.viewport.scale);
259
+ if (!hit || hit.id !== this.pointerDownHitId || hit.type !== "task") return;
260
+ if (!this.hitTaskCheckbox(world.x, world.y, hit)) return;
261
+ const before = hit.data.checked === true;
262
+ const after = !before;
263
+ const id = hit.id;
264
+ const apply = (checked: boolean) => {
265
+ const el = state.elementsById.get(id);
266
+ if (!el) return;
267
+ el.data.checked = checked;
268
+ el.updatedAt = Date.now();
269
+ state.emitChange();
270
+ };
271
+ commands.execute("toggle-task", () => apply(after), () => apply(before));
272
+ }
273
+
274
+ private hitTaskCheckbox(wx: number, wy: number, el: BoardElement): boolean {
275
+ const boxSize = Math.min(22, Math.max(16, el.height * 0.38));
276
+ const boxX = el.x + 16;
277
+ const boxY = el.y + el.height / 2 - boxSize / 2;
278
+ const pad = 8;
279
+ return wx >= boxX - pad && wx <= boxX + boxSize + pad && wy >= boxY - pad && wy <= boxY + boxSize + pad;
280
+ }
281
+
282
+ private hitHandle(e: PointerEvent, ctx: ToolContext): HandleId | null {
283
+ const { state, renderer } = ctx;
284
+ const selected = state.getSelected();
285
+ if (selected.length === 0) return null;
286
+ if (selected.length === 1 && this.isLinear(selected[0])) return null;
287
+
288
+ let bbox;
289
+ if (selected.length === 1) {
290
+ bbox = renderer.elementScreenBBox(selected[0]);
291
+ } else {
292
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
293
+ for (const el of selected) {
294
+ const b = renderer.elementScreenBBox(el);
295
+ minX = Math.min(minX, b.x);
296
+ minY = Math.min(minY, b.y);
297
+ maxX = Math.max(maxX, b.x + b.w);
298
+ maxY = Math.max(maxY, b.y + b.h);
299
+ }
300
+ bbox = { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
301
+ }
302
+
303
+ for (const p of handlePoints(bbox)) {
304
+ if (
305
+ Math.abs(e.clientX - p.x) <= HANDLE_HIT_RADIUS &&
306
+ Math.abs(e.clientY - p.y) <= HANDLE_HIT_RADIUS
307
+ ) {
308
+ return p.id;
309
+ }
310
+ }
311
+ return null;
312
+ }
313
+
314
+ private hitEndpoint(e: PointerEvent, el: BoardElement, ctx: ToolContext): boolean | null {
315
+ const vp = ctx.state.viewport;
316
+ const sx1 = el.x * vp.scale + vp.offsetX;
317
+ const sy1 = el.y * vp.scale + vp.offsetY;
318
+ const sx2 = (el.x + el.width) * vp.scale + vp.offsetX;
319
+ const sy2 = (el.y + el.height) * vp.scale + vp.offsetY;
320
+ if (Math.hypot(e.clientX - sx1, e.clientY - sy1) <= HANDLE_HIT_RADIUS + 2) return true;
321
+ if (Math.hypot(e.clientX - sx2, e.clientY - sy2) <= HANDLE_HIT_RADIUS + 2) return false;
322
+ return null;
323
+ }
324
+
325
+ private applyResize(dx: number, dy: number, preserveAspect: boolean, ctx: ToolContext): void {
326
+ const { state } = ctx;
327
+ const h = this.activeHandle!;
328
+
329
+ // group bbox in world coords from snapshots
330
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
331
+ for (const s of this.snapshots) {
332
+ minX = Math.min(minX, Math.min(s.x, s.x + s.width));
333
+ minY = Math.min(minY, Math.min(s.y, s.y + s.height));
334
+ maxX = Math.max(maxX, Math.max(s.x, s.x + s.width));
335
+ maxY = Math.max(maxY, Math.max(s.y, s.y + s.height));
336
+ }
337
+ const ow = maxX - minX;
338
+ const oh = maxY - minY;
339
+ if (ow === 0 || oh === 0) return;
340
+
341
+ let nx = minX, ny = minY, nw = ow, nh = oh;
342
+ if (h.includes("e")) nw = ow + dx;
343
+ if (h.includes("w")) { nx = minX + dx; nw = ow - dx; }
344
+ if (h.includes("s")) nh = oh + dy;
345
+ if (h.includes("n")) { ny = minY + dy; nh = oh - dy; }
346
+
347
+ if (preserveAspect && (h === "nw" || h === "ne" || h === "se" || h === "sw")) {
348
+ const ratio = ow / oh;
349
+ if (Math.abs(nw / ow) > Math.abs(nh / oh)) {
350
+ const newH = nw / ratio;
351
+ if (h.includes("n")) ny = ny + (nh - newH);
352
+ nh = newH;
353
+ } else {
354
+ const newW = nh * ratio;
355
+ if (h.includes("w")) nx = nx + (nw - newW);
356
+ nw = newW;
357
+ }
358
+ }
359
+
360
+ const MIN = 4;
361
+ if (Math.abs(nw) < MIN || Math.abs(nh) < MIN) return;
362
+
363
+ const scaleX = nw / ow;
364
+ const scaleY = nh / oh;
365
+
366
+ for (const s of this.snapshots) {
367
+ const el = state.elementsById.get(s.id);
368
+ if (!el || el.locked) continue;
369
+ el.x = nx + (s.x - minX) * scaleX;
370
+ el.y = ny + (s.y - minY) * scaleY;
371
+ el.width = s.width * scaleX;
372
+ el.height = s.height * scaleY;
373
+ el.updatedAt = Date.now();
374
+ invalidateTextCache(el.id);
375
+ }
376
+ }
377
+ }