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.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/bin/viteboard.mjs +59 -0
- package/content/docs/assets/image-mqjpqlyw.png +0 -0
- package/content/docs/assets/image-mqjsmenr.png +0 -0
- package/content/docs/boards/product-roadmap.board.json +2130 -0
- package/content/docs/capabilities.md +89 -0
- package/content/docs/docs.md +84 -0
- package/content/docs/guide/boards-in-markdown.md +48 -0
- package/content/docs/guide/boards.md +92 -0
- package/content/docs/guide/getting-started.md +71 -0
- package/content/docs/guide/test.md +6 -0
- package/content/docs/index.md +47 -0
- package/content/docs/product-roadmap.board.json +2130 -0
- package/content/docs/test.md +219 -0
- package/content/docs/tetst-dir/test-board.board.json +15 -0
- package/content/docs/tetst-dir/test-test.md +1 -0
- package/content/docs/workspace.md +73 -0
- package/dist/assets/index-CEpxLM2o.css +1 -0
- package/dist/assets/index-D4xvJdEQ.js +60 -0
- package/dist/index.html +13 -0
- package/index.html +13 -0
- package/package.json +52 -0
- package/src/app/AppController.ts +955 -0
- package/src/app/BoardState.ts +130 -0
- package/src/app/ClipboardService.ts +159 -0
- package/src/app/CommandManager.ts +66 -0
- package/src/app/ElementActions.ts +152 -0
- package/src/app/EmbedRegionSelector.ts +103 -0
- package/src/app/ExportPng.ts +107 -0
- package/src/app/ImageInsertService.ts +141 -0
- package/src/app/KeyboardShortcuts.ts +124 -0
- package/src/app/main.ts +6 -0
- package/src/board/BoardPreview.ts +189 -0
- package/src/board/BoardService.ts +222 -0
- package/src/canvas/CanvasRenderer.ts +253 -0
- package/src/canvas/CanvasSurface.ts +70 -0
- package/src/canvas/HitTester.ts +110 -0
- package/src/canvas/ImageCache.ts +123 -0
- package/src/canvas/PerformanceMonitor.ts +31 -0
- package/src/canvas/RenderScheduler.ts +26 -0
- package/src/canvas/Viewport.ts +77 -0
- package/src/content/ContentApi.ts +258 -0
- package/src/content/Markdown.ts +431 -0
- package/src/content/MarkdownView.ts +70 -0
- package/src/editor/DocEditor.ts +799 -0
- package/src/editor/htmlToMarkdown.ts +333 -0
- package/src/elements/renderElement.ts +509 -0
- package/src/elements/types.ts +118 -0
- package/src/shell/Shell.ts +2950 -0
- package/src/shell/Sidebar.ts +1352 -0
- package/src/storage/AssetStore.ts +86 -0
- package/src/storage/BoardSerializer.ts +114 -0
- package/src/storage/ImportExportService.ts +153 -0
- package/src/storage/IndexedDbStore.ts +92 -0
- package/src/storage/LocalBoardStore.ts +104 -0
- package/src/styles.css +3257 -0
- package/src/templates/helpers.ts +124 -0
- package/src/templates/index.ts +65 -0
- package/src/templates/journeyMapTemplate.ts +52 -0
- package/src/templates/opportunityTreeTemplate.ts +45 -0
- package/src/templates/personaTemplate.ts +41 -0
- package/src/templates/prioritizationMatrixTemplate.ts +44 -0
- package/src/templates/requirementsFlowTemplate.ts +45 -0
- package/src/templates/screenshotReviewTemplate.ts +52 -0
- package/src/templates/systemDiagramTemplate.ts +41 -0
- package/src/testing/StressTestGenerator.ts +134 -0
- package/src/testing/StressTestPanel.ts +64 -0
- package/src/tools/ArrowTool.ts +87 -0
- package/src/tools/ImageTool.ts +39 -0
- package/src/tools/PanTool.ts +34 -0
- package/src/tools/SelectTool.ts +377 -0
- package/src/tools/ShapeTool.ts +106 -0
- package/src/tools/StickyTool.ts +69 -0
- package/src/tools/TaskTool.ts +52 -0
- package/src/tools/TextTool.ts +45 -0
- package/src/tools/ToolContext.ts +44 -0
- package/src/tools/ToolController.ts +178 -0
- package/src/ui/Modal.ts +58 -0
- package/src/ui/PerformanceOverlay.ts +75 -0
- package/src/ui/StorageManager.ts +94 -0
- package/src/ui/TemplatePicker.ts +67 -0
- package/src/ui/TextEditorOverlay.ts +137 -0
- package/src/ui/Toolbar.ts +151 -0
- package/src/ui/TopControls.ts +137 -0
- package/src/ui/icons.ts +50 -0
- package/tsconfig.json +19 -0
- package/vite.config.ts +694 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { BoardState } from "./BoardState";
|
|
2
|
+
import type { ImageCache } from "../canvas/ImageCache";
|
|
3
|
+
import { renderElement } from "../elements/renderElement";
|
|
4
|
+
|
|
5
|
+
function contentBounds(state: BoardState): { x: number; y: number; w: number; h: number } | null {
|
|
6
|
+
if (state.elements.length === 0) return null;
|
|
7
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
8
|
+
for (const el of state.elements) {
|
|
9
|
+
if (el.hidden) continue;
|
|
10
|
+
minX = Math.min(minX, Math.min(el.x, el.x + el.width));
|
|
11
|
+
minY = Math.min(minY, Math.min(el.y, el.y + el.height));
|
|
12
|
+
maxX = Math.max(maxX, Math.max(el.x, el.x + el.width));
|
|
13
|
+
maxY = Math.max(maxY, Math.max(el.y, el.y + el.height));
|
|
14
|
+
}
|
|
15
|
+
if (!Number.isFinite(minX)) return null;
|
|
16
|
+
const pad = 48;
|
|
17
|
+
return { x: minX - pad, y: minY - pad, w: maxX - minX + pad * 2, h: maxY - minY + pad * 2 };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderRegionToCanvas(
|
|
21
|
+
state: BoardState,
|
|
22
|
+
imageCache: ImageCache,
|
|
23
|
+
region: { x: number; y: number; w: number; h: number },
|
|
24
|
+
scale: number
|
|
25
|
+
): HTMLCanvasElement {
|
|
26
|
+
const MAX_DIM = 8192;
|
|
27
|
+
let outW = Math.round(region.w * scale);
|
|
28
|
+
let outH = Math.round(region.h * scale);
|
|
29
|
+
if (outW > MAX_DIM || outH > MAX_DIM) {
|
|
30
|
+
const f = MAX_DIM / Math.max(outW, outH);
|
|
31
|
+
scale *= f;
|
|
32
|
+
outW = Math.round(region.w * scale);
|
|
33
|
+
outH = Math.round(region.h * scale);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const canvas = document.createElement("canvas");
|
|
37
|
+
canvas.width = Math.max(1, outW);
|
|
38
|
+
canvas.height = Math.max(1, outH);
|
|
39
|
+
const ctx = canvas.getContext("2d")!;
|
|
40
|
+
|
|
41
|
+
const bg = state.theme === "dark" ? "#111315" : "#f3f2f1";
|
|
42
|
+
ctx.fillStyle = bg;
|
|
43
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
44
|
+
|
|
45
|
+
ctx.scale(scale, scale);
|
|
46
|
+
ctx.translate(-region.x, -region.y);
|
|
47
|
+
|
|
48
|
+
const sorted = state.getSorted();
|
|
49
|
+
for (const el of sorted) {
|
|
50
|
+
if (el.hidden) continue;
|
|
51
|
+
// simple culling against region
|
|
52
|
+
if (
|
|
53
|
+
Math.max(el.x, el.x + el.width) < region.x ||
|
|
54
|
+
Math.min(el.x, el.x + el.width) > region.x + region.w ||
|
|
55
|
+
Math.max(el.y, el.y + el.height) < region.y ||
|
|
56
|
+
Math.min(el.y, el.y + el.height) > region.y + region.h
|
|
57
|
+
)
|
|
58
|
+
continue;
|
|
59
|
+
renderElement(el, { ctx, scale, lod: 2, theme: state.theme, imageCache });
|
|
60
|
+
}
|
|
61
|
+
return canvas;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
canvas.toBlob((blob) => {
|
|
67
|
+
if (blob) resolve(blob);
|
|
68
|
+
else reject(new Error("PNG encode failed"));
|
|
69
|
+
}, "image/png");
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function exportViewportPng(
|
|
74
|
+
state: BoardState,
|
|
75
|
+
imageCache: ImageCache,
|
|
76
|
+
screenWidth: number,
|
|
77
|
+
screenHeight: number
|
|
78
|
+
): Promise<Blob> {
|
|
79
|
+
const vp = state.viewport;
|
|
80
|
+
const region = {
|
|
81
|
+
x: -vp.offsetX / vp.scale,
|
|
82
|
+
y: -vp.offsetY / vp.scale,
|
|
83
|
+
w: screenWidth / vp.scale,
|
|
84
|
+
h: screenHeight / vp.scale,
|
|
85
|
+
};
|
|
86
|
+
const canvas = renderRegionToCanvas(state, imageCache, region, vp.scale);
|
|
87
|
+
return canvasToBlob(canvas);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function exportFullBoardPng(
|
|
91
|
+
state: BoardState,
|
|
92
|
+
imageCache: ImageCache
|
|
93
|
+
): Promise<Blob | null> {
|
|
94
|
+
const region = contentBounds(state);
|
|
95
|
+
if (!region) return null;
|
|
96
|
+
const canvas = renderRegionToCanvas(state, imageCache, region, 1);
|
|
97
|
+
return canvasToBlob(canvas);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function downloadBlob(blob: Blob, filename: string): void {
|
|
101
|
+
const url = URL.createObjectURL(blob);
|
|
102
|
+
const a = document.createElement("a");
|
|
103
|
+
a.href = url;
|
|
104
|
+
a.download = filename;
|
|
105
|
+
a.click();
|
|
106
|
+
URL.revokeObjectURL(url);
|
|
107
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { BoardState } from "../app/BoardState";
|
|
2
|
+
import type { CommandManager } from "../app/CommandManager";
|
|
3
|
+
import type { ImageCache } from "../canvas/ImageCache";
|
|
4
|
+
import { assetStore } from "../storage/AssetStore";
|
|
5
|
+
import { createElement } from "../elements/types";
|
|
6
|
+
import { screenToWorld } from "../canvas/Viewport";
|
|
7
|
+
|
|
8
|
+
const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
|
9
|
+
const LARGE_ASSET_BYTES = 5 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Shared image insertion pipeline used by paste, drag-and-drop and the
|
|
13
|
+
* toolbar image tool. In workspace mode it writes images into the active docs
|
|
14
|
+
* root; the IndexedDB path remains as a legacy fallback.
|
|
15
|
+
*/
|
|
16
|
+
export class ImageInsertService {
|
|
17
|
+
private state: BoardState;
|
|
18
|
+
private commands: CommandManager;
|
|
19
|
+
private imageCache: ImageCache;
|
|
20
|
+
private notify: (msg: string) => void;
|
|
21
|
+
private uploadImage?: (blob: Blob, name?: string) => Promise<string>;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
state: BoardState,
|
|
25
|
+
commands: CommandManager,
|
|
26
|
+
imageCache: ImageCache,
|
|
27
|
+
notify: (msg: string) => void,
|
|
28
|
+
uploadImage?: (blob: Blob, name?: string) => Promise<string>
|
|
29
|
+
) {
|
|
30
|
+
this.state = state;
|
|
31
|
+
this.commands = commands;
|
|
32
|
+
this.imageCache = imageCache;
|
|
33
|
+
this.notify = notify;
|
|
34
|
+
this.uploadImage = uploadImage;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async insertBlob(
|
|
38
|
+
blob: Blob,
|
|
39
|
+
screenX: number,
|
|
40
|
+
screenY: number,
|
|
41
|
+
name?: string
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
if (!ACCEPTED_TYPES.includes(blob.type)) {
|
|
44
|
+
this.notify("Unsupported image type");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (blob.size > LARGE_ASSET_BYTES) {
|
|
48
|
+
this.notify("Large image added — consider optimizing it for git");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Decode once for natural dimensions + cache pre-warm.
|
|
52
|
+
let naturalW = 0;
|
|
53
|
+
let naturalH = 0;
|
|
54
|
+
let bitmap: ImageBitmap | null = null;
|
|
55
|
+
try {
|
|
56
|
+
bitmap = await createImageBitmap(blob);
|
|
57
|
+
naturalW = bitmap.width;
|
|
58
|
+
naturalH = bitmap.height;
|
|
59
|
+
} catch {
|
|
60
|
+
this.notify("Could not decode image");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// File-backed (git) mode: write the image to disk and reference it by path.
|
|
65
|
+
// Legacy mode: store the blob in IndexedDB and reference it by assetId.
|
|
66
|
+
const imageData: Record<string, unknown> = {
|
|
67
|
+
originalWidth: naturalW,
|
|
68
|
+
originalHeight: naturalH,
|
|
69
|
+
alt: name,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (this.uploadImage) {
|
|
73
|
+
try {
|
|
74
|
+
const src = await this.uploadImage(blob, name);
|
|
75
|
+
imageData.src = src;
|
|
76
|
+
if (bitmap) this.imageCache.setBySrc(src, bitmap);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
this.notify("Failed to save image to disk");
|
|
79
|
+
console.error("Image upload failed", err);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
try {
|
|
84
|
+
const asset = await assetStore.createFromBlob(blob, name);
|
|
85
|
+
imageData.assetId = asset.id;
|
|
86
|
+
if (bitmap) this.imageCache.set(asset.id, bitmap);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
this.notify("Failed to store image (storage quota?)");
|
|
89
|
+
console.error("Asset store failed", err);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const world = screenToWorld(screenX, screenY, this.state.viewport);
|
|
95
|
+
// scale down very large images to a sane on-board size
|
|
96
|
+
const maxDim = 480 / Math.min(1, this.state.viewport.scale);
|
|
97
|
+
let w = naturalW;
|
|
98
|
+
let h = naturalH;
|
|
99
|
+
const largest = Math.max(w, h);
|
|
100
|
+
if (largest > maxDim) {
|
|
101
|
+
const f = maxDim / largest;
|
|
102
|
+
w *= f;
|
|
103
|
+
h *= f;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const el = createElement("image", {
|
|
107
|
+
x: world.x - w / 2,
|
|
108
|
+
y: world.y - h / 2,
|
|
109
|
+
width: w,
|
|
110
|
+
height: h,
|
|
111
|
+
zIndex: this.state.maxZIndex() + 1,
|
|
112
|
+
data: imageData,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const state = this.state;
|
|
116
|
+
this.commands.execute(
|
|
117
|
+
"insert-image",
|
|
118
|
+
() => {
|
|
119
|
+
if (!state.elementsById.has(el.id)) state.addElement(el);
|
|
120
|
+
state.emitChange();
|
|
121
|
+
},
|
|
122
|
+
() => {
|
|
123
|
+
state.removeElement(el.id);
|
|
124
|
+
state.emitChange();
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
state.clearSelection();
|
|
129
|
+
state.selectedIds.add(el.id);
|
|
130
|
+
state.requestRender();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async insertFiles(files: FileList | File[], screenX: number, screenY: number): Promise<void> {
|
|
134
|
+
let offset = 0;
|
|
135
|
+
for (const file of Array.from(files)) {
|
|
136
|
+
if (!file.type.startsWith("image/")) continue;
|
|
137
|
+
await this.insertBlob(file, screenX + offset, screenY + offset, file.name);
|
|
138
|
+
offset += 24;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { BoardState } from "./BoardState";
|
|
2
|
+
import type { CommandManager } from "./CommandManager";
|
|
3
|
+
import type { ElementActions } from "./ElementActions";
|
|
4
|
+
import type { ToolController } from "../tools/ToolController";
|
|
5
|
+
import { zoomAt } from "../canvas/Viewport";
|
|
6
|
+
|
|
7
|
+
export type ShortcutDeps = {
|
|
8
|
+
state: BoardState;
|
|
9
|
+
commands: CommandManager;
|
|
10
|
+
actions: ElementActions;
|
|
11
|
+
toolController: ToolController;
|
|
12
|
+
resetView: () => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function bindKeyboardShortcuts(deps: ShortcutDeps, signal?: AbortSignal): void {
|
|
16
|
+
const { state, commands, actions, toolController } = deps;
|
|
17
|
+
|
|
18
|
+
window.addEventListener("keydown", (e) => {
|
|
19
|
+
const target = e.target as HTMLElement | null;
|
|
20
|
+
if (
|
|
21
|
+
target &&
|
|
22
|
+
(target.tagName === "TEXTAREA" ||
|
|
23
|
+
target.tagName === "INPUT" ||
|
|
24
|
+
target.isContentEditable)
|
|
25
|
+
) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
30
|
+
const nudge = e.shiftKey ? 10 : 1;
|
|
31
|
+
|
|
32
|
+
if (!mod) {
|
|
33
|
+
switch (e.key) {
|
|
34
|
+
case "ArrowLeft":
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
actions.nudgeSelection(-nudge, 0);
|
|
37
|
+
return;
|
|
38
|
+
case "ArrowRight":
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
actions.nudgeSelection(nudge, 0);
|
|
41
|
+
return;
|
|
42
|
+
case "ArrowUp":
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
actions.nudgeSelection(0, -nudge);
|
|
45
|
+
return;
|
|
46
|
+
case "ArrowDown":
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
actions.nudgeSelection(0, nudge);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (mod) {
|
|
54
|
+
switch (e.key.toLowerCase()) {
|
|
55
|
+
case "d":
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
actions.duplicateSelection();
|
|
58
|
+
return;
|
|
59
|
+
case "z":
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
if (e.shiftKey) commands.redo();
|
|
62
|
+
else commands.undo();
|
|
63
|
+
return;
|
|
64
|
+
case "]":
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
if (e.shiftKey) actions.bringToFront();
|
|
67
|
+
else actions.bringForward();
|
|
68
|
+
return;
|
|
69
|
+
case "[":
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
if (e.shiftKey) actions.sendToBack();
|
|
72
|
+
else actions.sendBackward();
|
|
73
|
+
return;
|
|
74
|
+
case "=":
|
|
75
|
+
case "+":
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
zoomAt(state.viewport, window.innerWidth / 2, window.innerHeight / 2, state.viewport.scale * 1.2);
|
|
78
|
+
state.requestRender();
|
|
79
|
+
return;
|
|
80
|
+
case "-":
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
zoomAt(state.viewport, window.innerWidth / 2, window.innerHeight / 2, state.viewport.scale / 1.2);
|
|
83
|
+
state.requestRender();
|
|
84
|
+
return;
|
|
85
|
+
case "0":
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
deps.resetView();
|
|
88
|
+
return;
|
|
89
|
+
case "a":
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
state.clearSelection();
|
|
92
|
+
for (const el of state.elements) {
|
|
93
|
+
if (!el.locked && !el.hidden) state.selectedIds.add(el.id);
|
|
94
|
+
}
|
|
95
|
+
state.requestRender();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
switch (e.key) {
|
|
102
|
+
case "Delete":
|
|
103
|
+
case "Backspace":
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
actions.deleteSelection();
|
|
106
|
+
return;
|
|
107
|
+
case "Escape":
|
|
108
|
+
toolController.cancel();
|
|
109
|
+
toolController.setTool("select");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
switch (e.key.toLowerCase()) {
|
|
114
|
+
case "v": toolController.setTool("select"); return;
|
|
115
|
+
case "h": toolController.setTool("hand"); return;
|
|
116
|
+
case "s": toolController.setTool("sticky"); return;
|
|
117
|
+
case "r": toolController.setTool("rectangle"); return;
|
|
118
|
+
case "o": toolController.setTool("ellipse"); return;
|
|
119
|
+
case "t": toolController.setTool("text"); return;
|
|
120
|
+
case "a": toolController.setTool("arrow"); return;
|
|
121
|
+
case "i": toolController.setTool("image"); return;
|
|
122
|
+
}
|
|
123
|
+
}, { signal });
|
|
124
|
+
}
|
package/src/app/main.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { ContentApi } from "../content/ContentApi";
|
|
2
|
+
import { ImageCache } from "../canvas/ImageCache";
|
|
3
|
+
import { assetStore } from "../storage/AssetStore";
|
|
4
|
+
import { renderElement, type RenderContext } from "../elements/renderElement";
|
|
5
|
+
import { validateBoardDocument } from "../storage/BoardSerializer";
|
|
6
|
+
import type { BoardDocument, BoardElement } from "../elements/types";
|
|
7
|
+
|
|
8
|
+
export type BoardPreviewOptions = {
|
|
9
|
+
rootId: string;
|
|
10
|
+
src: string;
|
|
11
|
+
srcCandidates?: string[];
|
|
12
|
+
region: [number, number, number, number] | null;
|
|
13
|
+
height: number;
|
|
14
|
+
title: string | null;
|
|
15
|
+
theme: "light" | "dark";
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A read-only, static render of a board cropped to a world-space region.
|
|
20
|
+
* Used to embed boards inside markdown docs. No tools, no event loop — it
|
|
21
|
+
* paints once (and again when lazy images finish decoding or on resize),
|
|
22
|
+
* which keeps docs VitePress-fast even with several embeds on a page.
|
|
23
|
+
*/
|
|
24
|
+
export class BoardPreview {
|
|
25
|
+
readonly element: HTMLElement;
|
|
26
|
+
private canvas: HTMLCanvasElement;
|
|
27
|
+
private ctx: CanvasRenderingContext2D;
|
|
28
|
+
private imageCache: ImageCache;
|
|
29
|
+
private doc: BoardDocument | null = null;
|
|
30
|
+
private region: [number, number, number, number] = [0, 0, 100, 100];
|
|
31
|
+
private opts: BoardPreviewOptions;
|
|
32
|
+
private openLink: HTMLAnchorElement;
|
|
33
|
+
private ro: ResizeObserver;
|
|
34
|
+
|
|
35
|
+
constructor(figure: HTMLElement, opts: BoardPreviewOptions) {
|
|
36
|
+
this.opts = opts;
|
|
37
|
+
this.element = figure;
|
|
38
|
+
figure.classList.add("board-embed");
|
|
39
|
+
figure.textContent = "";
|
|
40
|
+
|
|
41
|
+
const frame = document.createElement("div");
|
|
42
|
+
frame.className = "board-embed-frame";
|
|
43
|
+
frame.style.height = opts.height + "px";
|
|
44
|
+
|
|
45
|
+
this.canvas = document.createElement("canvas");
|
|
46
|
+
this.ctx = this.canvas.getContext("2d") as CanvasRenderingContext2D;
|
|
47
|
+
frame.appendChild(this.canvas);
|
|
48
|
+
figure.appendChild(frame);
|
|
49
|
+
|
|
50
|
+
const open = document.createElement("a");
|
|
51
|
+
open.className = "board-embed-open";
|
|
52
|
+
const regionParam = opts.region ? `?region=${encodeURIComponent(opts.region.join(","))}` : "";
|
|
53
|
+
open.href = `#/root/${encodeURIComponent(opts.rootId)}/boards/${encodeURIComponent(opts.src)}${regionParam}`;
|
|
54
|
+
open.textContent = "Open board →";
|
|
55
|
+
this.openLink = open;
|
|
56
|
+
frame.appendChild(open);
|
|
57
|
+
|
|
58
|
+
if (opts.title) {
|
|
59
|
+
const cap = document.createElement("figcaption");
|
|
60
|
+
cap.textContent = opts.title;
|
|
61
|
+
figure.appendChild(cap);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.imageCache = new ImageCache(assetStore, () => this.render(), (src) =>
|
|
65
|
+
ContentApi.assetUrl(this.opts.rootId, src)
|
|
66
|
+
);
|
|
67
|
+
this.ro = new ResizeObserver(() => this.render());
|
|
68
|
+
this.ro.observe(frame);
|
|
69
|
+
|
|
70
|
+
void this.load();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private async load(): Promise<void> {
|
|
74
|
+
try {
|
|
75
|
+
const candidates = this.opts.srcCandidates?.length ? this.opts.srcCandidates : [this.opts.src];
|
|
76
|
+
let doc: BoardDocument | null = null;
|
|
77
|
+
let resolvedSrc = this.opts.src;
|
|
78
|
+
for (const src of candidates) {
|
|
79
|
+
const raw = await ContentApi.getBoard(this.opts.rootId, src);
|
|
80
|
+
doc = raw ? validateBoardDocument(raw) : null;
|
|
81
|
+
if (doc) {
|
|
82
|
+
resolvedSrc = src;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!doc) return this.renderError(`Board "${this.opts.src}" not found`);
|
|
87
|
+
this.opts.src = resolvedSrc;
|
|
88
|
+
const regionParam = this.opts.region ? `?region=${encodeURIComponent(this.opts.region.join(","))}` : "";
|
|
89
|
+
this.openLink.href =
|
|
90
|
+
`#/root/${encodeURIComponent(this.opts.rootId)}/boards/${encodeURIComponent(resolvedSrc)}${regionParam}`;
|
|
91
|
+
this.doc = doc;
|
|
92
|
+
this.region = this.opts.region ?? boundsOf(doc.elements);
|
|
93
|
+
this.render();
|
|
94
|
+
} catch {
|
|
95
|
+
this.renderError("Failed to load board");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private renderError(msg: string): void {
|
|
100
|
+
this.element.querySelector(".board-embed-frame")?.classList.add("error");
|
|
101
|
+
const frame = this.element.querySelector(".board-embed-frame");
|
|
102
|
+
if (frame) {
|
|
103
|
+
frame.innerHTML = "";
|
|
104
|
+
const span = document.createElement("span");
|
|
105
|
+
span.className = "board-embed-msg";
|
|
106
|
+
span.textContent = msg;
|
|
107
|
+
frame.appendChild(span);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
render(): void {
|
|
112
|
+
if (!this.doc) return;
|
|
113
|
+
const frame = this.canvas.parentElement as HTMLElement;
|
|
114
|
+
const cssW = Math.max(1, frame.clientWidth);
|
|
115
|
+
const cssH = Math.max(1, frame.clientHeight);
|
|
116
|
+
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
|
117
|
+
|
|
118
|
+
this.canvas.width = Math.round(cssW * dpr);
|
|
119
|
+
this.canvas.height = Math.round(cssH * dpr);
|
|
120
|
+
this.canvas.style.width = cssW + "px";
|
|
121
|
+
this.canvas.style.height = cssH + "px";
|
|
122
|
+
|
|
123
|
+
const ctx = this.ctx;
|
|
124
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
125
|
+
ctx.clearRect(0, 0, cssW, cssH);
|
|
126
|
+
|
|
127
|
+
// background matching the canvas theme
|
|
128
|
+
ctx.fillStyle = this.opts.theme === "dark" ? "#111315" : "#f3f2f1";
|
|
129
|
+
ctx.fillRect(0, 0, cssW, cssH);
|
|
130
|
+
|
|
131
|
+
const [rx, ry, rw, rh] = this.region;
|
|
132
|
+
if (rw <= 0 || rh <= 0) return;
|
|
133
|
+
|
|
134
|
+
// "contain" fit: the region rectangle fits centered within the canvas
|
|
135
|
+
const scale = Math.min(cssW / rw, cssH / rh);
|
|
136
|
+
const offsetX = (cssW - rw * scale) / 2 - rx * scale;
|
|
137
|
+
const offsetY = (cssH - rh * scale) / 2 - ry * scale;
|
|
138
|
+
|
|
139
|
+
ctx.save();
|
|
140
|
+
ctx.translate(offsetX, offsetY);
|
|
141
|
+
ctx.scale(scale, scale);
|
|
142
|
+
|
|
143
|
+
const rc: RenderContext = {
|
|
144
|
+
ctx,
|
|
145
|
+
scale,
|
|
146
|
+
lod: 2,
|
|
147
|
+
theme: this.opts.theme,
|
|
148
|
+
imageCache: this.imageCache,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// cull to the visible region (with margin) for speed
|
|
152
|
+
const margin = Math.max(rw, rh) * 0.1;
|
|
153
|
+
for (const el of [...this.doc.elements].sort((a, b) => a.zIndex - b.zIndex)) {
|
|
154
|
+
if (el.hidden) continue;
|
|
155
|
+
if (
|
|
156
|
+
el.x + el.width < rx - margin ||
|
|
157
|
+
el.x > rx + rw + margin ||
|
|
158
|
+
el.y + el.height < ry - margin ||
|
|
159
|
+
el.y > ry + rh + margin
|
|
160
|
+
) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
renderElement(el, rc);
|
|
164
|
+
}
|
|
165
|
+
ctx.restore();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
destroy(): void {
|
|
169
|
+
this.ro.disconnect();
|
|
170
|
+
this.imageCache.clear();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Bounding box of all elements with a little padding. */
|
|
175
|
+
function boundsOf(elements: BoardElement[]): [number, number, number, number] {
|
|
176
|
+
if (elements.length === 0) return [0, 0, 800, 600];
|
|
177
|
+
let minX = Infinity,
|
|
178
|
+
minY = Infinity,
|
|
179
|
+
maxX = -Infinity,
|
|
180
|
+
maxY = -Infinity;
|
|
181
|
+
for (const el of elements) {
|
|
182
|
+
minX = Math.min(minX, el.x, el.x + el.width);
|
|
183
|
+
minY = Math.min(minY, el.y, el.y + el.height);
|
|
184
|
+
maxX = Math.max(maxX, el.x, el.x + el.width);
|
|
185
|
+
maxY = Math.max(maxY, el.y, el.y + el.height);
|
|
186
|
+
}
|
|
187
|
+
const pad = 40;
|
|
188
|
+
return [minX - pad, minY - pad, maxX - minX + pad * 2, maxY - minY + pad * 2];
|
|
189
|
+
}
|