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,123 @@
|
|
|
1
|
+
import type { AssetStore } from "../storage/AssetStore";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory cache of decoded ImageBitmaps keyed by asset ID.
|
|
5
|
+
* Decoding happens once, asynchronously; a re-render is requested when done.
|
|
6
|
+
*/
|
|
7
|
+
export class ImageCache {
|
|
8
|
+
private cache = new Map<string, ImageBitmap>();
|
|
9
|
+
private loading = new Set<string>();
|
|
10
|
+
private missing = new Set<string>();
|
|
11
|
+
private assetStore: AssetStore;
|
|
12
|
+
private onLoaded: () => void;
|
|
13
|
+
private resolveSrcUrl?: (src: string) => string;
|
|
14
|
+
|
|
15
|
+
constructor(assetStore: AssetStore, onLoaded: () => void, resolveSrcUrl?: (src: string) => string) {
|
|
16
|
+
this.assetStore = assetStore;
|
|
17
|
+
this.onLoaded = onLoaded;
|
|
18
|
+
this.resolveSrcUrl = resolveSrcUrl;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get(assetId: string): ImageBitmap | null {
|
|
22
|
+
const bmp = this.cache.get(assetId);
|
|
23
|
+
if (bmp) return bmp;
|
|
24
|
+
if (!this.loading.has(assetId) && !this.missing.has(assetId)) {
|
|
25
|
+
void this.load(assetId);
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
isMissing(assetId: string): boolean {
|
|
31
|
+
return this.missing.has(assetId);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Resolve a board image `data.src` (relative content path or URL) to a URL. */
|
|
35
|
+
private srcToUrl(src: string): string {
|
|
36
|
+
if (this.resolveSrcUrl) return this.resolveSrcUrl(src);
|
|
37
|
+
if (/^(https?:)?\/\//.test(src) || src.startsWith("/") || src.startsWith("data:")) {
|
|
38
|
+
return src;
|
|
39
|
+
}
|
|
40
|
+
return `/__api/asset?path=${encodeURIComponent(src)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
keyForSrc(src: string): string {
|
|
44
|
+
return this.srcToUrl(src);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Load a file-backed image by its `data.src` path (used by git-based boards). */
|
|
48
|
+
getBySrc(src: string): ImageBitmap | null {
|
|
49
|
+
const url = this.srcToUrl(src);
|
|
50
|
+
const bmp = this.cache.get(url);
|
|
51
|
+
if (bmp) return bmp;
|
|
52
|
+
if (!this.loading.has(url) && !this.missing.has(url)) void this.loadUrl(url);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
isMissingSrc(src: string): boolean {
|
|
57
|
+
return this.missing.has(this.srcToUrl(src));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private async loadUrl(url: string): Promise<void> {
|
|
61
|
+
this.loading.add(url);
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(url);
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
this.missing.add(url);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const blob = await res.blob();
|
|
69
|
+
const bitmap = await createImageBitmap(blob);
|
|
70
|
+
this.cache.set(url, bitmap);
|
|
71
|
+
this.onLoaded();
|
|
72
|
+
} catch {
|
|
73
|
+
this.missing.add(url);
|
|
74
|
+
} finally {
|
|
75
|
+
this.loading.delete(url);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Put a freshly created bitmap directly into the cache. */
|
|
80
|
+
set(assetId: string, bitmap: ImageBitmap): void {
|
|
81
|
+
this.cache.set(assetId, bitmap);
|
|
82
|
+
this.missing.delete(assetId);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
setBySrc(src: string, bitmap: ImageBitmap): void {
|
|
86
|
+
this.set(this.srcToUrl(src), bitmap);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async load(assetId: string): Promise<void> {
|
|
90
|
+
this.loading.add(assetId);
|
|
91
|
+
try {
|
|
92
|
+
const asset = await this.assetStore.get(assetId);
|
|
93
|
+
if (!asset) {
|
|
94
|
+
this.missing.add(assetId);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const bitmap = await createImageBitmap(asset.blob);
|
|
98
|
+
this.cache.set(assetId, bitmap);
|
|
99
|
+
this.onLoaded();
|
|
100
|
+
} catch {
|
|
101
|
+
this.missing.add(assetId);
|
|
102
|
+
} finally {
|
|
103
|
+
this.loading.delete(assetId);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
evict(assetId: string): void {
|
|
108
|
+
const bmp = this.cache.get(assetId);
|
|
109
|
+
if (bmp) bmp.close();
|
|
110
|
+
this.cache.delete(assetId);
|
|
111
|
+
this.missing.delete(assetId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
clear(): void {
|
|
115
|
+
for (const bmp of this.cache.values()) bmp.close();
|
|
116
|
+
this.cache.clear();
|
|
117
|
+
this.missing.clear();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get size(): number {
|
|
121
|
+
return this.cache.size;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks FPS via rAF sampling and aggregates render/hit-test/save timings.
|
|
3
|
+
*/
|
|
4
|
+
export class PerformanceMonitor {
|
|
5
|
+
fps = 0;
|
|
6
|
+
private frameCount = 0;
|
|
7
|
+
private lastSample = performance.now();
|
|
8
|
+
private running = false;
|
|
9
|
+
|
|
10
|
+
start(): void {
|
|
11
|
+
if (this.running) return;
|
|
12
|
+
this.running = true;
|
|
13
|
+
const tick = () => {
|
|
14
|
+
if (!this.running) return;
|
|
15
|
+
this.frameCount++;
|
|
16
|
+
const now = performance.now();
|
|
17
|
+
const elapsed = now - this.lastSample;
|
|
18
|
+
if (elapsed >= 1000) {
|
|
19
|
+
this.fps = Math.round((this.frameCount * 1000) / elapsed);
|
|
20
|
+
this.frameCount = 0;
|
|
21
|
+
this.lastSample = now;
|
|
22
|
+
}
|
|
23
|
+
requestAnimationFrame(tick);
|
|
24
|
+
};
|
|
25
|
+
requestAnimationFrame(tick);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
stop(): void {
|
|
29
|
+
this.running = false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ensures rendering happens at most once per animation frame.
|
|
3
|
+
*/
|
|
4
|
+
export class RenderScheduler {
|
|
5
|
+
private rafId: number | null = null;
|
|
6
|
+
private renderFn: () => void;
|
|
7
|
+
|
|
8
|
+
constructor(renderFn: () => void) {
|
|
9
|
+
this.renderFn = renderFn;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
requestRender(): void {
|
|
13
|
+
if (this.rafId !== null) return;
|
|
14
|
+
this.rafId = requestAnimationFrame(() => {
|
|
15
|
+
this.rafId = null;
|
|
16
|
+
this.renderFn();
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
cancel(): void {
|
|
21
|
+
if (this.rafId !== null) {
|
|
22
|
+
cancelAnimationFrame(this.rafId);
|
|
23
|
+
this.rafId = null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export type Viewport = {
|
|
2
|
+
offsetX: number;
|
|
3
|
+
offsetY: number;
|
|
4
|
+
scale: number;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type ViewportBounds = {
|
|
8
|
+
left: number;
|
|
9
|
+
top: number;
|
|
10
|
+
right: number;
|
|
11
|
+
bottom: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const MIN_ZOOM = 0.05;
|
|
15
|
+
export const MAX_ZOOM = 40;
|
|
16
|
+
export const DEFAULT_ZOOM = 1;
|
|
17
|
+
|
|
18
|
+
export function screenToWorld(
|
|
19
|
+
screenX: number,
|
|
20
|
+
screenY: number,
|
|
21
|
+
viewport: Viewport
|
|
22
|
+
): { x: number; y: number } {
|
|
23
|
+
return {
|
|
24
|
+
x: (screenX - viewport.offsetX) / viewport.scale,
|
|
25
|
+
y: (screenY - viewport.offsetY) / viewport.scale,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function worldToScreen(
|
|
30
|
+
worldX: number,
|
|
31
|
+
worldY: number,
|
|
32
|
+
viewport: Viewport
|
|
33
|
+
): { x: number; y: number } {
|
|
34
|
+
return {
|
|
35
|
+
x: worldX * viewport.scale + viewport.offsetX,
|
|
36
|
+
y: worldY * viewport.scale + viewport.offsetY,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function clampZoom(scale: number): number {
|
|
41
|
+
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, scale));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Cursor-centered zoom: the world point under the cursor stays under the
|
|
46
|
+
* cursor after the zoom is applied.
|
|
47
|
+
*/
|
|
48
|
+
export function zoomAt(
|
|
49
|
+
viewport: Viewport,
|
|
50
|
+
cursorX: number,
|
|
51
|
+
cursorY: number,
|
|
52
|
+
newScale: number
|
|
53
|
+
): void {
|
|
54
|
+
const clamped = clampZoom(newScale);
|
|
55
|
+
const worldBeforeZoom = {
|
|
56
|
+
x: (cursorX - viewport.offsetX) / viewport.scale,
|
|
57
|
+
y: (cursorY - viewport.offsetY) / viewport.scale,
|
|
58
|
+
};
|
|
59
|
+
viewport.scale = clamped;
|
|
60
|
+
viewport.offsetX = cursorX - worldBeforeZoom.x * viewport.scale;
|
|
61
|
+
viewport.offsetY = cursorY - worldBeforeZoom.y * viewport.scale;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getViewportBounds(
|
|
65
|
+
viewport: Viewport,
|
|
66
|
+
screenWidth: number,
|
|
67
|
+
screenHeight: number
|
|
68
|
+
): ViewportBounds {
|
|
69
|
+
const topLeft = screenToWorld(0, 0, viewport);
|
|
70
|
+
const bottomRight = screenToWorld(screenWidth, screenHeight, viewport);
|
|
71
|
+
return {
|
|
72
|
+
left: topLeft.x,
|
|
73
|
+
top: topLeft.y,
|
|
74
|
+
right: bottomRight.x,
|
|
75
|
+
bottom: bottomRight.y,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { BoardDocument } from "../elements/types";
|
|
2
|
+
|
|
3
|
+
export type TreeNode =
|
|
4
|
+
| { type: "file"; name: string; path: string }
|
|
5
|
+
| { type: "dir"; name: string; path: string; children: TreeNode[] };
|
|
6
|
+
|
|
7
|
+
export type DocsRootSummary = {
|
|
8
|
+
id: string;
|
|
9
|
+
projectId: string;
|
|
10
|
+
projectLabel: string;
|
|
11
|
+
relativePath: string;
|
|
12
|
+
displayPath: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type WorkspaceProject = {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
docsRoots: DocsRootSummary[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type WorkspaceState = {
|
|
22
|
+
projects: WorkspaceProject[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type PickProjectResult = {
|
|
26
|
+
cancelled: boolean;
|
|
27
|
+
workspace: WorkspaceState;
|
|
28
|
+
addedProjectId?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const API = "/__api";
|
|
32
|
+
|
|
33
|
+
async function json<T>(res: Response): Promise<T> {
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
let detail = "";
|
|
36
|
+
try {
|
|
37
|
+
detail = (await res.json())?.error ?? "";
|
|
38
|
+
} catch {
|
|
39
|
+
/* ignore */
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
|
|
42
|
+
}
|
|
43
|
+
return res.json() as Promise<T>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const ContentApi = {
|
|
47
|
+
async getWorkspace(): Promise<WorkspaceState> {
|
|
48
|
+
return json<WorkspaceState>(await fetch(`${API}/workspace`));
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async removeProject(projectId: string): Promise<void> {
|
|
52
|
+
await json(
|
|
53
|
+
await fetch(
|
|
54
|
+
`${API}/workspace/project?id=${encodeURIComponent(projectId)}`,
|
|
55
|
+
{ method: "DELETE" }
|
|
56
|
+
)
|
|
57
|
+
);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async pickAndAddProject(): Promise<PickProjectResult> {
|
|
61
|
+
return json<PickProjectResult>(
|
|
62
|
+
await fetch(`${API}/workspace/pick-project`, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async ensureProjectDocsRoot(projectId: string): Promise<DocsRootSummary> {
|
|
69
|
+
const data = await json<{ root: DocsRootSummary }>(
|
|
70
|
+
await fetch(`${API}/workspace/project/docs-root`, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "Content-Type": "application/json" },
|
|
73
|
+
body: JSON.stringify({ projectId }),
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
return data.root;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async getTree(rootId: string): Promise<TreeNode[]> {
|
|
80
|
+
const data = await json<{ tree: TreeNode[] }>(
|
|
81
|
+
await fetch(`${API}/tree?rootId=${encodeURIComponent(rootId)}`)
|
|
82
|
+
);
|
|
83
|
+
return data.tree;
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async getProjectTree(projectId: string): Promise<TreeNode[]> {
|
|
87
|
+
const data = await json<{ tree: TreeNode[] }>(
|
|
88
|
+
await fetch(`${API}/project-tree?projectId=${encodeURIComponent(projectId)}`)
|
|
89
|
+
);
|
|
90
|
+
return data.tree;
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
async createDocsFolder(projectId: string, parentPath: string): Promise<void> {
|
|
94
|
+
await json(
|
|
95
|
+
await fetch(`${API}/workspace/project/create-docs-folder`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { "Content-Type": "application/json" },
|
|
98
|
+
body: JSON.stringify({ projectId, parentPath }),
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
async readFile(rootId: string, path: string): Promise<string> {
|
|
104
|
+
const data = await json<{ content: string }>(
|
|
105
|
+
await fetch(
|
|
106
|
+
`${API}/file?rootId=${encodeURIComponent(rootId)}&path=${encodeURIComponent(path)}`
|
|
107
|
+
)
|
|
108
|
+
);
|
|
109
|
+
return data.content;
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
async writeFile(rootId: string, path: string, content: string): Promise<void> {
|
|
113
|
+
await json(
|
|
114
|
+
await fetch(`${API}/file`, {
|
|
115
|
+
method: "PUT",
|
|
116
|
+
headers: { "Content-Type": "application/json" },
|
|
117
|
+
body: JSON.stringify({ rootId, path, content }),
|
|
118
|
+
})
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async createDirectory(rootId: string, path: string): Promise<void> {
|
|
123
|
+
await json(
|
|
124
|
+
await fetch(`${API}/directory`, {
|
|
125
|
+
method: "PUT",
|
|
126
|
+
headers: { "Content-Type": "application/json" },
|
|
127
|
+
body: JSON.stringify({ rootId, path }),
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async getBoard(rootId: string, id: string): Promise<BoardDocument | null> {
|
|
133
|
+
const res = await fetch(
|
|
134
|
+
`${API}/board?rootId=${encodeURIComponent(rootId)}&id=${encodeURIComponent(id)}`
|
|
135
|
+
);
|
|
136
|
+
if (res.status === 404) return null;
|
|
137
|
+
const data = await json<{ doc: BoardDocument }>(res);
|
|
138
|
+
return data.doc;
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async saveBoard(rootId: string, id: string, doc: BoardDocument): Promise<void> {
|
|
142
|
+
await json(
|
|
143
|
+
await fetch(`${API}/board`, {
|
|
144
|
+
method: "PUT",
|
|
145
|
+
headers: { "Content-Type": "application/json" },
|
|
146
|
+
body: JSON.stringify({ rootId, id, doc }),
|
|
147
|
+
})
|
|
148
|
+
);
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async deleteFile(rootId: string, filePath: string): Promise<void> {
|
|
152
|
+
await json(
|
|
153
|
+
await fetch(
|
|
154
|
+
`${API}/file?rootId=${encodeURIComponent(rootId)}&path=${encodeURIComponent(filePath)}`,
|
|
155
|
+
{ method: "DELETE" }
|
|
156
|
+
)
|
|
157
|
+
);
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
async renameFile(rootId: string, oldPath: string, newPath: string, ignoreMissing = false): Promise<void> {
|
|
161
|
+
await json(
|
|
162
|
+
await fetch(`${API}/file`, {
|
|
163
|
+
method: "PATCH",
|
|
164
|
+
headers: { "Content-Type": "application/json" },
|
|
165
|
+
body: JSON.stringify({ rootId, oldPath, newPath, ignoreMissing }),
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
async deleteBoard(rootId: string, id: string): Promise<void> {
|
|
171
|
+
await json(
|
|
172
|
+
await fetch(
|
|
173
|
+
`${API}/board?rootId=${encodeURIComponent(rootId)}&id=${encodeURIComponent(id)}`,
|
|
174
|
+
{ method: "DELETE" }
|
|
175
|
+
)
|
|
176
|
+
);
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
assetUrl(rootId: string, path: string): string {
|
|
180
|
+
return `${API}/asset?rootId=${encodeURIComponent(rootId)}&path=${encodeURIComponent(path)}`;
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async uploadAsset(rootId: string, name: string, base64: string): Promise<string> {
|
|
184
|
+
const data = await json<{ path: string }>(
|
|
185
|
+
await fetch(`${API}/asset`, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: { "Content-Type": "application/json" },
|
|
188
|
+
body: JSON.stringify({ rootId, name, base64 }),
|
|
189
|
+
})
|
|
190
|
+
);
|
|
191
|
+
return data.path;
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export function filesUnder(tree: TreeNode[], folder: string): TreeNode[] {
|
|
196
|
+
const root = tree.find((n) => n.type === "dir" && n.name === folder);
|
|
197
|
+
if (!root || root.type !== "dir") return [];
|
|
198
|
+
const out: TreeNode[] = [];
|
|
199
|
+
const walk = (nodes: TreeNode[]) => {
|
|
200
|
+
for (const n of nodes) {
|
|
201
|
+
if (n.type === "file") out.push(n);
|
|
202
|
+
else walk(n.children);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
walk(root.children);
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function firstMarkdownPath(tree: TreeNode[]): string | null {
|
|
210
|
+
for (const node of tree) {
|
|
211
|
+
if (node.type === "file" && node.name.endsWith(".md")) return node.path;
|
|
212
|
+
if (node.type === "dir") {
|
|
213
|
+
const found = firstMarkdownPath(node.children);
|
|
214
|
+
if (found) return found;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function normalizeRelativePath(input: string): string {
|
|
221
|
+
const parts: string[] = [];
|
|
222
|
+
for (const raw of input.replace(/\\/g, "/").split("/")) {
|
|
223
|
+
const part = raw.trim();
|
|
224
|
+
if (!part || part === ".") continue;
|
|
225
|
+
if (part === "..") {
|
|
226
|
+
if (parts.length > 0) parts.pop();
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
parts.push(part);
|
|
230
|
+
}
|
|
231
|
+
return parts.join("/");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function dirnamePosix(filePath: string): string {
|
|
235
|
+
const normalized = normalizeRelativePath(filePath);
|
|
236
|
+
const idx = normalized.lastIndexOf("/");
|
|
237
|
+
return idx === -1 ? "" : normalized.slice(0, idx);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function resolveDocPath(fromFile: string, target: string): string {
|
|
241
|
+
if (/^(https?:|mailto:|tel:|#|data:)/.test(target)) return target;
|
|
242
|
+
if (target.startsWith("/")) return normalizeRelativePath(target);
|
|
243
|
+
const base = dirnamePosix(fromFile);
|
|
244
|
+
return normalizeRelativePath(base ? `${base}/${target}` : target);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function relativePathBetween(fromFile: string, toPath: string): string {
|
|
248
|
+
const fromDir = dirnamePosix(fromFile);
|
|
249
|
+
const fromParts = fromDir ? fromDir.split("/").filter(Boolean) : [];
|
|
250
|
+
const toParts = normalizeRelativePath(toPath).split("/").filter(Boolean);
|
|
251
|
+
let shared = 0;
|
|
252
|
+
while (shared < fromParts.length && shared < toParts.length && fromParts[shared] === toParts[shared]) {
|
|
253
|
+
shared++;
|
|
254
|
+
}
|
|
255
|
+
const up = fromParts.slice(shared).map(() => "..");
|
|
256
|
+
const down = toParts.slice(shared);
|
|
257
|
+
return [...up, ...down].join("/") || toParts[toParts.length - 1] || "";
|
|
258
|
+
}
|