Using IndexedDB

This commit is contained in:
2026-01-21 19:01:59 +01:00
parent d32a86fea8
commit 8a2f05de71

View File

@@ -1,7 +1,10 @@
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import type { Item, Manifest, Asset, AssetStore, Viewport } from './types'; import type { Item, Manifest, Asset, AssetStore, Viewport } from './types';
const STORAGE_KEY = 'buboard'; const DB_NAME = 'buboard';
const DB_VERSION = 1;
const STORE_NAME = 'state';
const LEGACY_STORAGE_KEY = 'buboard';
const DEFAULT_SHARED_CSS = `* { const DEFAULT_SHARED_CSS = `* {
box-sizing: border-box; box-sizing: border-box;
@@ -39,30 +42,22 @@ const DEFAULT_APP_CSS = `:root {
--cm-operator: #56b6c2; --cm-operator: #56b6c2;
}`; }`;
interface StoredAsset { interface StoredAssetIndexedDB {
dataUrl: string; blob: Blob;
filename: string; filename: string;
} }
interface StoredState { function openDb(): Promise<IDBDatabase> {
manifest: Manifest;
assets: Record<string, StoredAsset>;
}
async function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const request = indexedDB.open(DB_NAME, DB_VERSION);
reader.onload = () => resolve(reader.result as string); request.onerror = () => reject(request.error);
reader.onerror = reject; request.onsuccess = () => resolve(request.result);
reader.readAsDataURL(blob); request.onupgradeneeded = () => {
request.result.createObjectStore(STORE_NAME);
};
}); });
} }
async function dataUrlToBlob(dataUrl: string): Promise<Blob> {
const res = await fetch(dataUrl);
return res.blob();
}
function createState() { function createState() {
let manifest = $state<Manifest>({ let manifest = $state<Manifest>({
version: 1, version: 1,
@@ -81,6 +76,7 @@ function createState() {
let clipboard = $state<Omit<Item, 'id'>[]>([]); let clipboard = $state<Omit<Item, 'id'>[]>([]);
let locked = $state<boolean>(false); let locked = $state<boolean>(false);
let snapToGrid = $state<boolean>(false); let snapToGrid = $state<boolean>(false);
let persistenceError = $state<string | null>(null);
const GRID_SIZE = 20; const GRID_SIZE = 20;
@@ -98,18 +94,25 @@ function createState() {
clearTimeout(saveTimeout); clearTimeout(saveTimeout);
saveTimeout = null; saveTimeout = null;
} }
const storedAssets: Record<string, StoredAsset> = {};
for (const [id, asset] of Object.entries(assets)) {
storedAssets[id] = {
dataUrl: await blobToDataUrl(asset.blob),
filename: asset.filename,
};
}
const stored: StoredState = { manifest, assets: storedAssets };
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); const storedAssets: Record<string, StoredAssetIndexedDB> = {};
} catch { for (const [id, asset] of Object.entries(assets)) {
// localStorage full or unavailable storedAssets[id] = { blob: asset.blob, filename: asset.filename };
}
const db = await openDb();
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
store.put($state.snapshot(manifest), 'manifest');
store.put(storedAssets, 'assets');
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
persistenceError = null;
} catch (e) {
persistenceError = e instanceof Error ? e.message : 'Failed to save';
throw e;
} }
} }
@@ -122,29 +125,78 @@ function createState() {
async function restore() { async function restore() {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const db = await openDb();
const loadFromDb = (): Promise<{ manifest: Manifest | null; assets: Record<string, StoredAssetIndexedDB> | null }> => {
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const manifestReq = store.get('manifest');
const assetsReq = store.get('assets');
tx.oncomplete = () => {
resolve({ manifest: manifestReq.result ?? null, assets: assetsReq.result ?? null });
};
tx.onerror = () => resolve({ manifest: null, assets: null });
});
};
const { manifest: storedManifest, assets: storedAssets } = await loadFromDb();
db.close();
if (storedManifest && storedManifest.version === 1) {
const restoredAssets: AssetStore = {};
if (storedAssets) {
for (const [id, asset] of Object.entries(storedAssets)) {
const url = URL.createObjectURL(asset.blob);
restoredAssets[id] = { blob: asset.blob, url, filename: asset.filename };
}
}
for (const item of storedManifest.items) {
if (item.assetId && restoredAssets[item.assetId]) {
const newUrl = restoredAssets[item.assetId].url;
item.html = item.html.replace(/src="[^"]*"/g, `src="${newUrl}"`);
}
}
manifest = { ...storedManifest, flags: storedManifest.flags ?? {} };
assets = restoredAssets;
return;
}
} catch (e) {
console.error('Failed to restore from IndexedDB:', e);
}
// Migration from localStorage
try {
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
if (!raw) return; if (!raw) return;
const stored: StoredState = JSON.parse(raw);
if (stored.manifest.version !== 1) return; interface LegacyStoredAsset { dataUrl: string; filename: string; }
interface LegacyStoredState { manifest: Manifest; assets: Record<string, LegacyStoredAsset>; }
const legacy: LegacyStoredState = JSON.parse(raw);
if (legacy.manifest.version !== 1) return;
const restoredAssets: AssetStore = {}; const restoredAssets: AssetStore = {};
for (const [id, storedAsset] of Object.entries(stored.assets)) { for (const [id, legacyAsset] of Object.entries(legacy.assets)) {
const blob = await dataUrlToBlob(storedAsset.dataUrl); const blob = await (await fetch(legacyAsset.dataUrl)).blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
restoredAssets[id] = { blob, url, filename: storedAsset.filename }; restoredAssets[id] = { blob, url, filename: legacyAsset.filename };
} }
for (const item of stored.manifest.items) { for (const item of legacy.manifest.items) {
if (item.assetId && restoredAssets[item.assetId]) { if (item.assetId && restoredAssets[item.assetId]) {
const newUrl = restoredAssets[item.assetId].url; const newUrl = restoredAssets[item.assetId].url;
item.html = item.html.replace(/src="[^"]*"/g, `src="${newUrl}"`); item.html = item.html.replace(/src="[^"]*"/g, `src="${newUrl}"`);
} }
} }
manifest = { ...stored.manifest, flags: stored.manifest.flags ?? {} }; manifest = { ...legacy.manifest, flags: legacy.manifest.flags ?? {} };
assets = restoredAssets; assets = restoredAssets;
} catch {
// corrupted or missing data await saveNow();
localStorage.removeItem(LEGACY_STORAGE_KEY);
} catch (e) {
console.error('Failed to migrate from localStorage:', e);
} }
} }
@@ -428,7 +480,7 @@ function createState() {
editingId = null; editingId = null;
editingGlobal = false; editingGlobal = false;
focusedId = null; focusedId = null;
localStorage.removeItem(STORAGE_KEY); indexedDB.deleteDatabase(DB_NAME);
} }
async function load(newManifest: Manifest, newAssets: AssetStore): Promise<void> { async function load(newManifest: Manifest, newAssets: AssetStore): Promise<void> {
@@ -481,6 +533,9 @@ function createState() {
get snapToGrid() { get snapToGrid() {
return snapToGrid; return snapToGrid;
}, },
get persistenceError() {
return persistenceError;
},
get maxZIndex() { get maxZIndex() {
return maxZIndex; return maxZIndex;
}, },