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,955 @@
1
+ import { BoardState } from "./BoardState";
2
+ import { CommandManager } from "./CommandManager";
3
+ import { ElementActions } from "./ElementActions";
4
+ import { ImageInsertService } from "./ImageInsertService";
5
+ import { ClipboardService } from "./ClipboardService";
6
+ import { bindKeyboardShortcuts } from "./KeyboardShortcuts";
7
+ import { exportViewportPng, exportFullBoardPng, downloadBlob } from "./ExportPng";
8
+ import { EmbedRegionSelector, type ScreenRect } from "./EmbedRegionSelector";
9
+
10
+ import { CanvasSurface } from "../canvas/CanvasSurface";
11
+ import { CanvasRenderer } from "../canvas/CanvasRenderer";
12
+ import { RenderScheduler } from "../canvas/RenderScheduler";
13
+ import { HitTester } from "../canvas/HitTester";
14
+ import { ImageCache } from "../canvas/ImageCache";
15
+ import { PerformanceMonitor } from "../canvas/PerformanceMonitor";
16
+ import { zoomAt, clampZoom, DEFAULT_ZOOM, screenToWorld } from "../canvas/Viewport";
17
+ import type { BoardDocument } from "../elements/types";
18
+
19
+ import { ToolController } from "../tools/ToolController";
20
+ import type { ToolContext, ToolName } from "../tools/ToolContext";
21
+ import { SelectTool } from "../tools/SelectTool";
22
+ import { PanTool } from "../tools/PanTool";
23
+ import { StickyTool, STICKY_COLORS, setLastStickyColor } from "../tools/StickyTool";
24
+ import { TaskTool } from "../tools/TaskTool";
25
+ import { ShapeTool } from "../tools/ShapeTool";
26
+ import { TextTool } from "../tools/TextTool";
27
+ import { ArrowTool } from "../tools/ArrowTool";
28
+ import { ImageTool } from "../tools/ImageTool";
29
+
30
+ import { boardStore } from "../storage/LocalBoardStore";
31
+ import { assetStore } from "../storage/AssetStore";
32
+ import { serializeBoard } from "../storage/BoardSerializer";
33
+ import { importExportService } from "../storage/ImportExportService";
34
+
35
+ import { Toolbar } from "../ui/Toolbar";
36
+ import { TopControls } from "../ui/TopControls";
37
+ import { TextEditorOverlay } from "../ui/TextEditorOverlay";
38
+ import { openTemplatePicker } from "../ui/TemplatePicker";
39
+ import { openStorageManager } from "../ui/StorageManager";
40
+ import { PerformanceOverlay } from "../ui/PerformanceOverlay";
41
+ import { openModal, showToast } from "../ui/Modal";
42
+
43
+ import { StressTestGenerator } from "../testing/StressTestGenerator";
44
+ import { StressTestPanel } from "../testing/StressTestPanel";
45
+ import { invalidateTextCache } from "../elements/renderElement";
46
+
47
+ const BOARD_SAVE_DEBOUNCE = 300;
48
+ const VIEWPORT_SAVE_DEBOUNCE = 1000;
49
+ const TEXT_SIZE_PRESETS = [
50
+ { label: "Small", shortLabel: "S", fontSize: 13, fontWeight: "400" },
51
+ { label: "Body", shortLabel: "Body", fontSize: 16, fontWeight: "400" },
52
+ { label: "H3", shortLabel: "H3", fontSize: 18, fontWeight: "650" },
53
+ { label: "H2", shortLabel: "H2", fontSize: 24, fontWeight: "700" },
54
+ { label: "H1", shortLabel: "H1", fontSize: 32, fontWeight: "750" },
55
+ ];
56
+ const TEXT_CAPABLE_TYPES = new Set(["text"]);
57
+ const SHAPE_TYPES = new Set(["rectangle", "ellipse", "frame"]);
58
+ const SHAPE_FILL_COLORS = ["#0078d4", "#4dabf7", "#6e6e6e", "#ffffff", "#0f0f0f", "#fef08a", "#93c5fd", "#fca5a5"];
59
+ const SHAPE_TEXT_COLORS = [
60
+ { label: "Black text", value: "#1f2933" },
61
+ { label: "White text", value: "#ffffff" },
62
+ ];
63
+
64
+ /**
65
+ * Optional configuration. With no options the controller still supports the
66
+ * legacy standalone IndexedDB-backed board mode. When file-backed options are
67
+ * passed (by the Docs shell) it loads/saves a board through repo files and
68
+ * uploads pasted images into the active docs root instead of IndexedDB.
69
+ */
70
+ export type AppOptions = {
71
+ boardId?: string;
72
+ loadBoard?: (id: string) => Promise<BoardDocument | undefined>;
73
+ saveBoard?: (doc: BoardDocument) => Promise<void>;
74
+ deleteBoard?: (id: string) => Promise<void>;
75
+ /** Persist an image blob to disk; returns the relative `src` path to store. */
76
+ uploadImage?: (blob: Blob, name?: string) => Promise<string>;
77
+ /** Resolve a board image src path inside the active docs root. */
78
+ resolveAssetUrl?: (src: string) => string;
79
+ /** Optional world-space region to fit when opening a board from an embed. */
80
+ initialRegion?: [number, number, number, number];
81
+ onExit?: () => void;
82
+ onDeleted?: () => void;
83
+ };
84
+
85
+ export class AppController {
86
+ state = new BoardState();
87
+ commands = new CommandManager();
88
+ surface: CanvasSurface;
89
+ renderer: CanvasRenderer;
90
+ scheduler: RenderScheduler;
91
+ hitTester: HitTester;
92
+ imageCache: ImageCache;
93
+ monitor = new PerformanceMonitor();
94
+
95
+ actions: ElementActions;
96
+ imageInsert: ImageInsertService;
97
+ toolController!: ToolController;
98
+ textEditor: TextEditorOverlay;
99
+
100
+ toolbar!: Toolbar;
101
+ topControls!: TopControls;
102
+ perfOverlay!: PerformanceOverlay;
103
+ stressPanel!: StressTestPanel;
104
+
105
+ private boardSaveTimer: number | null = null;
106
+ private viewportSaveTimer: number | null = null;
107
+ private options: AppOptions;
108
+ private listenerAbort = new AbortController();
109
+ private root: HTMLElement;
110
+ private bottomToolCluster: HTMLElement | null = null;
111
+ private stickyColorPopover: HTMLElement | null = null;
112
+ private textSizePopover: HTMLElement | null = null;
113
+ private shapeStylePopover: HTMLElement | null = null;
114
+ private layerMenu: HTMLElement | null = null;
115
+
116
+ constructor(root: HTMLElement, options: AppOptions = {}) {
117
+ this.root = root;
118
+ this.options = options;
119
+ if (options.boardId) this.state.boardId = options.boardId;
120
+ const signal = this.listenerAbort.signal;
121
+ this.surface = new CanvasSurface(root, 3, signal);
122
+ this.imageCache = new ImageCache(
123
+ assetStore,
124
+ () => this.state.requestRender(),
125
+ options.resolveAssetUrl
126
+ );
127
+ this.renderer = new CanvasRenderer(this.surface, this.state, this.imageCache);
128
+ this.scheduler = new RenderScheduler(() => this.renderer.render());
129
+ this.hitTester = new HitTester(this.state);
130
+ this.actions = new ElementActions(this.state, this.commands);
131
+ this.textEditor = new TextEditorOverlay(this.state, this.commands);
132
+ this.imageInsert = new ImageInsertService(
133
+ this.state,
134
+ this.commands,
135
+ this.imageCache,
136
+ showToast,
137
+ options.uploadImage
138
+ );
139
+
140
+ this.state.onRenderNeeded(() => {
141
+ this.scheduler.requestRender();
142
+ this.topControls?.setZoom(this.state.viewport.scale);
143
+ this.scheduleViewportSave();
144
+ this.updateContextualToolPanels();
145
+ });
146
+ this.state.onChange(() => this.scheduleBoardSave());
147
+ this.surface.onResize(() => this.state.requestRender());
148
+
149
+ this.setupTools(root, signal);
150
+ this.setupUi(root);
151
+ this.setupSelectionOverlays(root, signal);
152
+ this.setupDragDrop(root);
153
+ new ClipboardService(this.state, this.actions, this.imageInsert, signal);
154
+
155
+ bindKeyboardShortcuts(
156
+ {
157
+ state: this.state,
158
+ commands: this.commands,
159
+ actions: this.actions,
160
+ toolController: this.toolController,
161
+ resetView: () => this.resetView(),
162
+ },
163
+ signal
164
+ );
165
+
166
+ void this.loadInitialState();
167
+ }
168
+
169
+ /**
170
+ * Tear down all window-level listeners and free image bitmaps. The host is
171
+ * expected to remove the editor's root container afterwards (which disposes
172
+ * the canvas/DOM listeners attached to it).
173
+ */
174
+ destroy(): void {
175
+ if (this.boardSaveTimer !== null) clearTimeout(this.boardSaveTimer);
176
+ if (this.viewportSaveTimer !== null) clearTimeout(this.viewportSaveTimer);
177
+ this.listenerAbort.abort();
178
+ this.imageCache.clear();
179
+ }
180
+
181
+ // ---------- setup ----------
182
+
183
+ private setupTools(root: HTMLElement, signal?: AbortSignal): void {
184
+ const ctx: ToolContext = {
185
+ state: this.state,
186
+ renderer: this.renderer,
187
+ surface: this.surface,
188
+ hitTester: this.hitTester,
189
+ commands: this.commands,
190
+ setTool: (tool) => this.toolController.setTool(tool),
191
+ setCursor: (cursor) => {
192
+ this.surface.container.style.cursor = cursor;
193
+ },
194
+ openTextEditor: (el) => this.textEditor.open(el),
195
+ };
196
+
197
+ this.toolController = new ToolController(
198
+ this.surface.container,
199
+ ctx,
200
+ [
201
+ new SelectTool(),
202
+ new PanTool(),
203
+ new StickyTool(),
204
+ new TaskTool(),
205
+ new ShapeTool("rectangle"),
206
+ new ShapeTool("ellipse"),
207
+ new ShapeTool("diamond"),
208
+ new ShapeTool("triangle"),
209
+ new ShapeTool("pill"),
210
+ new ShapeTool("chevron"),
211
+ new TextTool(),
212
+ new ArrowTool(),
213
+ new ImageTool((files, x, y) => void this.imageInsert.insertFiles(files, x, y)),
214
+ ],
215
+ signal
216
+ );
217
+
218
+ this.toolController.onToolChange((tool: ToolName) => {
219
+ this.toolbar?.setActiveTool(tool);
220
+ });
221
+ }
222
+
223
+ private setupUi(root: HTMLElement): void {
224
+ this.toolbar = new Toolbar(root, {
225
+ setTool: (tool) => this.toolController.setTool(tool),
226
+ openTemplates: () => openTemplatePicker(this.state, this.commands),
227
+ toggleTheme: () => this.toggleTheme(),
228
+ openExportMenu: () => this.openExportMenu(),
229
+ toggleStressPanel: () => this.stressPanel.toggle(),
230
+ // Embed tool only makes sense for file-backed boards (the doc shell).
231
+ copyEmbed: this.options.onExit ? () => this.startEmbedSelection() : undefined,
232
+ });
233
+ this.toolbar.setActiveTool("select");
234
+
235
+ this.topControls = new TopControls(
236
+ root,
237
+ {
238
+ onExit: this.options.onExit ? () => this.exitToDocs() : undefined,
239
+ resetView: () => this.resetView(),
240
+ zoomIn: () => {
241
+ zoomAt(this.state.viewport, window.innerWidth / 2, window.innerHeight / 2, this.state.viewport.scale * 1.2);
242
+ this.state.requestRender();
243
+ },
244
+ zoomOut: () => {
245
+ zoomAt(this.state.viewport, window.innerWidth / 2, window.innerHeight / 2, this.state.viewport.scale / 1.2);
246
+ this.state.requestRender();
247
+ },
248
+ openStorageManager: () =>
249
+ openStorageManager(
250
+ this.state,
251
+ () => this.clearBoard(),
252
+ () => void this.resetAllLocalData()
253
+ ),
254
+ togglePerfOverlay: () => this.perfOverlay.toggle(),
255
+ deleteBoard: this.options.deleteBoard ? () => this.confirmDeleteBoard() : undefined,
256
+ onTitleChange: (title) => {
257
+ this.state.title = title;
258
+ this.state.emitChange();
259
+ },
260
+ },
261
+ this.state.title
262
+ );
263
+
264
+ this.perfOverlay = new PerformanceOverlay(
265
+ root,
266
+ this.state,
267
+ this.renderer,
268
+ this.hitTester,
269
+ this.monitor,
270
+ this.imageCache
271
+ );
272
+
273
+ const generator = new StressTestGenerator(this.state);
274
+ this.stressPanel = new StressTestPanel(root, generator, this.renderer);
275
+ }
276
+
277
+ private setupSelectionOverlays(root: HTMLElement, signal: AbortSignal): void {
278
+ this.bottomToolCluster = document.createElement("div");
279
+ this.bottomToolCluster.className = "bottom-tool-cluster";
280
+ root.appendChild(this.bottomToolCluster);
281
+ this.bottomToolCluster.appendChild(this.toolbar.element);
282
+
283
+ this.stickyColorPopover = document.createElement("div");
284
+ this.stickyColorPopover.className = "context-tool-popover sticky-color-popover";
285
+ this.stickyColorPopover.setAttribute("aria-hidden", "true");
286
+ for (const color of STICKY_COLORS) {
287
+ const btn = document.createElement("button");
288
+ btn.type = "button";
289
+ btn.className = "sticky-color-btn";
290
+ btn.title = "Set sticky color";
291
+ btn.setAttribute("aria-label", `Set sticky color ${color}`);
292
+ btn.style.setProperty("--swatch", color);
293
+ btn.addEventListener("click", () => this.setSelectedStickyColor(color));
294
+ this.stickyColorPopover.appendChild(btn);
295
+ }
296
+ this.bottomToolCluster.appendChild(this.stickyColorPopover);
297
+
298
+ this.textSizePopover = document.createElement("div");
299
+ this.textSizePopover.className = "context-tool-popover text-size-popover";
300
+ this.textSizePopover.setAttribute("aria-hidden", "true");
301
+ for (const preset of TEXT_SIZE_PRESETS) {
302
+ const btn = document.createElement("button");
303
+ btn.type = "button";
304
+ btn.className = "text-size-btn";
305
+ btn.textContent = preset.shortLabel;
306
+ btn.setAttribute("aria-label", `Set text size ${preset.label}`);
307
+ btn.addEventListener("click", () => this.setSelectedTextSize(preset));
308
+ this.textSizePopover.appendChild(btn);
309
+ }
310
+ this.bottomToolCluster.appendChild(this.textSizePopover);
311
+
312
+ this.shapeStylePopover = document.createElement("div");
313
+ this.shapeStylePopover.className = "context-tool-popover shape-style-popover";
314
+ this.shapeStylePopover.setAttribute("aria-hidden", "true");
315
+ for (const color of SHAPE_FILL_COLORS) {
316
+ const btn = document.createElement("button");
317
+ btn.type = "button";
318
+ btn.className = "shape-fill-btn";
319
+ btn.title = "Set shape color";
320
+ btn.setAttribute("aria-label", `Set shape color ${color}`);
321
+ btn.style.setProperty("--swatch", color);
322
+ btn.addEventListener("click", () => this.setSelectedShapeFill(color));
323
+ this.shapeStylePopover.appendChild(btn);
324
+ }
325
+ const divider = document.createElement("div");
326
+ divider.className = "context-tool-divider";
327
+ this.shapeStylePopover.appendChild(divider);
328
+ for (const color of SHAPE_TEXT_COLORS) {
329
+ const btn = document.createElement("button");
330
+ btn.type = "button";
331
+ btn.className = "shape-text-color-btn";
332
+ btn.textContent = color.value === "#ffffff" ? "W" : "B";
333
+ btn.setAttribute("aria-label", color.label);
334
+ btn.addEventListener("click", () => this.setSelectedShapeTextColor(color.value));
335
+ this.shapeStylePopover.appendChild(btn);
336
+ }
337
+ this.bottomToolCluster.appendChild(this.shapeStylePopover);
338
+
339
+ this.surface.interactionLayer.addEventListener(
340
+ "contextmenu",
341
+ (e) => this.openLayerMenu(e),
342
+ { signal, capture: true }
343
+ );
344
+
345
+ document.addEventListener(
346
+ "pointerdown",
347
+ (e) => {
348
+ if (this.layerMenu && !this.layerMenu.contains(e.target as Node)) this.closeLayerMenu();
349
+ },
350
+ { signal }
351
+ );
352
+ document.addEventListener(
353
+ "keydown",
354
+ (e) => {
355
+ if (e.key === "Escape") this.closeLayerMenu();
356
+ },
357
+ { signal }
358
+ );
359
+ }
360
+
361
+ private updateContextualToolPanels(): void {
362
+ this.updateStickyColorPopover();
363
+ this.updateTextSizePopover();
364
+ this.updateShapeStylePopover();
365
+ }
366
+
367
+ private setContextPanelVisible(panel: HTMLElement | null, isVisible: boolean): void {
368
+ if (!panel) return;
369
+ panel.classList.toggle("is-visible", isVisible);
370
+ panel.setAttribute("aria-hidden", String(!isVisible));
371
+ }
372
+
373
+ private updateStickyColorPopover(): void {
374
+ const popover = this.stickyColorPopover;
375
+ if (!popover) return;
376
+ const selectedStickies = this.state.getSelected().filter((el) => el.type === "sticky");
377
+ const shouldShow = selectedStickies.length > 0 && !this.textEditor.isEditing;
378
+ this.setContextPanelVisible(popover, shouldShow);
379
+ if (!shouldShow) return;
380
+
381
+ const activeFill = selectedStickies[0]?.style.fill;
382
+ for (const child of popover.children) {
383
+ const btn = child as HTMLButtonElement;
384
+ const isActive = btn.style.getPropertyValue("--swatch") === activeFill;
385
+ btn.classList.toggle("active", isActive);
386
+ btn.setAttribute("aria-pressed", String(isActive));
387
+ }
388
+ }
389
+
390
+ private setSelectedStickyColor(color: string): void {
391
+ setLastStickyColor(color);
392
+ const targets = this.state.getSelected().filter((el) => el.type === "sticky" && !el.locked);
393
+ if (targets.length === 0) return;
394
+ const state = this.state;
395
+ const before = targets.map((el) => ({
396
+ id: el.id,
397
+ fill: el.style.fill,
398
+ textColor: el.style.textColor,
399
+ updatedAt: el.updatedAt,
400
+ }));
401
+ const after = targets.map((el) => ({
402
+ id: el.id,
403
+ fill: color,
404
+ textColor: "#1f2933",
405
+ updatedAt: Date.now(),
406
+ }));
407
+ const apply = (snaps: typeof before): void => {
408
+ for (const snap of snaps) {
409
+ const el = state.elementsById.get(snap.id);
410
+ if (!el) continue;
411
+ el.style.fill = snap.fill;
412
+ el.style.textColor = snap.textColor;
413
+ el.updatedAt = snap.updatedAt;
414
+ }
415
+ state.emitChange();
416
+ };
417
+ this.commands.execute("sticky-color", () => apply(after), () => apply(before));
418
+ }
419
+
420
+ private updateTextSizePopover(): void {
421
+ const popover = this.textSizePopover;
422
+ if (!popover) return;
423
+ const targets = this.selectedTextTargets();
424
+ const shouldShow = targets.length > 0 && !this.textEditor.isEditing;
425
+ this.setContextPanelVisible(popover, shouldShow);
426
+ if (!shouldShow) return;
427
+
428
+ const activeSize = targets[0]?.style.fontSize ?? (targets[0]?.type === "sticky" ? 15 : 16);
429
+ for (const child of popover.children) {
430
+ const btn = child as HTMLButtonElement;
431
+ const preset = TEXT_SIZE_PRESETS.find((item) => item.shortLabel === btn.textContent);
432
+ const isActive = preset?.fontSize === activeSize;
433
+ btn.classList.toggle("active", isActive);
434
+ btn.setAttribute("aria-pressed", String(isActive));
435
+ }
436
+ }
437
+
438
+ private selectedTextTargets(): ReturnType<BoardState["getSelected"]> {
439
+ return this.state
440
+ .getSelected()
441
+ .filter((el) => TEXT_CAPABLE_TYPES.has(el.type) && !el.locked);
442
+ }
443
+
444
+ private setSelectedTextSize(preset: (typeof TEXT_SIZE_PRESETS)[number]): void {
445
+ const targets = this.selectedTextTargets();
446
+ if (targets.length === 0) return;
447
+ const state = this.state;
448
+ const before = targets.map((el) => ({
449
+ id: el.id,
450
+ fontSize: el.style.fontSize,
451
+ fontWeight: el.style.fontWeight,
452
+ height: el.height,
453
+ updatedAt: el.updatedAt,
454
+ }));
455
+ const after = targets.map((el) => ({
456
+ id: el.id,
457
+ fontSize: preset.fontSize,
458
+ fontWeight: preset.fontWeight,
459
+ height: el.type === "text" ? Math.max(el.height, Math.ceil(preset.fontSize * 1.45)) : el.height,
460
+ updatedAt: Date.now(),
461
+ }));
462
+ const apply = (snaps: typeof before): void => {
463
+ for (const snap of snaps) {
464
+ const el = state.elementsById.get(snap.id);
465
+ if (!el) continue;
466
+ el.style.fontSize = snap.fontSize;
467
+ el.style.fontWeight = snap.fontWeight;
468
+ el.height = snap.height;
469
+ el.updatedAt = snap.updatedAt;
470
+ invalidateTextCache(el.id);
471
+ }
472
+ state.emitChange();
473
+ };
474
+ this.commands.execute("text-size", () => apply(after), () => apply(before));
475
+ }
476
+
477
+ private updateShapeStylePopover(): void {
478
+ const popover = this.shapeStylePopover;
479
+ if (!popover) return;
480
+ const targets = this.selectedShapeTargets();
481
+ const shouldShow = targets.length > 0 && !this.textEditor.isEditing;
482
+ this.setContextPanelVisible(popover, shouldShow);
483
+ if (!shouldShow) return;
484
+
485
+ const activeFill = targets[0]?.style.fill;
486
+ const activeTextColor = targets[0]?.style.textColor ?? "#1f2933";
487
+ for (const child of popover.children) {
488
+ const el = child as HTMLElement;
489
+ if (el.classList.contains("shape-fill-btn")) {
490
+ const isActive = el.style.getPropertyValue("--swatch") === activeFill;
491
+ el.classList.toggle("active", isActive);
492
+ el.setAttribute("aria-pressed", String(isActive));
493
+ }
494
+ if (el.classList.contains("shape-text-color-btn")) {
495
+ const isWhite = el.textContent === "W";
496
+ const isActive = isWhite ? activeTextColor === "#ffffff" : activeTextColor !== "#ffffff";
497
+ el.classList.toggle("active", isActive);
498
+ el.setAttribute("aria-pressed", String(isActive));
499
+ }
500
+ }
501
+ }
502
+
503
+ private selectedShapeTargets(): ReturnType<BoardState["getSelected"]> {
504
+ return this.state
505
+ .getSelected()
506
+ .filter((el) => SHAPE_TYPES.has(el.type) && !el.locked);
507
+ }
508
+
509
+ private setSelectedShapeFill(color: string): void {
510
+ const targets = this.selectedShapeTargets();
511
+ if (targets.length === 0) return;
512
+ const state = this.state;
513
+ const before = targets.map((el) => ({ id: el.id, fill: el.style.fill, updatedAt: el.updatedAt }));
514
+ const after = targets.map((el) => ({ id: el.id, fill: color, updatedAt: Date.now() }));
515
+ const apply = (snaps: typeof before): void => {
516
+ for (const snap of snaps) {
517
+ const el = state.elementsById.get(snap.id);
518
+ if (!el) continue;
519
+ el.style.fill = snap.fill;
520
+ el.updatedAt = snap.updatedAt;
521
+ }
522
+ state.emitChange();
523
+ };
524
+ this.commands.execute("shape-fill", () => apply(after), () => apply(before));
525
+ }
526
+
527
+ private setSelectedShapeTextColor(color: string): void {
528
+ const targets = this.selectedShapeTargets();
529
+ if (targets.length === 0) return;
530
+ const state = this.state;
531
+ const before = targets.map((el) => ({ id: el.id, textColor: el.style.textColor, updatedAt: el.updatedAt }));
532
+ const after = targets.map((el) => ({ id: el.id, textColor: color, updatedAt: Date.now() }));
533
+ const apply = (snaps: typeof before): void => {
534
+ for (const snap of snaps) {
535
+ const el = state.elementsById.get(snap.id);
536
+ if (!el) continue;
537
+ el.style.textColor = snap.textColor;
538
+ el.updatedAt = snap.updatedAt;
539
+ invalidateTextCache(el.id);
540
+ }
541
+ state.emitChange();
542
+ };
543
+ this.commands.execute("shape-text-color", () => apply(after), () => apply(before));
544
+ }
545
+
546
+ private openLayerMenu(e: MouseEvent): void {
547
+ e.preventDefault();
548
+ e.stopPropagation();
549
+ if (this.textEditor.isEditing) return;
550
+
551
+ const world = screenToWorld(e.clientX, e.clientY, this.state.viewport);
552
+ const hit = this.hitTester.hitTest(world.x, world.y, this.state.viewport.scale);
553
+ if (hit && !this.state.selectedIds.has(hit.id)) {
554
+ this.state.clearSelection();
555
+ this.state.selectedIds.add(hit.id);
556
+ this.state.requestRender();
557
+ }
558
+
559
+ const selected = this.state.getSelected();
560
+ if (selected.length === 0) {
561
+ this.closeLayerMenu();
562
+ return;
563
+ }
564
+
565
+ this.closeLayerMenu();
566
+ const menu = document.createElement("div");
567
+ menu.className = "layer-menu";
568
+ menu.setAttribute("role", "menu");
569
+ const label = document.createElement("div");
570
+ label.className = "layer-menu-label";
571
+ label.textContent = selected.length === 1 ? "Layer" : `${selected.length} layers`;
572
+ menu.appendChild(label);
573
+
574
+ const addAction = (text: string, action: () => void) => {
575
+ const btn = document.createElement("button");
576
+ btn.type = "button";
577
+ btn.setAttribute("role", "menuitem");
578
+ btn.textContent = text;
579
+ btn.addEventListener("click", () => {
580
+ action();
581
+ this.closeLayerMenu();
582
+ });
583
+ menu.appendChild(btn);
584
+ };
585
+ const addDivider = () => {
586
+ const div = document.createElement("div");
587
+ div.className = "layer-menu-divider";
588
+ menu.appendChild(div);
589
+ };
590
+
591
+ addAction("Bring forward", () => this.actions.bringForward());
592
+ addAction("Send backward", () => this.actions.sendBackward());
593
+ addDivider();
594
+ addAction("Bring to front", () => this.actions.bringToFront());
595
+ addAction("Send to back", () => this.actions.sendToBack());
596
+
597
+ this.root.appendChild(menu);
598
+ this.layerMenu = menu;
599
+ this.positionLayerMenu(menu, e.clientX, e.clientY);
600
+ }
601
+
602
+ private positionLayerMenu(menu: HTMLElement, x: number, y: number): void {
603
+ const margin = 12;
604
+ menu.style.left = "0px";
605
+ menu.style.top = "0px";
606
+ requestAnimationFrame(() => {
607
+ const rect = menu.getBoundingClientRect();
608
+ const left = Math.min(Math.max(margin, x), window.innerWidth - rect.width - margin);
609
+ const top = Math.min(Math.max(margin, y), window.innerHeight - rect.height - margin);
610
+ menu.style.left = `${left}px`;
611
+ menu.style.top = `${top}px`;
612
+ });
613
+ }
614
+
615
+ private closeLayerMenu(): void {
616
+ this.layerMenu?.remove();
617
+ this.layerMenu = null;
618
+ }
619
+
620
+ /** Save, then hand control back to the docs shell. */
621
+ private exitToDocs(): void {
622
+ if (this.boardSaveTimer !== null) {
623
+ clearTimeout(this.boardSaveTimer);
624
+ this.boardSaveTimer = null;
625
+ }
626
+ void this.saveBoard().finally(() => this.options.onExit?.());
627
+ }
628
+
629
+ /**
630
+ * Launch the drag-to-select overlay. The user draws a rectangle over the
631
+ * board; that screen rectangle is converted to a world-space region and a
632
+ * `board` embed snippet is copied to the clipboard automatically.
633
+ */
634
+ private startEmbedSelection(): void {
635
+ EmbedRegionSelector.begin(this.root, (rect) => {
636
+ if (rect) void this.copyEmbedSnippet(rect);
637
+ });
638
+ }
639
+
640
+ /** Build a `board` markdown directive for the given screen rectangle. */
641
+ private async copyEmbedSnippet(rect: ScreenRect): Promise<void> {
642
+ const vp = this.state.viewport;
643
+ const topLeft = screenToWorld(rect.x, rect.y, vp);
644
+ const bottomRight = screenToWorld(rect.x + rect.w, rect.y + rect.h, vp);
645
+ const x = Math.round(topLeft.x);
646
+ const y = Math.round(topLeft.y);
647
+ const w = Math.round(bottomRight.x - topLeft.x);
648
+ const h = Math.round(bottomRight.y - topLeft.y);
649
+ const snippet =
650
+ "```board\n" +
651
+ `src: /${this.state.boardId}\n` +
652
+ `region: ${x}, ${y}, ${w}, ${h}\n` +
653
+ "height: 360\n" +
654
+ `title: ${this.state.title}\n` +
655
+ "```";
656
+ try {
657
+ await navigator.clipboard.writeText(snippet);
658
+ showToast("Embed snippet copied — paste it into any doc");
659
+ } catch {
660
+ showToast("Could not access clipboard");
661
+ }
662
+ }
663
+
664
+ private setupDragDrop(root: HTMLElement): void {
665
+ root.addEventListener("dragover", (e) => {
666
+ e.preventDefault();
667
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
668
+ });
669
+ root.addEventListener("drop", (e) => {
670
+ e.preventDefault();
671
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
672
+ void this.imageInsert.insertFiles(e.dataTransfer.files, e.clientX, e.clientY);
673
+ }
674
+ });
675
+ }
676
+
677
+ // ---------- persistence ----------
678
+
679
+ private async loadInitialState(): Promise<void> {
680
+ // theme first (instant)
681
+ const savedTheme = boardStore.getPref("theme");
682
+ if (savedTheme === "dark" || savedTheme === "light") {
683
+ this.applyTheme(savedTheme);
684
+ } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
685
+ this.applyTheme("dark");
686
+ }
687
+
688
+ // File-backed mode: load the specific board through the injected loader.
689
+ if (this.options.loadBoard) {
690
+ try {
691
+ const doc = await this.options.loadBoard(this.options.boardId ?? this.state.boardId);
692
+ if (doc) {
693
+ this.applyDocument(doc);
694
+ this.fitInitialRegion();
695
+ }
696
+ } catch (err) {
697
+ console.error("Board file load failed", err);
698
+ showToast("Could not load board file");
699
+ }
700
+ this.state.requestRender();
701
+ return;
702
+ }
703
+
704
+ const lastBoardId = boardStore.getPref("lastBoardId") ?? "default-board";
705
+ try {
706
+ const doc = await boardStore.loadBoard(lastBoardId);
707
+ if (doc) this.applyDocument(doc);
708
+ } catch (err) {
709
+ console.error("Board recovery failed", err);
710
+ const recovery = boardStore.getPref("recovery");
711
+ if (recovery) {
712
+ showToast("Could not load saved board — starting fresh");
713
+ }
714
+ }
715
+ this.state.requestRender();
716
+ }
717
+
718
+ private applyDocument(doc: BoardDocument): void {
719
+ this.state.boardId = doc.id;
720
+ this.state.title = doc.title;
721
+ this.state.createdAt = doc.createdAt;
722
+ this.state.viewport = { ...doc.viewport };
723
+ this.state.elements = [];
724
+ this.state.elementsById.clear();
725
+ for (const el of doc.elements) this.state.addElement(el);
726
+ this.applyTheme(doc.theme);
727
+ this.topControls.setTitle(doc.title);
728
+ this.topControls.setZoom(doc.viewport.scale);
729
+ this.state.invalidateSort();
730
+ }
731
+
732
+ private fitInitialRegion(): void {
733
+ const region = this.options.initialRegion;
734
+ if (!region) return;
735
+ const [x, y, w, h] = region;
736
+ if (w <= 0 || h <= 0) return;
737
+
738
+ const marginX = Math.min(120, Math.max(48, this.surface.width * 0.08));
739
+ const topMargin = 88;
740
+ const bottomMargin = 124;
741
+ const availableW = Math.max(240, this.surface.width - marginX * 2);
742
+ const availableH = Math.max(180, this.surface.height - topMargin - bottomMargin);
743
+ const fitScale = Math.min(availableW / w, availableH / h);
744
+ const maxComfortScale = Math.max(0.7, Math.min(1.1, this.state.viewport.scale * 1.15));
745
+ const scale = clampZoom(Math.min(fitScale * 0.58, maxComfortScale));
746
+
747
+ this.state.viewport = {
748
+ scale,
749
+ offsetX: marginX + (availableW - w * scale) / 2 - x * scale,
750
+ offsetY: topMargin + (availableH - h * scale) / 2 - y * scale,
751
+ };
752
+ this.topControls.setZoom(scale);
753
+ }
754
+
755
+ private scheduleBoardSave(): void {
756
+ if (this.boardSaveTimer !== null) clearTimeout(this.boardSaveTimer);
757
+ this.boardSaveTimer = window.setTimeout(() => {
758
+ this.boardSaveTimer = null;
759
+ void this.saveBoard();
760
+ }, BOARD_SAVE_DEBOUNCE);
761
+ }
762
+
763
+ private scheduleViewportSave(): void {
764
+ if (this.viewportSaveTimer !== null) return;
765
+ this.viewportSaveTimer = window.setTimeout(() => {
766
+ this.viewportSaveTimer = null;
767
+ void this.saveBoard();
768
+ }, VIEWPORT_SAVE_DEBOUNCE);
769
+ }
770
+
771
+ private async saveBoard(): Promise<void> {
772
+ try {
773
+ if (this.options.saveBoard) {
774
+ await this.options.saveBoard(serializeBoard(this.state));
775
+ } else {
776
+ await boardStore.saveBoard(serializeBoard(this.state));
777
+ }
778
+ } catch {
779
+ showToast(this.options.saveBoard ? "Save to file failed" : "Save failed — browser storage may be full");
780
+ }
781
+ }
782
+
783
+ // ---------- theme ----------
784
+
785
+ applyTheme(theme: "light" | "dark"): void {
786
+ this.state.theme = theme;
787
+ document.documentElement.setAttribute("data-theme", theme);
788
+ boardStore.setPref("theme", theme);
789
+ this.toolbar?.setTheme(theme);
790
+ this.state.requestRender();
791
+ }
792
+
793
+ toggleTheme(): void {
794
+ this.applyTheme(this.state.theme === "dark" ? "light" : "dark");
795
+ this.scheduleBoardSave();
796
+ }
797
+
798
+ // ---------- view ----------
799
+
800
+ resetView(): void {
801
+ this.state.viewport.offsetX = 0;
802
+ this.state.viewport.offsetY = 0;
803
+ this.state.viewport.scale = DEFAULT_ZOOM;
804
+ this.state.requestRender();
805
+ }
806
+
807
+ // ---------- board management ----------
808
+
809
+ clearBoard(): void {
810
+ const state = this.state;
811
+ const removed = [...state.elements];
812
+ this.commands.execute(
813
+ "clear-board",
814
+ () => {
815
+ for (const el of removed) state.removeElement(el.id);
816
+ state.emitChange();
817
+ },
818
+ () => {
819
+ for (const el of removed) {
820
+ if (!state.elementsById.has(el.id)) state.addElement(el);
821
+ }
822
+ state.emitChange();
823
+ }
824
+ );
825
+ showToast("Board cleared");
826
+ }
827
+
828
+ async resetAllLocalData(): Promise<void> {
829
+ await boardStore.clearAllBoards();
830
+ await assetStore.clearAll();
831
+ boardStore.clearPrefs();
832
+ this.imageCache.clear();
833
+ this.commands.clear();
834
+ this.state.elements = [];
835
+ this.state.elementsById.clear();
836
+ this.state.clearSelection();
837
+ this.state.invalidateSort();
838
+ this.resetView();
839
+ showToast("All local data cleared");
840
+ }
841
+
842
+ private confirmDeleteBoard(): void {
843
+ openModal("Delete board?", (body, close) => {
844
+ const p = document.createElement("p");
845
+ p.className = "modal-copy";
846
+ p.textContent = `This will permanently delete "${this.state.title}" from the active docs root. This cannot be undone.`;
847
+ const actions = document.createElement("div");
848
+ actions.className = "modal-actions";
849
+ const cancel = document.createElement("button");
850
+ cancel.className = "btn";
851
+ cancel.textContent = "Cancel";
852
+ cancel.addEventListener("click", close);
853
+ const del = document.createElement("button");
854
+ del.className = "btn danger";
855
+ del.textContent = "Delete board";
856
+ del.addEventListener("click", async () => {
857
+ del.disabled = true;
858
+ if (this.boardSaveTimer !== null) {
859
+ clearTimeout(this.boardSaveTimer);
860
+ this.boardSaveTimer = null;
861
+ }
862
+ if (this.viewportSaveTimer !== null) {
863
+ clearTimeout(this.viewportSaveTimer);
864
+ this.viewportSaveTimer = null;
865
+ }
866
+ try {
867
+ await this.options.deleteBoard?.(this.state.boardId);
868
+ close();
869
+ showToast("Board deleted");
870
+ (this.options.onDeleted ?? this.options.onExit)?.();
871
+ } catch {
872
+ del.disabled = false;
873
+ showToast("Could not delete board");
874
+ }
875
+ });
876
+ actions.append(cancel, del);
877
+ body.append(p, actions);
878
+ });
879
+ }
880
+
881
+ // ---------- export / import ----------
882
+
883
+ private openExportMenu(): void {
884
+ openModal("Export & import", (body, close) => {
885
+ const actions = document.createElement("div");
886
+ actions.className = "modal-actions";
887
+ actions.style.flexDirection = "column";
888
+ actions.style.alignItems = "stretch";
889
+
890
+ const addAction = (label: string, fn: () => void | Promise<void>, primary = false) => {
891
+ const btn = document.createElement("button");
892
+ btn.className = "btn" + (primary ? " primary" : "");
893
+ btn.textContent = label;
894
+ btn.addEventListener("click", () => {
895
+ void fn();
896
+ close();
897
+ });
898
+ actions.appendChild(btn);
899
+ };
900
+
901
+ addAction("Export current view as PNG", async () => {
902
+ const blob = await exportViewportPng(this.state, this.imageCache, this.surface.width, this.surface.height);
903
+ downloadBlob(blob, this.state.title + "-view.png");
904
+ }, true);
905
+
906
+ addAction("Export full board as PNG", async () => {
907
+ const blob = await exportFullBoardPng(this.state, this.imageCache);
908
+ if (blob) downloadBlob(blob, this.state.title + "-board.png");
909
+ else showToast("Board is empty");
910
+ });
911
+
912
+ addAction("Export board JSON (no images)", () => {
913
+ importExportService.exportBoardJson(serializeBoard(this.state));
914
+ });
915
+
916
+ addAction("Export full bundle (JSON + images)", async () => {
917
+ await importExportService.exportFullBundle(serializeBoard(this.state));
918
+ });
919
+
920
+ addAction("Import board from file…", () => this.pickImportFile());
921
+
922
+ body.appendChild(actions);
923
+ });
924
+ }
925
+
926
+ private pickImportFile(): void {
927
+ const input = document.createElement("input");
928
+ input.type = "file";
929
+ input.accept = "application/json,.json";
930
+ input.onchange = async () => {
931
+ const file = input.files?.[0];
932
+ if (!file) return;
933
+ const doc = await importExportService.importFromFile(file);
934
+ if (!doc) {
935
+ showToast("Import failed — invalid board file");
936
+ return;
937
+ }
938
+ // replace current board only after successful validation
939
+ this.state.boardId = doc.id;
940
+ this.state.title = doc.title;
941
+ this.state.createdAt = doc.createdAt;
942
+ this.state.viewport = { ...doc.viewport };
943
+ this.state.elements = [];
944
+ this.state.elementsById.clear();
945
+ this.state.clearSelection();
946
+ for (const el of doc.elements) this.state.addElement(el);
947
+ this.applyTheme(doc.theme);
948
+ this.topControls.setTitle(doc.title);
949
+ this.commands.clear();
950
+ this.state.emitChange();
951
+ showToast("Board imported");
952
+ };
953
+ input.click();
954
+ }
955
+ }