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,86 @@
1
+ import type { LocalAsset } from "../elements/types";
2
+ import { generateId } from "../elements/types";
3
+ import { idb } from "./IndexedDbStore";
4
+
5
+ /**
6
+ * Legacy local-only asset store backed by IndexedDB.
7
+ * The workspace-first docs flow prefers file-backed assets inside repo docs
8
+ * roots; this store remains as a fallback for standalone/local-only board mode.
9
+ */
10
+ export class AssetStore {
11
+ async get(id: string): Promise<LocalAsset | undefined> {
12
+ try {
13
+ return await idb.get<LocalAsset>("assets", id);
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ }
18
+
19
+ async createFromBlob(blob: Blob, name?: string): Promise<LocalAsset> {
20
+ const bitmap = await createImageBitmap(blob);
21
+ const now = Date.now();
22
+ const asset: LocalAsset = {
23
+ id: generateId(),
24
+ type: "image",
25
+ mimeType: blob.type || "image/png",
26
+ blob,
27
+ width: bitmap.width,
28
+ height: bitmap.height,
29
+ sizeBytes: blob.size,
30
+ createdAt: now,
31
+ updatedAt: now,
32
+ name,
33
+ };
34
+ bitmap.close();
35
+ await idb.put("assets", asset);
36
+ return asset;
37
+ }
38
+
39
+ async put(asset: LocalAsset): Promise<void> {
40
+ await idb.put("assets", asset);
41
+ }
42
+
43
+ async delete(id: string): Promise<void> {
44
+ await idb.delete("assets", id);
45
+ }
46
+
47
+ async getAllKeys(): Promise<string[]> {
48
+ try {
49
+ return await idb.getAllKeys("assets");
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ async getAll(): Promise<LocalAsset[]> {
56
+ try {
57
+ return await idb.getAll<LocalAsset>("assets");
58
+ } catch {
59
+ return [];
60
+ }
61
+ }
62
+
63
+ async totalSizeBytes(): Promise<number> {
64
+ const all = await this.getAll();
65
+ return all.reduce((sum, a) => sum + a.sizeBytes, 0);
66
+ }
67
+
68
+ /** Remove assets not referenced by the given set of asset IDs. */
69
+ async clearUnused(referencedIds: Set<string>): Promise<number> {
70
+ const keys = await this.getAllKeys();
71
+ let removed = 0;
72
+ for (const key of keys) {
73
+ if (!referencedIds.has(key)) {
74
+ await idb.delete("assets", key);
75
+ removed++;
76
+ }
77
+ }
78
+ return removed;
79
+ }
80
+
81
+ async clearAll(): Promise<void> {
82
+ await idb.clearStore("assets");
83
+ }
84
+ }
85
+
86
+ export const assetStore = new AssetStore();
@@ -0,0 +1,114 @@
1
+ import type { BoardDocument, BoardElement, ElementType } from "../elements/types";
2
+ import type { BoardState } from "../app/BoardState";
3
+
4
+ const VALID_TYPES: ElementType[] = [
5
+ "sticky",
6
+ "task",
7
+ "rectangle",
8
+ "ellipse",
9
+ "text",
10
+ "line",
11
+ "arrow",
12
+ "connector",
13
+ "image",
14
+ "screenshot",
15
+ "frame",
16
+ "templateGroup",
17
+ ];
18
+
19
+ export function serializeBoard(state: BoardState): BoardDocument {
20
+ const assetIds = new Set<string>();
21
+ for (const el of state.elements) {
22
+ const assetId = el.data.assetId;
23
+ if (typeof assetId === "string") assetIds.add(assetId);
24
+ }
25
+ return {
26
+ version: 1,
27
+ id: state.boardId,
28
+ title: state.title,
29
+ createdAt: state.createdAt,
30
+ updatedAt: Date.now(),
31
+ viewport: { ...state.viewport },
32
+ theme: state.theme,
33
+ elements: state.elements,
34
+ assetIds: [...assetIds],
35
+ };
36
+ }
37
+
38
+ function isFiniteNumber(v: unknown): v is number {
39
+ return typeof v === "number" && Number.isFinite(v);
40
+ }
41
+
42
+ export function validateElement(raw: unknown): BoardElement | null {
43
+ if (typeof raw !== "object" || raw === null) return null;
44
+ const el = raw as Record<string, unknown>;
45
+ if (typeof el.id !== "string") return null;
46
+ if (typeof el.type !== "string" || !VALID_TYPES.includes(el.type as ElementType))
47
+ return null;
48
+ if (
49
+ !isFiniteNumber(el.x) ||
50
+ !isFiniteNumber(el.y) ||
51
+ !isFiniteNumber(el.width) ||
52
+ !isFiniteNumber(el.height)
53
+ )
54
+ return null;
55
+
56
+ return {
57
+ id: el.id,
58
+ type: el.type as ElementType,
59
+ x: el.x,
60
+ y: el.y,
61
+ width: el.width,
62
+ height: el.height,
63
+ rotation: isFiniteNumber(el.rotation) ? el.rotation : 0,
64
+ zIndex: isFiniteNumber(el.zIndex) ? el.zIndex : 0,
65
+ locked: el.locked === true,
66
+ hidden: el.hidden === true,
67
+ createdAt: isFiniteNumber(el.createdAt) ? el.createdAt : Date.now(),
68
+ updatedAt: isFiniteNumber(el.updatedAt) ? el.updatedAt : Date.now(),
69
+ style:
70
+ typeof el.style === "object" && el.style !== null
71
+ ? (el.style as BoardElement["style"])
72
+ : {},
73
+ data:
74
+ typeof el.data === "object" && el.data !== null
75
+ ? (el.data as Record<string, unknown>)
76
+ : {},
77
+ };
78
+ }
79
+
80
+ export function validateBoardDocument(raw: unknown): BoardDocument | null {
81
+ if (typeof raw !== "object" || raw === null) return null;
82
+ const doc = raw as Record<string, unknown>;
83
+ if (doc.version !== 1) return null;
84
+ if (typeof doc.id !== "string") return null;
85
+ if (!Array.isArray(doc.elements)) return null;
86
+
87
+ const elements: BoardElement[] = [];
88
+ for (const rawEl of doc.elements) {
89
+ const el = validateElement(rawEl);
90
+ if (el) elements.push(el);
91
+ }
92
+
93
+ const vp = (doc.viewport ?? {}) as Record<string, unknown>;
94
+ return {
95
+ version: 1,
96
+ id: doc.id,
97
+ title: typeof doc.title === "string" ? doc.title.slice(0, 200) : "Imported Board",
98
+ createdAt: isFiniteNumber(doc.createdAt) ? doc.createdAt : Date.now(),
99
+ updatedAt: isFiniteNumber(doc.updatedAt) ? doc.updatedAt : Date.now(),
100
+ viewport: {
101
+ offsetX: isFiniteNumber(vp.offsetX) ? vp.offsetX : 0,
102
+ offsetY: isFiniteNumber(vp.offsetY) ? vp.offsetY : 0,
103
+ scale:
104
+ isFiniteNumber(vp.scale) && vp.scale > 0.01 && vp.scale <= 40
105
+ ? vp.scale
106
+ : 1,
107
+ },
108
+ theme: doc.theme === "dark" ? "dark" : "light",
109
+ elements,
110
+ assetIds: Array.isArray(doc.assetIds)
111
+ ? doc.assetIds.filter((id): id is string => typeof id === "string")
112
+ : [],
113
+ };
114
+ }
@@ -0,0 +1,153 @@
1
+ import type { BoardDocument, LocalAsset } from "../elements/types";
2
+ import { generateId } from "../elements/types";
3
+ import { validateBoardDocument } from "./BoardSerializer";
4
+ import { assetStore } from "./AssetStore";
5
+
6
+ type ExportedAsset = {
7
+ id: string;
8
+ mimeType: string;
9
+ width: number;
10
+ height: number;
11
+ name?: string;
12
+ base64: string;
13
+ };
14
+
15
+ type BoardBundle = {
16
+ bundleVersion: 1;
17
+ board: BoardDocument;
18
+ assets: ExportedAsset[];
19
+ };
20
+
21
+ function blobToBase64(blob: Blob): Promise<string> {
22
+ return new Promise((resolve, reject) => {
23
+ const reader = new FileReader();
24
+ reader.onload = () => {
25
+ const result = reader.result as string;
26
+ resolve(result.slice(result.indexOf(",") + 1));
27
+ };
28
+ reader.onerror = () => reject(reader.error);
29
+ reader.readAsDataURL(blob);
30
+ });
31
+ }
32
+
33
+ function base64ToBlob(base64: string, mimeType: string): Blob {
34
+ const binary = atob(base64);
35
+ const bytes = new Uint8Array(binary.length);
36
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
37
+ return new Blob([bytes], { type: mimeType });
38
+ }
39
+
40
+ function downloadJson(filename: string, data: unknown): void {
41
+ const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
42
+ const url = URL.createObjectURL(blob);
43
+ const a = document.createElement("a");
44
+ a.href = url;
45
+ a.download = filename;
46
+ a.click();
47
+ URL.revokeObjectURL(url);
48
+ }
49
+
50
+ function sanitizeFilename(name: string): string {
51
+ return name.replace(/[^a-z0-9-_ ]/gi, "").trim().slice(0, 60) || "board";
52
+ }
53
+
54
+ export class ImportExportService {
55
+ /** Option A: board JSON only (no image blobs). */
56
+ exportBoardJson(doc: BoardDocument): void {
57
+ downloadJson(`${sanitizeFilename(doc.title)}.json`, doc);
58
+ }
59
+
60
+ /** Option B: full bundle with base64 assets. */
61
+ async exportFullBundle(doc: BoardDocument): Promise<void> {
62
+ const assets: ExportedAsset[] = [];
63
+ for (const assetId of doc.assetIds) {
64
+ const asset = await assetStore.get(assetId);
65
+ if (!asset) continue;
66
+ assets.push({
67
+ id: asset.id,
68
+ mimeType: asset.mimeType,
69
+ width: asset.width,
70
+ height: asset.height,
71
+ name: asset.name,
72
+ base64: await blobToBase64(asset.blob),
73
+ });
74
+ }
75
+ const bundle: BoardBundle = { bundleVersion: 1, board: doc, assets };
76
+ downloadJson(`${sanitizeFilename(doc.title)}-bundle.json`, bundle);
77
+ }
78
+
79
+ /**
80
+ * Parses and validates an imported file. Imports any bundled assets into the
81
+ * legacy IndexedDB asset store. Returns the validated board document, or null
82
+ * if invalid.
83
+ * Never executes imported content.
84
+ */
85
+ async importFromFile(file: File): Promise<BoardDocument | null> {
86
+ let raw: unknown;
87
+ try {
88
+ raw = JSON.parse(await file.text());
89
+ } catch {
90
+ return null;
91
+ }
92
+ if (typeof raw !== "object" || raw === null) return null;
93
+
94
+ const maybeBundle = raw as Record<string, unknown>;
95
+ if (maybeBundle.bundleVersion === 1 && Array.isArray(maybeBundle.assets)) {
96
+ const board = validateBoardDocument(maybeBundle.board);
97
+ if (!board) return null;
98
+ const idRemap = new Map<string, string>();
99
+ for (const rawAsset of maybeBundle.assets) {
100
+ const imported = await this.importAsset(rawAsset);
101
+ if (imported) idRemap.set(imported.originalId, imported.asset.id);
102
+ }
103
+ // remap asset references in elements
104
+ for (const el of board.elements) {
105
+ const ref = el.data.assetId;
106
+ if (typeof ref === "string" && idRemap.has(ref)) {
107
+ el.data.assetId = idRemap.get(ref);
108
+ }
109
+ }
110
+ board.assetIds = [...idRemap.values()];
111
+ return board;
112
+ }
113
+
114
+ return validateBoardDocument(raw);
115
+ }
116
+
117
+ private async importAsset(
118
+ raw: unknown
119
+ ): Promise<{ originalId: string; asset: LocalAsset } | null> {
120
+ if (typeof raw !== "object" || raw === null) return null;
121
+ const a = raw as Record<string, unknown>;
122
+ if (
123
+ typeof a.id !== "string" ||
124
+ typeof a.base64 !== "string" ||
125
+ typeof a.mimeType !== "string"
126
+ )
127
+ return null;
128
+ if (!a.mimeType.startsWith("image/")) return null;
129
+
130
+ try {
131
+ const blob = base64ToBlob(a.base64, a.mimeType);
132
+ const now = Date.now();
133
+ const asset: LocalAsset = {
134
+ id: generateId(),
135
+ type: "image",
136
+ mimeType: a.mimeType,
137
+ blob,
138
+ width: typeof a.width === "number" ? a.width : 0,
139
+ height: typeof a.height === "number" ? a.height : 0,
140
+ sizeBytes: blob.size,
141
+ createdAt: now,
142
+ updatedAt: now,
143
+ name: typeof a.name === "string" ? a.name.slice(0, 100) : undefined,
144
+ };
145
+ await assetStore.put(asset);
146
+ return { originalId: a.id, asset };
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+ }
152
+
153
+ export const importExportService = new ImportExportService();
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Minimal native IndexedDB wrapper.
3
+ * Stores: boards, assets
4
+ */
5
+ const DB_NAME = "viteboard";
6
+ const DB_VERSION = 1;
7
+
8
+ export class IndexedDbStore {
9
+ private dbPromise: Promise<IDBDatabase> | null = null;
10
+
11
+ private open(): Promise<IDBDatabase> {
12
+ if (this.dbPromise) return this.dbPromise;
13
+ this.dbPromise = new Promise((resolve, reject) => {
14
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
15
+ req.onupgradeneeded = () => {
16
+ const db = req.result;
17
+ if (!db.objectStoreNames.contains("boards")) {
18
+ db.createObjectStore("boards", { keyPath: "id" });
19
+ }
20
+ if (!db.objectStoreNames.contains("assets")) {
21
+ db.createObjectStore("assets", { keyPath: "id" });
22
+ }
23
+ };
24
+ req.onsuccess = () => resolve(req.result);
25
+ req.onerror = () => reject(req.error ?? new Error("IndexedDB open failed"));
26
+ req.onblocked = () => reject(new Error("IndexedDB blocked"));
27
+ });
28
+ return this.dbPromise;
29
+ }
30
+
31
+ async put(storeName: string, value: unknown): Promise<void> {
32
+ const db = await this.open();
33
+ return new Promise((resolve, reject) => {
34
+ const tx = db.transaction(storeName, "readwrite");
35
+ tx.objectStore(storeName).put(value as any);
36
+ tx.oncomplete = () => resolve();
37
+ tx.onerror = () => reject(tx.error ?? new Error("IndexedDB write failed"));
38
+ });
39
+ }
40
+
41
+ async get<T>(storeName: string, key: string): Promise<T | undefined> {
42
+ const db = await this.open();
43
+ return new Promise((resolve, reject) => {
44
+ const tx = db.transaction(storeName, "readonly");
45
+ const req = tx.objectStore(storeName).get(key);
46
+ req.onsuccess = () => resolve(req.result as T | undefined);
47
+ req.onerror = () => reject(req.error ?? new Error("IndexedDB read failed"));
48
+ });
49
+ }
50
+
51
+ async delete(storeName: string, key: string): Promise<void> {
52
+ const db = await this.open();
53
+ return new Promise((resolve, reject) => {
54
+ const tx = db.transaction(storeName, "readwrite");
55
+ tx.objectStore(storeName).delete(key);
56
+ tx.oncomplete = () => resolve();
57
+ tx.onerror = () => reject(tx.error ?? new Error("IndexedDB delete failed"));
58
+ });
59
+ }
60
+
61
+ async getAllKeys(storeName: string): Promise<string[]> {
62
+ const db = await this.open();
63
+ return new Promise((resolve, reject) => {
64
+ const tx = db.transaction(storeName, "readonly");
65
+ const req = tx.objectStore(storeName).getAllKeys();
66
+ req.onsuccess = () => resolve(req.result as string[]);
67
+ req.onerror = () => reject(req.error ?? new Error("IndexedDB keys failed"));
68
+ });
69
+ }
70
+
71
+ async getAll<T>(storeName: string): Promise<T[]> {
72
+ const db = await this.open();
73
+ return new Promise((resolve, reject) => {
74
+ const tx = db.transaction(storeName, "readonly");
75
+ const req = tx.objectStore(storeName).getAll();
76
+ req.onsuccess = () => resolve(req.result as T[]);
77
+ req.onerror = () => reject(req.error ?? new Error("IndexedDB read failed"));
78
+ });
79
+ }
80
+
81
+ async clearStore(storeName: string): Promise<void> {
82
+ const db = await this.open();
83
+ return new Promise((resolve, reject) => {
84
+ const tx = db.transaction(storeName, "readwrite");
85
+ tx.objectStore(storeName).clear();
86
+ tx.oncomplete = () => resolve();
87
+ tx.onerror = () => reject(tx.error ?? new Error("IndexedDB clear failed"));
88
+ });
89
+ }
90
+ }
91
+
92
+ export const idb = new IndexedDbStore();
@@ -0,0 +1,104 @@
1
+ import type { BoardDocument } from "../elements/types";
2
+ import { idb } from "./IndexedDbStore";
3
+
4
+ const LS_PREFIX = "psc:";
5
+
6
+ /**
7
+ * Legacy standalone-board persistence: IndexedDB for the full document,
8
+ * localStorage for lightweight preferences and recovery metadata.
9
+ */
10
+ export class LocalBoardStore {
11
+ lastSaveMs = 0;
12
+
13
+ async saveBoard(doc: BoardDocument): Promise<void> {
14
+ const start = performance.now();
15
+ try {
16
+ await idb.put("boards", doc);
17
+ this.lastSaveMs = performance.now() - start;
18
+ // emergency recovery metadata only (small)
19
+ this.setPref("lastBoardId", doc.id);
20
+ this.setPref(
21
+ "recovery",
22
+ JSON.stringify({
23
+ id: doc.id,
24
+ title: doc.title,
25
+ updatedAt: doc.updatedAt,
26
+ elementCount: doc.elements.length,
27
+ })
28
+ );
29
+ } catch (err) {
30
+ console.error("Board save failed", err);
31
+ throw err;
32
+ }
33
+ }
34
+
35
+ async loadBoard(id: string): Promise<BoardDocument | undefined> {
36
+ try {
37
+ return await idb.get<BoardDocument>("boards", id);
38
+ } catch (err) {
39
+ console.error("Board load failed", err);
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ async deleteBoard(id: string): Promise<void> {
45
+ await idb.delete("boards", id);
46
+ }
47
+
48
+ async clearAllBoards(): Promise<void> {
49
+ await idb.clearStore("boards");
50
+ }
51
+
52
+ // ---------- localStorage prefs ----------
53
+
54
+ setPref(key: string, value: string): void {
55
+ try {
56
+ localStorage.setItem(LS_PREFIX + key, value);
57
+ } catch {
58
+ // quota exceeded or unavailable; non-fatal
59
+ }
60
+ }
61
+
62
+ getPref(key: string): string | null {
63
+ try {
64
+ return localStorage.getItem(LS_PREFIX + key);
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ removePref(key: string): void {
71
+ try {
72
+ localStorage.removeItem(LS_PREFIX + key);
73
+ } catch {
74
+ // ignore
75
+ }
76
+ }
77
+
78
+ clearPrefs(): void {
79
+ try {
80
+ const toRemove: string[] = [];
81
+ for (let i = 0; i < localStorage.length; i++) {
82
+ const key = localStorage.key(i);
83
+ if (key && key.startsWith(LS_PREFIX)) toRemove.push(key);
84
+ }
85
+ for (const key of toRemove) localStorage.removeItem(key);
86
+ } catch {
87
+ // ignore
88
+ }
89
+ }
90
+
91
+ async estimateStorage(): Promise<{ usage: number; quota: number }> {
92
+ try {
93
+ if (navigator.storage && navigator.storage.estimate) {
94
+ const est = await navigator.storage.estimate();
95
+ return { usage: est.usage ?? 0, quota: est.quota ?? 0 };
96
+ }
97
+ } catch {
98
+ // ignore
99
+ }
100
+ return { usage: 0, quota: 0 };
101
+ }
102
+ }
103
+
104
+ export const boardStore = new LocalBoardStore();