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,509 @@
1
+ import type { BoardElement } from "./types";
2
+ import type { ImageCache } from "../canvas/ImageCache";
3
+
4
+ export type RenderContext = {
5
+ ctx: CanvasRenderingContext2D;
6
+ scale: number;
7
+ /** level of detail: 2 = full, 1 = simplified (<25%), 0 = blocks (<10%) */
8
+ lod: 0 | 1 | 2;
9
+ theme: "light" | "dark";
10
+ imageCache: ImageCache;
11
+ };
12
+
13
+ const textMeasureCache = new Map<string, string[]>();
14
+ const TEXT_CACHE_LIMIT = 2000;
15
+
16
+ export function invalidateTextCache(elementId: string): void {
17
+ for (const key of textMeasureCache.keys()) {
18
+ if (key.startsWith(elementId + "|")) textMeasureCache.delete(key);
19
+ }
20
+ }
21
+
22
+ export function getRenderedTextFontSize(el: BoardElement): number {
23
+ if (el.data.shapeKind === "chevron") {
24
+ const byHeight = Math.abs(el.height) * 0.34;
25
+ const byWidth = Math.abs(el.width) * 0.18;
26
+ return Math.round(Math.max(13, Math.min(48, byHeight, byWidth)));
27
+ }
28
+ return el.style.fontSize ?? 14;
29
+ }
30
+
31
+ function wrapText(
32
+ ctx: CanvasRenderingContext2D,
33
+ cacheKey: string,
34
+ text: string,
35
+ maxWidth: number
36
+ ): string[] {
37
+ const cached = textMeasureCache.get(cacheKey);
38
+ if (cached) return cached;
39
+
40
+ const lines: string[] = [];
41
+ for (const paragraph of text.split("\n")) {
42
+ if (paragraph === "") {
43
+ lines.push("");
44
+ continue;
45
+ }
46
+ const words = paragraph.split(" ");
47
+ let line = "";
48
+ for (const word of words) {
49
+ const test = line === "" ? word : line + " " + word;
50
+ if (ctx.measureText(test).width > maxWidth && line !== "") {
51
+ lines.push(line);
52
+ line = word;
53
+ } else {
54
+ line = test;
55
+ }
56
+ }
57
+ lines.push(line);
58
+ }
59
+
60
+ if (textMeasureCache.size > TEXT_CACHE_LIMIT) textMeasureCache.clear();
61
+ textMeasureCache.set(cacheKey, lines);
62
+ return lines;
63
+ }
64
+
65
+ function roundRectPath(
66
+ ctx: CanvasRenderingContext2D,
67
+ x: number,
68
+ y: number,
69
+ w: number,
70
+ h: number,
71
+ r: number
72
+ ): void {
73
+ const radius = Math.min(r, w / 2, h / 2);
74
+ ctx.beginPath();
75
+ ctx.moveTo(x + radius, y);
76
+ ctx.arcTo(x + w, y, x + w, y + h, radius);
77
+ ctx.arcTo(x + w, y + h, x, y + h, radius);
78
+ ctx.arcTo(x, y + h, x, y, radius);
79
+ ctx.arcTo(x, y, x + w, y, radius);
80
+ ctx.closePath();
81
+ }
82
+
83
+ export function renderElement(el: BoardElement, rc: RenderContext): void {
84
+ const { ctx, lod } = rc;
85
+ ctx.save();
86
+ ctx.globalAlpha = el.style.opacity ?? 1;
87
+
88
+ if (lod === 0) {
89
+ renderAsBlock(el, rc);
90
+ ctx.restore();
91
+ return;
92
+ }
93
+
94
+ switch (el.type) {
95
+ case "sticky":
96
+ renderSticky(el, rc);
97
+ break;
98
+ case "task":
99
+ renderTask(el, rc);
100
+ break;
101
+ case "rectangle":
102
+ case "frame":
103
+ renderRectangle(el, rc);
104
+ break;
105
+ case "ellipse":
106
+ renderEllipse(el, rc);
107
+ break;
108
+ case "text":
109
+ renderText(el, rc);
110
+ break;
111
+ case "line":
112
+ case "arrow":
113
+ case "connector":
114
+ renderLineArrow(el, rc);
115
+ break;
116
+ case "image":
117
+ case "screenshot":
118
+ renderImage(el, rc);
119
+ break;
120
+ default:
121
+ renderRectangle(el, rc);
122
+ }
123
+ ctx.restore();
124
+ }
125
+
126
+ function renderAsBlock(el: BoardElement, rc: RenderContext): void {
127
+ const { ctx } = rc;
128
+ if (el.type === "line" || el.type === "arrow" || el.type === "connector") {
129
+ ctx.strokeStyle = el.style.stroke ?? (rc.theme === "dark" ? "#888" : "#999");
130
+ ctx.lineWidth = Math.max(1 / rc.scale, el.style.strokeWidth ?? 2);
131
+ ctx.beginPath();
132
+ ctx.moveTo(el.x, el.y);
133
+ ctx.lineTo(el.x + el.width, el.y + el.height);
134
+ ctx.stroke();
135
+ return;
136
+ }
137
+ ctx.fillStyle =
138
+ el.style.fill ?? (rc.theme === "dark" ? "#3a3d42" : "#d8d8d4");
139
+ ctx.fillRect(el.x, el.y, el.width, el.height);
140
+ }
141
+
142
+ function applyShadow(el: BoardElement, rc: RenderContext): void {
143
+ if (el.style.shadow && rc.lod === 2) {
144
+ rc.ctx.shadowColor =
145
+ rc.theme === "dark" ? "rgba(0,0,0,0.5)" : "rgba(0,0,0,0.14)";
146
+ rc.ctx.shadowBlur = 8 * rc.scale;
147
+ rc.ctx.shadowOffsetY = 2 * rc.scale;
148
+ }
149
+ }
150
+
151
+ function clearShadow(rc: RenderContext): void {
152
+ rc.ctx.shadowColor = "transparent";
153
+ rc.ctx.shadowBlur = 0;
154
+ rc.ctx.shadowOffsetY = 0;
155
+ }
156
+
157
+ function renderSticky(el: BoardElement, rc: RenderContext): void {
158
+ const { ctx, lod } = rc;
159
+ const fill = el.style.fill ?? "#fef08a";
160
+ applyShadow(el, rc);
161
+ roundRectPath(ctx, el.x, el.y, el.width, el.height, el.style.borderRadius ?? 12);
162
+ ctx.fillStyle = fill;
163
+ ctx.fill();
164
+ clearShadow(rc);
165
+
166
+ if (lod === 2) {
167
+ const text = (el.data.text as string) ?? "";
168
+ if (text) {
169
+ drawWrappedText(el, rc, text, 14);
170
+ }
171
+ }
172
+ }
173
+
174
+ function renderTask(el: BoardElement, rc: RenderContext): void {
175
+ const { ctx, lod } = rc;
176
+ const checked = el.data.checked === true;
177
+ const isDark = rc.theme === "dark";
178
+ const radius = el.style.borderRadius ?? 14;
179
+ applyShadow(el, rc);
180
+ roundRectPath(ctx, el.x, el.y, el.width, el.height, radius);
181
+ ctx.fillStyle = el.style.fill ?? (isDark ? "#17191b" : "#ffffff");
182
+ ctx.fill();
183
+ clearShadow(rc);
184
+
185
+ ctx.strokeStyle = el.style.stroke ?? (isDark ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.09)");
186
+ ctx.lineWidth = el.style.strokeWidth ?? 1;
187
+ ctx.stroke();
188
+
189
+ if (lod < 2) return;
190
+
191
+ const boxSize = Math.min(22, Math.max(16, el.height * 0.38));
192
+ const boxX = el.x + 16;
193
+ const boxY = el.y + el.height / 2 - boxSize / 2;
194
+ roundRectPath(ctx, boxX, boxY, boxSize, boxSize, 6);
195
+ ctx.fillStyle = checked ? "#0078d4" : isDark ? "#111314" : "#f3f2f1";
196
+ ctx.fill();
197
+ ctx.strokeStyle = checked ? "#0078d4" : isDark ? "rgba(255,255,255,0.18)" : "rgba(0,0,0,0.13)";
198
+ ctx.lineWidth = 1.4;
199
+ ctx.stroke();
200
+
201
+ if (checked) {
202
+ ctx.strokeStyle = "#ffffff";
203
+ ctx.lineWidth = 2.1;
204
+ ctx.lineCap = "round";
205
+ ctx.lineJoin = "round";
206
+ ctx.beginPath();
207
+ ctx.moveTo(boxX + boxSize * 0.26, boxY + boxSize * 0.52);
208
+ ctx.lineTo(boxX + boxSize * 0.43, boxY + boxSize * 0.68);
209
+ ctx.lineTo(boxX + boxSize * 0.75, boxY + boxSize * 0.32);
210
+ ctx.stroke();
211
+ }
212
+
213
+ const text = (el.data.text as string) ?? "";
214
+ if (!text) return;
215
+ const leftPad = 50;
216
+ const rightPad = 16;
217
+ const fontSize = el.style.fontSize ?? 15;
218
+ const fontFamily =
219
+ el.style.fontFamily ??
220
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
221
+ ctx.font = `${el.style.fontWeight ?? "650"} ${fontSize}px ${fontFamily}`;
222
+ ctx.fillStyle = checked ? (isDark ? "#a7aaa8" : "#777777") : el.style.textColor ?? (isDark ? "#f6f6f3" : "#252829");
223
+ ctx.textAlign = "left";
224
+ ctx.textBaseline = "middle";
225
+
226
+ ctx.save();
227
+ ctx.beginPath();
228
+ ctx.rect(el.x + leftPad, el.y, Math.max(10, el.width - leftPad - rightPad), el.height);
229
+ ctx.clip();
230
+ ctx.fillText(text, el.x + leftPad, el.y + el.height / 2);
231
+ if (checked) {
232
+ const metrics = ctx.measureText(text);
233
+ const lineY = el.y + el.height / 2 + 1;
234
+ ctx.strokeStyle = isDark ? "rgba(167,170,168,0.58)" : "rgba(119,119,119,0.48)";
235
+ ctx.lineWidth = 1.3;
236
+ ctx.beginPath();
237
+ ctx.moveTo(el.x + leftPad, lineY);
238
+ ctx.lineTo(el.x + leftPad + Math.min(metrics.width, el.width - leftPad - rightPad), lineY);
239
+ ctx.stroke();
240
+ }
241
+ ctx.restore();
242
+ }
243
+
244
+ function drawText(
245
+ ctx: CanvasRenderingContext2D,
246
+ value: string,
247
+ x: number,
248
+ y: number,
249
+ fontSize: number,
250
+ color: string,
251
+ fontWeight: string,
252
+ fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
253
+ ): void {
254
+ ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
255
+ ctx.fillStyle = color;
256
+ ctx.textAlign = "left";
257
+ ctx.textBaseline = "alphabetic";
258
+ ctx.fillText(value, x, y);
259
+ }
260
+
261
+ function renderRectangle(el: BoardElement, rc: RenderContext): void {
262
+ const { ctx, lod } = rc;
263
+ const shapeKind = (el.data.shapeKind as string | undefined) ?? "rectangle";
264
+ const r = el.style.borderRadius ?? 0;
265
+ applyShadow(el, rc);
266
+ if (shapeKind === "diamond") {
267
+ pathDiamond(ctx, el);
268
+ } else if (shapeKind === "triangle") {
269
+ pathTriangle(ctx, el);
270
+ } else if (shapeKind === "chevron") {
271
+ pathChevron(ctx, el);
272
+ } else if (shapeKind === "pill") {
273
+ roundRectPath(ctx, el.x, el.y, el.width, el.height, Math.min(Math.abs(el.height) / 2, Math.abs(el.width) / 2));
274
+ } else if (r > 0) {
275
+ roundRectPath(ctx, el.x, el.y, el.width, el.height, r);
276
+ } else {
277
+ ctx.beginPath();
278
+ ctx.rect(el.x, el.y, el.width, el.height);
279
+ }
280
+ if (el.style.fill && el.style.fill !== "transparent") {
281
+ ctx.fillStyle = el.style.fill;
282
+ ctx.fill();
283
+ }
284
+ clearShadow(rc);
285
+ if (el.style.stroke && (el.style.strokeWidth ?? 0) > 0) {
286
+ ctx.strokeStyle = el.style.stroke;
287
+ ctx.lineWidth = el.style.strokeWidth ?? 1;
288
+ ctx.stroke();
289
+ }
290
+ if (lod === 2) {
291
+ const text = (el.data.text as string) ?? "";
292
+ if (text) {
293
+ const textPadding =
294
+ shapeKind === "chevron" ? Math.min(Math.abs(el.width) * 0.18, Math.abs(el.height) * 0.55) + 8 : 10;
295
+ drawWrappedText(el, rc, text, textPadding);
296
+ }
297
+ }
298
+ }
299
+
300
+ function pathDiamond(ctx: CanvasRenderingContext2D, el: BoardElement): void {
301
+ const x = el.x;
302
+ const y = el.y;
303
+ const w = el.width;
304
+ const h = el.height;
305
+ ctx.beginPath();
306
+ ctx.moveTo(x + w / 2, y);
307
+ ctx.lineTo(x + w, y + h / 2);
308
+ ctx.lineTo(x + w / 2, y + h);
309
+ ctx.lineTo(x, y + h / 2);
310
+ ctx.closePath();
311
+ }
312
+
313
+ function pathTriangle(ctx: CanvasRenderingContext2D, el: BoardElement): void {
314
+ const x = el.x;
315
+ const y = el.y;
316
+ const w = el.width;
317
+ const h = el.height;
318
+ ctx.beginPath();
319
+ ctx.moveTo(x + w / 2, y);
320
+ ctx.lineTo(x + w, y + h);
321
+ ctx.lineTo(x, y + h);
322
+ ctx.closePath();
323
+ }
324
+
325
+ function pathChevron(ctx: CanvasRenderingContext2D, el: BoardElement): void {
326
+ const x = el.x;
327
+ const y = el.y;
328
+ const w = el.width;
329
+ const h = el.height;
330
+ const notch = Math.min(Math.abs(w) * 0.22, Math.abs(h) * 0.62);
331
+ ctx.beginPath();
332
+ ctx.moveTo(x, y);
333
+ ctx.lineTo(x + w - notch, y);
334
+ ctx.lineTo(x + w, y + h / 2);
335
+ ctx.lineTo(x + w - notch, y + h);
336
+ ctx.lineTo(x, y + h);
337
+ ctx.lineTo(x + notch, y + h / 2);
338
+ ctx.closePath();
339
+ }
340
+
341
+ function renderEllipse(el: BoardElement, rc: RenderContext): void {
342
+ const { ctx, lod } = rc;
343
+ applyShadow(el, rc);
344
+ ctx.beginPath();
345
+ ctx.ellipse(
346
+ el.x + el.width / 2,
347
+ el.y + el.height / 2,
348
+ Math.abs(el.width / 2),
349
+ Math.abs(el.height / 2),
350
+ 0,
351
+ 0,
352
+ Math.PI * 2
353
+ );
354
+ if (el.style.fill && el.style.fill !== "transparent") {
355
+ ctx.fillStyle = el.style.fill;
356
+ ctx.fill();
357
+ }
358
+ clearShadow(rc);
359
+ if (el.style.stroke && (el.style.strokeWidth ?? 0) > 0) {
360
+ ctx.strokeStyle = el.style.stroke;
361
+ ctx.lineWidth = el.style.strokeWidth ?? 1;
362
+ ctx.stroke();
363
+ }
364
+ if (lod === 2) {
365
+ const text = (el.data.text as string) ?? "";
366
+ if (text) drawWrappedText(el, rc, text, 10);
367
+ }
368
+ }
369
+
370
+ function renderText(el: BoardElement, rc: RenderContext): void {
371
+ if (rc.lod < 2) {
372
+ // tiny placeholder bar when far out
373
+ rc.ctx.fillStyle = rc.theme === "dark" ? "#55585e" : "#c5c5c0";
374
+ rc.ctx.fillRect(el.x, el.y, el.width, Math.min(el.height, 6 / rc.scale));
375
+ return;
376
+ }
377
+ const text = (el.data.text as string) ?? "";
378
+ drawWrappedText(el, rc, text, 0, "left", "top");
379
+ }
380
+
381
+ function drawWrappedText(
382
+ el: BoardElement,
383
+ rc: RenderContext,
384
+ text: string,
385
+ padding: number,
386
+ align: "center" | "left" = "center",
387
+ vAlign: "middle" | "top" = "middle"
388
+ ): void {
389
+ const { ctx } = rc;
390
+ const isChevron = el.data.shapeKind === "chevron";
391
+ const fontSize = getRenderedTextFontSize(el);
392
+ const fontFamily =
393
+ el.style.fontFamily ??
394
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
395
+ const fontWeight = isChevron ? "750" : el.style.fontWeight ?? "400";
396
+ ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
397
+ ctx.fillStyle =
398
+ el.style.textColor ?? (rc.theme === "dark" && el.type === "text" ? "#f2f2f2" : "#1f2933");
399
+
400
+ const maxWidth = Math.max(10, el.width - padding * 2);
401
+ const cacheKey = `${el.id}|${el.updatedAt}|${fontSize}|${maxWidth.toFixed(0)}`;
402
+ const lines = wrapText(ctx, cacheKey, text, maxWidth);
403
+ const lineHeight = fontSize * 1.35;
404
+ const totalHeight = lines.length * lineHeight;
405
+
406
+ let startY: number;
407
+ if (vAlign === "middle") {
408
+ startY = el.y + el.height / 2 - totalHeight / 2 + lineHeight / 2;
409
+ } else {
410
+ startY = el.y + padding + lineHeight / 2;
411
+ }
412
+
413
+ ctx.textBaseline = "middle";
414
+ ctx.textAlign = align;
415
+ const tx = align === "center" ? el.x + el.width / 2 : el.x + padding;
416
+
417
+ // clip to element bounds
418
+ ctx.save();
419
+ ctx.beginPath();
420
+ ctx.rect(el.x, el.y, el.width, el.height);
421
+ ctx.clip();
422
+ for (let i = 0; i < lines.length; i++) {
423
+ ctx.fillText(lines[i], tx, startY + i * lineHeight);
424
+ }
425
+ ctx.restore();
426
+ }
427
+
428
+ function renderLineArrow(el: BoardElement, rc: RenderContext): void {
429
+ const { ctx, lod } = rc;
430
+ const x1 = el.x;
431
+ const y1 = el.y;
432
+ const x2 = el.x + el.width;
433
+ const y2 = el.y + el.height;
434
+ const stroke = el.style.stroke ?? (rc.theme === "dark" ? "#d1d5db" : "#374151");
435
+ const strokeWidth = el.style.strokeWidth ?? 2;
436
+
437
+ ctx.strokeStyle = stroke;
438
+ ctx.fillStyle = stroke;
439
+ ctx.lineWidth = strokeWidth;
440
+ ctx.beginPath();
441
+ ctx.moveTo(x1, y1);
442
+ ctx.lineTo(x2, y2);
443
+ ctx.stroke();
444
+
445
+ if (lod >= 1) {
446
+ const angle = Math.atan2(y2 - y1, x2 - x1);
447
+ const headLen = Math.max(11, strokeWidth * 5.5);
448
+ if (el.style.arrowEnd ?? el.type === "arrow") {
449
+ drawArrowhead(ctx, x2, y2, angle, headLen);
450
+ }
451
+ if (el.style.arrowStart) {
452
+ drawArrowhead(ctx, x1, y1, angle + Math.PI, headLen);
453
+ }
454
+ }
455
+ }
456
+
457
+ function drawArrowhead(
458
+ ctx: CanvasRenderingContext2D,
459
+ x: number,
460
+ y: number,
461
+ angle: number,
462
+ len: number
463
+ ): void {
464
+ ctx.beginPath();
465
+ ctx.moveTo(x, y);
466
+ ctx.lineTo(
467
+ x - len * Math.cos(angle - Math.PI / 6),
468
+ y - len * Math.sin(angle - Math.PI / 6)
469
+ );
470
+ ctx.lineTo(
471
+ x - len * Math.cos(angle + Math.PI / 6),
472
+ y - len * Math.sin(angle + Math.PI / 6)
473
+ );
474
+ ctx.closePath();
475
+ ctx.fill();
476
+ }
477
+
478
+ function renderImage(el: BoardElement, rc: RenderContext): void {
479
+ const { ctx, imageCache } = rc;
480
+ const src = el.data.src as string | undefined;
481
+ const assetId = el.data.assetId as string | undefined;
482
+ // File-backed boards reference images by `src` path; legacy boards by assetId.
483
+ const img = src ? imageCache.getBySrc(src) : assetId ? imageCache.get(assetId) : null;
484
+
485
+ if (img) {
486
+ if (rc.lod === 1 && rc.scale < 0.15) {
487
+ ctx.fillStyle = rc.theme === "dark" ? "#3a3d42" : "#d8d8d4";
488
+ ctx.fillRect(el.x, el.y, el.width, el.height);
489
+ return;
490
+ }
491
+ ctx.drawImage(img, el.x, el.y, el.width, el.height);
492
+ } else {
493
+ // placeholder / broken state
494
+ ctx.fillStyle = rc.theme === "dark" ? "#2a2d31" : "#ececea";
495
+ ctx.fillRect(el.x, el.y, el.width, el.height);
496
+ ctx.strokeStyle = rc.theme === "dark" ? "#4a4d52" : "#c9c9c5";
497
+ ctx.lineWidth = 1 / rc.scale;
498
+ ctx.strokeRect(el.x, el.y, el.width, el.height);
499
+ if (rc.lod === 2) {
500
+ const isMissing = src ? imageCache.isMissingSrc(src) : assetId ? imageCache.isMissing(assetId) : false;
501
+ const status = isMissing ? "Missing image" : "Loading…";
502
+ ctx.font = `12px -apple-system, sans-serif`;
503
+ ctx.fillStyle = rc.theme === "dark" ? "#9ca3af" : "#6b7280";
504
+ ctx.textAlign = "center";
505
+ ctx.textBaseline = "middle";
506
+ ctx.fillText(status, el.x + el.width / 2, el.y + el.height / 2);
507
+ }
508
+ }
509
+ }
@@ -0,0 +1,118 @@
1
+ export type ElementType =
2
+ | "sticky"
3
+ | "task"
4
+ | "rectangle"
5
+ | "ellipse"
6
+ | "text"
7
+ | "line"
8
+ | "arrow"
9
+ | "connector"
10
+ | "image"
11
+ | "screenshot"
12
+ | "frame"
13
+ | "templateGroup";
14
+
15
+ export type ElementStyle = {
16
+ fill?: string;
17
+ stroke?: string;
18
+ strokeWidth?: number;
19
+ opacity?: number;
20
+ borderRadius?: number;
21
+ shadow?: boolean;
22
+ fontSize?: number;
23
+ fontFamily?: string;
24
+ fontWeight?: string;
25
+ textColor?: string;
26
+ arrowStart?: boolean;
27
+ arrowEnd?: boolean;
28
+ };
29
+
30
+ export type BoardElement = {
31
+ id: string;
32
+ type: ElementType;
33
+ x: number;
34
+ y: number;
35
+ width: number;
36
+ height: number;
37
+ rotation: number;
38
+ zIndex: number;
39
+ locked: boolean;
40
+ hidden: boolean;
41
+ createdAt: number;
42
+ updatedAt: number;
43
+ style: ElementStyle;
44
+ data: Record<string, unknown>;
45
+ };
46
+
47
+ export type ImageElementData = {
48
+ assetId: string;
49
+ originalWidth: number;
50
+ originalHeight: number;
51
+ alt?: string;
52
+ };
53
+
54
+ export type LocalAsset = {
55
+ id: string;
56
+ type: "image";
57
+ mimeType: string;
58
+ blob: Blob;
59
+ width: number;
60
+ height: number;
61
+ sizeBytes: number;
62
+ createdAt: number;
63
+ updatedAt: number;
64
+ name?: string;
65
+ };
66
+
67
+ export type BoardDocument = {
68
+ version: 1;
69
+ id: string;
70
+ title: string;
71
+ createdAt: number;
72
+ updatedAt: number;
73
+ viewport: {
74
+ offsetX: number;
75
+ offsetY: number;
76
+ scale: number;
77
+ };
78
+ theme: "light" | "dark";
79
+ elements: BoardElement[];
80
+ assetIds: string[];
81
+ };
82
+
83
+ let idCounter = 0;
84
+
85
+ export function generateId(): string {
86
+ idCounter = (idCounter + 1) % 0xffff;
87
+ return (
88
+ Date.now().toString(36) +
89
+ "-" +
90
+ idCounter.toString(36) +
91
+ "-" +
92
+ Math.random().toString(36).slice(2, 8)
93
+ );
94
+ }
95
+
96
+ export function createElement(
97
+ type: ElementType,
98
+ partial: Partial<BoardElement> = {}
99
+ ): BoardElement {
100
+ const now = Date.now();
101
+ return {
102
+ id: generateId(),
103
+ type,
104
+ x: 0,
105
+ y: 0,
106
+ width: 100,
107
+ height: 100,
108
+ rotation: 0,
109
+ zIndex: 0,
110
+ locked: false,
111
+ hidden: false,
112
+ createdAt: now,
113
+ updatedAt: now,
114
+ style: {},
115
+ data: {},
116
+ ...partial,
117
+ };
118
+ }