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