hopefully fixing issues

This commit is contained in:
2026-01-22 10:44:59 +01:00
parent 8a2f05de71
commit aa550c96b7
14 changed files with 545 additions and 417 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

14
buboard-dist/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>buboard</title>
<script type="module" crossorigin src="/assets/index-BhcwbkF3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CVqZQDnN.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -56,7 +56,7 @@
{#if editingItem}
<Editor mode="item" item={editingItem} onClose={() => appState.edit(null)} />
{:else}
<Editor mode="global" onClose={() => appState.editGlobal(false)} />
<Editor mode="global" onClose={() => appState.setEditingGlobal(false)} />
{/if}
</div>
{/if}

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { state } from './state.svelte';
import { state as boardState } from './state.svelte';
import Item from './Item.svelte';
let container: HTMLDivElement;
let isPanning = false;
let hasPanned = false;
let lastX = 0;
let lastY = 0;
let container: HTMLDivElement | null = $state(null);
let isPanning = $state(false);
let hasPanned = $state(false);
let lastX = $state(0);
let lastY = $state(0);
function handleMouseDown(e: MouseEvent) {
if (e.button === 1 || (e.button === 0 && (e.shiftKey || e.target === container))) {
@@ -25,36 +25,37 @@
if (dx !== 0 || dy !== 0) hasPanned = true;
lastX = e.clientX;
lastY = e.clientY;
state.pan(dx, dy);
boardState.pan(dx, dy);
}
function handleMouseUp(e: MouseEvent) {
if (isPanning && !hasPanned && e.target === container) {
state.clearSelection();
state.focus(null);
boardState.clearSelection();
boardState.focus(null);
}
isPanning = false;
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
if (!container) return;
const delta = e.deltaY * (e.deltaMode === 1 ? 16 : 1);
const factor = Math.pow(0.995, delta);
const rect = container.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
state.zoomAt(factor, cx, cy);
boardState.zoomAt(factor, cx, cy);
}
function handleDrop(e: DragEvent) {
e.preventDefault();
if (state.locked) return;
if (boardState.locked || !container) return;
const files = e.dataTransfer?.files;
if (!files || files.length === 0) return;
const rect = container.getBoundingClientRect();
const dropX = state.snap((e.clientX - rect.left - state.viewport.x) / state.viewport.zoom);
const dropY = state.snap((e.clientY - rect.top - state.viewport.y) / state.viewport.zoom);
const dropX = boardState.snap((e.clientX - rect.left - boardState.viewport.x) / boardState.viewport.zoom);
const dropY = boardState.snap((e.clientY - rect.top - boardState.viewport.y) / boardState.viewport.zoom);
for (const file of files) {
handleFile(file, dropX, dropY);
@@ -69,8 +70,8 @@
if (file.type.startsWith('image/')) {
const img = new Image();
img.onload = () => {
state.addAsset(assetId, { blob: file, url, filename: file.name });
state.addItem({
boardState.addAsset(assetId, { blob: file, url, filename: file.name });
boardState.addItem({
id,
assetId,
html: `<img src="${url}" alt="" />`,
@@ -84,13 +85,13 @@
width: img.naturalWidth,
height: img.naturalHeight,
rotation: 0,
zIndex: state.maxZIndex + 1
zIndex: boardState.maxZIndex + 1
});
};
img.src = url;
} else if (file.type.startsWith('audio/')) {
state.addAsset(assetId, { blob: file, url, filename: file.name });
state.addItem({
boardState.addAsset(assetId, { blob: file, url, filename: file.name });
boardState.addItem({
id,
assetId,
html: `<audio src="${url}" controls></audio>`,
@@ -102,13 +103,13 @@
width: 300,
height: 54,
rotation: 0,
zIndex: state.maxZIndex + 1
zIndex: boardState.maxZIndex + 1
});
} else if (file.type.startsWith('video/')) {
const video = document.createElement('video');
video.onloadedmetadata = () => {
state.addAsset(assetId, { blob: file, url, filename: file.name });
state.addItem({
boardState.addAsset(assetId, { blob: file, url, filename: file.name });
boardState.addItem({
id,
assetId,
html: `<video src="${url}" controls></video>`,
@@ -121,7 +122,7 @@
width: video.videoWidth || 640,
height: video.videoHeight || 360,
rotation: 0,
zIndex: state.maxZIndex + 1
zIndex: boardState.maxZIndex + 1
});
};
video.src = url;
@@ -133,66 +134,66 @@
}
function handleKeyDown(e: KeyboardEvent) {
if (state.editingId || state.editingGlobal) return;
if (state.focusedId) return;
if (boardState.editingId || boardState.editingGlobal) return;
if (boardState.focusedId) return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if ((e.target as HTMLElement)?.isContentEditable) return;
if (e.key === 'Escape') {
state.clearSelection();
state.focus(null);
boardState.clearSelection();
boardState.focus(null);
return;
}
if (state.locked) return;
if (boardState.locked || !container) return;
const mod = e.metaKey || e.ctrlKey;
const rect = container.getBoundingClientRect();
const centerX = (rect.width / 2 - state.viewport.x) / state.viewport.zoom;
const centerY = (rect.height / 2 - state.viewport.y) / state.viewport.zoom;
const centerX = (rect.width / 2 - boardState.viewport.x) / boardState.viewport.zoom;
const centerY = (rect.height / 2 - boardState.viewport.y) / boardState.viewport.zoom;
const hasSelection = state.selectedIds.size > 0;
const singleSelection = state.selectedIds.size === 1;
const firstSelectedId = singleSelection ? [...state.selectedIds][0] : null;
const hasSelection = boardState.selectedIds.size > 0;
const singleSelection = boardState.selectedIds.size === 1;
const firstSelectedId = singleSelection ? [...boardState.selectedIds][0] : null;
if (mod && e.key === 'c' && hasSelection) {
e.preventDefault();
state.copySelected();
boardState.copySelected();
} else if (mod && e.key === 'v') {
e.preventDefault();
const newIds = state.pasteItems(centerX, centerY);
if (newIds.length > 0) state.select(newIds[0]);
const newIds = boardState.pasteItems(centerX, centerY);
if (newIds.length > 0) boardState.select(newIds[0]);
for (let i = 1; i < newIds.length; i++) {
state.toggleSelection(newIds[i]);
boardState.toggleSelection(newIds[i]);
}
} else if (mod && e.key === 'd' && hasSelection) {
e.preventDefault();
const newIds = state.duplicateSelected(centerX, centerY);
if (newIds.length > 0) state.select(newIds[0]);
const newIds = boardState.duplicateSelected(centerX, centerY);
if (newIds.length > 0) boardState.select(newIds[0]);
for (let i = 1; i < newIds.length; i++) {
state.toggleSelection(newIds[i]);
boardState.toggleSelection(newIds[i]);
}
} else if (e.key === '[' && hasSelection) {
e.preventDefault();
for (const id of state.selectedIds) {
state.sendBackward(id);
for (const id of boardState.selectedIds) {
boardState.sendBackward(id);
}
} else if (e.key === ']' && hasSelection) {
e.preventDefault();
for (const id of state.selectedIds) {
state.bringForward(id);
for (const id of boardState.selectedIds) {
boardState.bringForward(id);
}
} else if (e.key.startsWith('Arrow') && hasSelection) {
e.preventDefault();
const step = e.shiftKey ? 10 : 1;
const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
state.moveSelected(dx, dy);
boardState.moveSelected(dx, dy);
} else if (e.key === 'e' && singleSelection && !mod) {
e.preventDefault();
state.focus(firstSelectedId!);
state.edit(firstSelectedId!);
boardState.focus(firstSelectedId!);
boardState.edit(firstSelectedId!);
}
}
</script>
@@ -210,10 +211,10 @@
>
<div
class="viewport"
style="transform: translate({state.viewport.x}px, {state.viewport.y}px) scale({state.viewport
style="transform: translate({boardState.viewport.x}px, {boardState.viewport.y}px) scale({boardState.viewport
.zoom})"
>
{#each state.manifest.items as item (item.id)}
{#each boardState.manifest.items as item (item.id)}
<Item {item} />
{/each}
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Code, Palette, X, Globe, Layers } from 'lucide-svelte';
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { EditorState, Compartment } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { html } from '@codemirror/lang-html';
@@ -40,10 +40,10 @@
editMode = modes[(idx + 1) % modes.length];
}
let htmlContainer = $state<HTMLDivElement>(undefined!);
let cssContainer = $state<HTMLDivElement>(undefined!);
let sharedContainer = $state<HTMLDivElement>(undefined!);
let appContainer = $state<HTMLDivElement>(undefined!);
let htmlContainer: HTMLDivElement | null = $state(null);
let cssContainer: HTMLDivElement | null = $state(null);
let sharedContainer: HTMLDivElement | null = $state(null);
let appContainer: HTMLDivElement | null = $state(null);
let htmlEditor: EditorView | null = null;
let cssEditor: EditorView | null = null;
@@ -136,13 +136,13 @@
const currentAppCss = appState.manifest.appCss;
if (currentAppCss !== previousAppCss) {
previousAppCss = currentAppCss;
setTimeout(() => {
tick().then(() => {
const newTheme = createTheme();
htmlEditor?.dispatch({ effects: themeCompartment.reconfigure(newTheme) });
cssEditor?.dispatch({ effects: themeCompartment.reconfigure(newTheme) });
sharedEditor?.dispatch({ effects: themeCompartment.reconfigure(newTheme) });
appEditor?.dispatch({ effects: themeCompartment.reconfigure(newTheme) });
}, 50);
});
}
});

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { SvelteMap } from 'svelte/reactivity';
import type { Item } from './types';
import { state as appState } from './state.svelte';
import { calculateCenterOffset, constrainToAspectRatio } from './geometry';
@@ -12,30 +11,27 @@
let isResizing = $state(false);
let isRotating = $state(false);
let dragStart = { x: 0, y: 0, itemX: 0, itemY: 0 };
let dragStartPositions: SvelteMap<string, { x: number; y: number }> = new SvelteMap();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- not reactive, only used during drag
let dragStartPositions = new Map<string, { x: number; y: number }>();
let resizeStart = { x: 0, y: 0, width: 0, height: 0, itemX: 0, itemY: 0, corner: '', aspectRatio: 1 };
let rotateStart = { angle: 0, startAngle: 0 };
let itemElement: HTMLDivElement | null = null;
let handleScale = $derived(() => {
let handleScale = $derived.by(() => {
const rawScale = 1 / appState.viewport.zoom;
return Math.min(16 / 12, Math.max(8 / 12, rawScale));
});
let cursorStyle = $derived(() => {
if (isDragging || isRotating) return 'grabbing';
return 'move';
});
let cursorStyle = $derived(isDragging || isRotating ? 'grabbing' : 'move');
let srcdoc = $derived(() => {
return `<!DOCTYPE html>
let srcdoc = $derived(`<!DOCTYPE html>
<html>
<head>
<style>${appState.manifest.sharedCss}</style>
<style>${item.css}</style>
</head>
<body>${item.html}</body>
</html>`;
});
</html>`);
function handleMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
@@ -56,7 +52,7 @@
itemX: item.x,
itemY: item.y
};
dragStartPositions = new SvelteMap();
dragStartPositions = new Map();
for (const id of appState.selectedIds) {
const selectedItem = appState.getItem(id);
if (selectedItem) {
@@ -83,9 +79,8 @@
function handleRotateStart(e: MouseEvent) {
e.stopPropagation();
isRotating = true;
const el = document.querySelector(`[data-item-id="${item.id}"]`);
if (!el) return;
const rect = el.getBoundingClientRect();
if (!itemElement) return;
const rect = itemElement.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
rotateStart = {
@@ -139,9 +134,8 @@
y: appState.snap(resizeStart.itemY + offset.y)
});
} else if (isRotating) {
const el = document.querySelector(`[data-item-id="${item.id}"]`);
if (!el) return;
const rect = el.getBoundingClientRect();
if (!itemElement) return;
const rect = itemElement.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const angle = Math.atan2(e.clientY - centerY, e.clientX - centerX) * (180 / Math.PI);
@@ -180,6 +174,7 @@
<svelte:window onmousemove={handleMouseMove} onmouseup={handleMouseUp} onkeydown={handleKeyDown} />
<div
bind:this={itemElement}
class="item"
class:selected={isSelected}
class:focused={isFocused}
@@ -191,8 +186,8 @@
height: {item.height}px;
transform: rotate({item.rotation}deg);
z-index: {item.zIndex};
cursor: {cursorStyle()};
--handle-scale: {handleScale()};
cursor: {cursorStyle};
--handle-scale: {handleScale};
{appState.locked ? 'pointer-events: none;' : ''}
"
onmousedown={handleMouseDown}
@@ -203,7 +198,7 @@
<iframe
class="content"
title="Item content"
srcdoc={srcdoc()}
srcdoc={srcdoc}
sandbox="allow-scripts allow-same-origin"
></iframe>

View File

@@ -4,9 +4,9 @@
let locked = $derived(appState.locked);
let imageInput = $state<HTMLInputElement>(undefined!);
let soundInput = $state<HTMLInputElement>(undefined!);
let videoInput = $state<HTMLInputElement>(undefined!);
let imageInput: HTMLInputElement | null = $state(null);
let soundInput: HTMLInputElement | null = $state(null);
let videoInput: HTMLInputElement | null = $state(null);
function getSpawnPosition() {
return {
@@ -56,11 +56,22 @@
function addEmbed() {
const url = prompt('Enter URL to embed:');
if (!url) return;
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
alert('Only HTTP/HTTPS URLs are allowed');
return;
}
} catch {
alert('Invalid URL');
return;
}
const id = crypto.randomUUID();
const pos = getSpawnPosition();
const escapedUrl = url.replace(/"/g, '&quot;');
appState.addItem({
id,
html: `<iframe src="${url}" frameborder="0" allowfullscreen></iframe>`,
html: `<iframe src="${escapedUrl}" frameborder="0" allowfullscreen></iframe>`,
css: `iframe {
width: 100%;
height: 100%;
@@ -185,9 +196,9 @@
<div class="palette">
<button onclick={addTile} title="Tile"><Square size={14} /></button>
<button onclick={addText} title="Text"><Type size={14} /></button>
<button onclick={() => imageInput.click()} title="Image"><Image size={14} /></button>
<button onclick={() => soundInput.click()} title="Sound"><Music size={14} /></button>
<button onclick={() => videoInput.click()} title="Video"><Video size={14} /></button>
<button onclick={() => imageInput?.click()} title="Image"><Image size={14} /></button>
<button onclick={() => soundInput?.click()} title="Sound"><Music size={14} /></button>
<button onclick={() => videoInput?.click()} title="Video"><Video size={14} /></button>
<button onclick={addEmbed} title="Embed"><Globe size={14} /></button>
<input

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Upload, Download, Paintbrush, Trash2, EyeOff, ZoomIn, Combine, Lock, Unlock, Grid3x3 } from 'lucide-svelte';
import { exportBoard, importBoard, mergeBoard } from './io';
import { exportBoard, importBoard, mergeBoard } from './io.svelte';
import { state } from './state.svelte';
import Palette from './Palette.svelte';
@@ -48,9 +48,9 @@
input.value = '';
}
function handleClear() {
async function handleClear() {
if (confirm('Clear the canvas? This cannot be undone.')) {
state.reset();
await state.reset();
}
}
@@ -111,7 +111,7 @@
<span>{zoomPercent}%</span>
</button>
</div>
<button onclick={() => state.editGlobal(true)} title="Style" disabled={state.locked}><Paintbrush size={14} /></button>
<button onclick={() => state.setEditingGlobal(true)} title="Style" disabled={state.locked}><Paintbrush size={14} /></button>
<button onclick={handleClear} title="Clear" disabled={state.locked}><Trash2 size={14} /></button>
<button onclick={handleImportClick} title="Import" disabled={state.locked}><Upload size={14} /></button>
<button onclick={handleMergeClick} title="Merge" disabled={state.locked}><Combine size={14} /></button>

View File

@@ -41,43 +41,3 @@ export function constrainToAspectRatio(
return { width: newWidth, height: newWidth / aspectRatio };
}
}
export function detectRotationCorner(
localX: number,
localY: number,
halfWidth: number,
halfHeight: number,
zoneRadius: number,
): string | null {
const corners: Record<string, Point> = {
nw: { x: -halfWidth, y: -halfHeight },
ne: { x: halfWidth, y: -halfHeight },
sw: { x: -halfWidth, y: halfHeight },
se: { x: halfWidth, y: halfHeight },
};
const isInsideBounds =
localX >= -halfWidth &&
localX <= halfWidth &&
localY >= -halfHeight &&
localY <= halfHeight;
if (isInsideBounds) return null;
for (const [name, corner] of Object.entries(corners)) {
const dx = localX - corner.x;
const dy = localY - corner.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > zoneRadius || dist < 3) continue;
const isOutwardX =
(name.includes('w') && dx < 0) || (name.includes('e') && dx > 0);
const isOutwardY =
(name.includes('n') && dy < 0) || (name.includes('s') && dy > 0);
if (isOutwardX || isOutwardY) return name;
}
return null;
}

View File

@@ -7,21 +7,32 @@ export async function exportBoard(): Promise<{
error?: string;
}> {
try {
await state.saveNow();
const zip = new JSZip();
const assetsFolder = zip.folder('assets');
if (!assetsFolder) throw new Error('Failed to create assets folder');
const manifestSnapshot = $state.snapshot(state.manifest);
const assetsSnapshot = $state.snapshot(state.assets);
const exportManifest: Manifest = {
version: 1,
items: state.manifest.items.map((item) => ({ ...item })),
sharedCss: state.manifest.sharedCss,
appCss: state.manifest.appCss,
flags: state.manifest.flags ?? {},
items: manifestSnapshot.items.map((item) => {
const cleanItem = { ...item };
if (cleanItem.assetId && assetsSnapshot[cleanItem.assetId]) {
cleanItem.html = cleanItem.html.replace(/src="blob:[^"]*"/g, 'src=""');
}
return cleanItem;
}),
sharedCss: manifestSnapshot.sharedCss,
appCss: manifestSnapshot.appCss,
flags: manifestSnapshot.flags ?? {},
};
for (const item of exportManifest.items) {
if (item.assetId) {
const asset = state.assets[item.assetId];
const asset = assetsSnapshot[item.assetId];
if (asset) {
const ext = asset.filename.split('.').pop() || 'bin';
const filename = `${item.assetId}.${ext}`;
@@ -77,7 +88,8 @@ export async function importBoard(
};
const assets: AssetStore = {};
const urlReplacements: Map<string, string> = new Map();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- not reactive, local bookkeeping
const urlReplacements = new Map<string, string>();
for (const item of manifest.items) {
if (item.assetId) {
@@ -98,7 +110,7 @@ export async function importBoard(
for (const item of manifest.items) {
if (item.assetId && urlReplacements.has(item.assetId)) {
const newUrl = urlReplacements.get(item.assetId)!;
item.html = item.html.replace(/src="[^"]*"/g, `src="${newUrl}"`);
item.html = item.html.replace(/src="(blob:[^"]*|)"/g, `src="${newUrl}"`);
}
}
@@ -133,6 +145,7 @@ export async function mergeBoard(
return { success: true, itemCount: 0 };
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- not reactive, local bookkeeping
const assetIdMap = new Map<string, string>();
const newAssets: AssetStore = {};
@@ -163,7 +176,7 @@ export async function mergeBoard(
let html = item.html;
if (newAssetId && newAssets[newAssetId]) {
html = html.replace(/src="[^"]*"/g, `src="${newAssets[newAssetId].url}"`);
html = html.replace(/src="(blob:[^"]*|)"/g, `src="${newAssets[newAssetId].url}"`);
}
return {

View File

@@ -58,72 +58,121 @@ function openDb(): Promise<IDBDatabase> {
});
}
function createState() {
let manifest = $state<Manifest>({
const GRID_SIZE = 20;
class BoardState {
manifest = $state<Manifest>({
version: 1,
items: [],
sharedCss: DEFAULT_SHARED_CSS,
appCss: DEFAULT_APP_CSS,
flags: {},
});
let assets = $state<AssetStore>({});
let viewport = $state<Viewport>({ x: 0, y: 0, zoom: 1 });
// eslint-disable-next-line svelte/no-unnecessary-state-wrap
let selectedIds = $state(new SvelteSet<string>());
let editingId = $state<string | null>(null);
let editingGlobal = $state<boolean>(false);
let focusedId = $state<string | null>(null);
let clipboard = $state<Omit<Item, 'id'>[]>([]);
let locked = $state<boolean>(false);
let snapToGrid = $state<boolean>(false);
let persistenceError = $state<string | null>(null);
assets = $state<AssetStore>({});
viewport = $state<Viewport>({ x: 0, y: 0, zoom: 1 });
selectedIds = $state(new SvelteSet<string>());
editingId = $state<string | null>(null);
editingGlobal = $state<boolean>(false);
focusedId = $state<string | null>(null);
clipboard = $state<Omit<Item, 'id'>[]>([]);
locked = $state<boolean>(false);
snapToGrid = $state<boolean>(false);
persistenceError = $state<string | null>(null);
isRestoring = $state<boolean>(true);
const GRID_SIZE = 20;
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
private animationId: number | null = null;
private isSaving = false;
private pendingSave = false;
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let animationId: number | null = null;
get maxZIndex(): number {
return this.manifest.items.length > 0
? Math.max(...this.manifest.items.map((i) => i.zIndex))
: 0;
}
const maxZIndex = $derived(
manifest.items.length > 0
? Math.max(...manifest.items.map((i) => i.zIndex))
: 0,
);
constructor() {
if (typeof window !== 'undefined') {
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
}
this.saveNow();
}
});
async function saveNow(): Promise<void> {
if (saveTimeout) {
clearTimeout(saveTimeout);
saveTimeout = null;
window.addEventListener('pagehide', () => {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
}
this.saveNow();
});
window.addEventListener('beforeunload', () => {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
this.saveNow();
}
});
}
this.restore();
}
async saveNow(): Promise<void> {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
}
if (this.isSaving) {
this.pendingSave = true;
return;
}
this.isSaving = true;
this.pendingSave = false;
try {
const storedAssets: Record<string, StoredAssetIndexedDB> = {};
for (const [id, asset] of Object.entries(assets)) {
for (const [id, asset] of Object.entries(this.assets)) {
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($state.snapshot(this.manifest), 'manifest');
store.put(storedAssets, 'assets');
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
persistenceError = null;
this.persistenceError = null;
} catch (e) {
persistenceError = e instanceof Error ? e.message : 'Failed to save';
throw e;
this.persistenceError = e instanceof Error ? e.message : 'Failed to save';
console.error('Failed to save to IndexedDB:', e);
} finally {
this.isSaving = false;
if (this.pendingSave) {
this.saveNow();
}
}
}
function save() {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
saveNow();
}, 500);
private save() {
if (this.saveTimeout) clearTimeout(this.saveTimeout);
this.saveTimeout = setTimeout(() => {
this.saveNow();
}, 100);
}
async function restore() {
private async restore() {
this.isRestoring = true;
try {
const db = await openDb();
@@ -154,27 +203,33 @@ function createState() {
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}"`);
item.html = item.html.replace(/src="(blob:[^"]*|)"/g, `src="${newUrl}"`);
}
}
manifest = { ...storedManifest, flags: storedManifest.flags ?? {} };
assets = restoredAssets;
this.manifest = { ...storedManifest, flags: storedManifest.flags ?? {} };
this.assets = restoredAssets;
this.isRestoring = false;
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) {
this.isRestoring = false;
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;
if (legacy.manifest.version !== 1) {
this.isRestoring = false;
return;
}
const restoredAssets: AssetStore = {};
for (const [id, legacyAsset] of Object.entries(legacy.assets)) {
@@ -186,397 +241,323 @@ function createState() {
for (const item of legacy.manifest.items) {
if (item.assetId && restoredAssets[item.assetId]) {
const newUrl = restoredAssets[item.assetId].url;
item.html = item.html.replace(/src="[^"]*"/g, `src="${newUrl}"`);
item.html = item.html.replace(/src="(blob:[^"]*|)"/g, `src="${newUrl}"`);
}
}
manifest = { ...legacy.manifest, flags: legacy.manifest.flags ?? {} };
assets = restoredAssets;
this.manifest = { ...legacy.manifest, flags: legacy.manifest.flags ?? {} };
this.assets = restoredAssets;
await saveNow();
await this.saveNow();
localStorage.removeItem(LEGACY_STORAGE_KEY);
} catch (e) {
console.error('Failed to migrate from localStorage:', e);
}
this.isRestoring = false;
}
function addItem(item: Item) {
manifest.items.push(item);
save();
addItem(item: Item) {
this.manifest.items.push(item);
this.save();
}
function updateItem(id: string, updates: Partial<Item>) {
const item = manifest.items.find((i) => i.id === id);
updateItem(id: string, updates: Partial<Item>) {
const item = this.manifest.items.find((i) => i.id === id);
if (!item) return;
Object.assign(item, updates);
save();
this.save();
}
function removeItem(id: string) {
const idx = manifest.items.findIndex((i) => i.id === id);
removeItem(id: string) {
const idx = this.manifest.items.findIndex((i) => i.id === id);
if (idx === -1) return;
const item = manifest.items[idx];
if (item.assetId && assets[item.assetId]) {
URL.revokeObjectURL(assets[item.assetId].url);
delete assets[item.assetId];
const item = this.manifest.items[idx];
if (item.assetId && this.assets[item.assetId]) {
URL.revokeObjectURL(this.assets[item.assetId].url);
delete this.assets[item.assetId];
}
manifest.items.splice(idx, 1);
selectedIds.delete(id);
if (editingId === id) editingId = null;
save();
this.manifest.items.splice(idx, 1);
this.selectedIds.delete(id);
if (this.editingId === id) this.editingId = null;
this.save();
}
function addAsset(id: string, asset: Asset) {
assets[id] = asset;
save();
addAsset(id: string, asset: Asset) {
this.assets[id] = asset;
this.save();
}
function addItems(items: Item[]) {
manifest.items.push(...items);
save();
addItems(items: Item[]) {
this.manifest.items.push(...items);
this.save();
}
function addAssets(newAssets: AssetStore) {
Object.assign(assets, newAssets);
save();
addAssets(newAssets: AssetStore) {
Object.assign(this.assets, newAssets);
this.save();
}
async function mergeData(newAssets: AssetStore, newItems: Item[]): Promise<void> {
Object.assign(assets, newAssets);
manifest.items.push(...newItems);
await saveNow();
async mergeData(newAssets: AssetStore, newItems: Item[]): Promise<void> {
Object.assign(this.assets, newAssets);
this.manifest.items.push(...newItems);
await this.saveNow();
}
function getItem(id: string): Item | undefined {
return manifest.items.find((i) => i.id === id);
getItem(id: string): Item | undefined {
return this.manifest.items.find((i) => i.id === id);
}
function select(id: string | null) {
selectedIds = new SvelteSet(id ? [id] : []);
select(id: string | null) {
this.selectedIds = new SvelteSet(id ? [id] : []);
}
function toggleSelection(id: string) {
const newSet = new SvelteSet(selectedIds);
toggleSelection(id: string) {
const newSet = new SvelteSet(this.selectedIds);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
selectedIds = newSet;
this.selectedIds = newSet;
}
function clearSelection() {
selectedIds = new SvelteSet();
clearSelection() {
this.selectedIds = new SvelteSet();
}
function edit(id: string | null) {
editingId = id;
if (id) editingGlobal = false;
edit(id: string | null) {
this.editingId = id;
if (id) this.editingGlobal = false;
}
function editGlobal(editing: boolean) {
editingGlobal = editing;
if (editing) editingId = null;
setEditingGlobal(editing: boolean) {
this.editingGlobal = editing;
if (editing) this.editingId = null;
}
function setLocked(value: boolean) {
locked = value;
setLocked(value: boolean) {
this.locked = value;
if (value) {
clearSelection();
focus(null);
edit(null);
editGlobal(false);
this.clearSelection();
this.focus(null);
this.edit(null);
this.setEditingGlobal(false);
}
}
function setSnapToGrid(value: boolean) {
snapToGrid = value;
setSnapToGrid(value: boolean) {
this.snapToGrid = value;
}
function snap(value: number): number {
return snapToGrid ? Math.round(value / GRID_SIZE) * GRID_SIZE : value;
snap(value: number): number {
return this.snapToGrid ? Math.round(value / GRID_SIZE) * GRID_SIZE : value;
}
function focus(id: string | null) {
focusedId = id;
focus(id: string | null) {
this.focusedId = id;
}
function updateSharedCss(css: string) {
manifest.sharedCss = css;
save();
updateSharedCss(css: string) {
this.manifest.sharedCss = css;
this.save();
}
function updateAppCss(css: string) {
manifest.appCss = css;
save();
updateAppCss(css: string) {
this.manifest.appCss = css;
this.save();
}
function bringToFront(id: string) {
const item = manifest.items.find((i) => i.id === id);
bringToFront(id: string) {
const item = this.manifest.items.find((i) => i.id === id);
if (!item) return;
item.zIndex = maxZIndex + 1;
save();
item.zIndex = this.maxZIndex + 1;
this.save();
}
function copySelected() {
if (selectedIds.size === 0) return;
const items = manifest.items.filter((i) => selectedIds.has(i.id));
clipboard = items.map(({ id: _id, ...rest }) => rest);
copySelected() {
if (this.selectedIds.size === 0) return;
const items = this.manifest.items.filter((i) => this.selectedIds.has(i.id));
this.clipboard = items.map(({ id: _id, ...rest }) => rest);
}
function pasteItems(x: number, y: number): string[] {
if (clipboard.length === 0) return [];
pasteItems(x: number, y: number): string[] {
if (this.clipboard.length === 0) return [];
const newIds: string[] = [];
const centerX =
clipboard.reduce((sum, i) => sum + i.x, 0) / clipboard.length;
this.clipboard.reduce((sum, i) => sum + i.x, 0) / this.clipboard.length;
const centerY =
clipboard.reduce((sum, i) => sum + i.y, 0) / clipboard.length;
for (const item of clipboard) {
this.clipboard.reduce((sum, i) => sum + i.y, 0) / this.clipboard.length;
for (const item of this.clipboard) {
const id = crypto.randomUUID();
const offsetX = item.x - centerX;
const offsetY = item.y - centerY;
addItem({
this.addItem({
...item,
id,
x: x + offsetX,
y: y + offsetY,
zIndex: maxZIndex + 1,
zIndex: this.maxZIndex + 1,
});
newIds.push(id);
}
return newIds;
}
function duplicateSelected(x: number, y: number): string[] {
copySelected();
return pasteItems(x, y);
duplicateSelected(x: number, y: number): string[] {
this.copySelected();
return this.pasteItems(x, y);
}
function removeSelected() {
const ids = [...selectedIds];
removeSelected() {
const ids = [...this.selectedIds];
for (const id of ids) {
removeItem(id);
this.removeItem(id);
}
}
function moveSelected(dx: number, dy: number) {
for (const id of selectedIds) {
const item = manifest.items.find((i) => i.id === id);
moveSelected(dx: number, dy: number) {
for (const id of this.selectedIds) {
const item = this.manifest.items.find((i) => i.id === id);
if (item) {
item.x += dx;
item.y += dy;
}
}
save();
this.save();
}
function sendBackward(id: string) {
const item = manifest.items.find((i) => i.id === id);
sendBackward(id: string) {
const item = this.manifest.items.find((i) => i.id === id);
if (!item || item.zIndex <= 0) return;
item.zIndex -= 1;
save();
this.save();
}
function bringForward(id: string) {
const item = manifest.items.find((i) => i.id === id);
bringForward(id: string) {
const item = this.manifest.items.find((i) => i.id === id);
if (!item) return;
item.zIndex += 1;
save();
this.save();
}
function pan(dx: number, dy: number) {
viewport.x += dx;
viewport.y += dy;
pan(dx: number, dy: number) {
this.viewport.x += dx;
this.viewport.y += dy;
}
function zoomAt(factor: number, cx: number, cy: number) {
const newZoom = Math.max(0.1, Math.min(10, viewport.zoom * factor));
const scale = newZoom / viewport.zoom;
viewport.x = cx - (cx - viewport.x) * scale;
viewport.y = cy - (cy - viewport.y) * scale;
viewport.zoom = newZoom;
zoomAt(factor: number, cx: number, cy: number) {
const newZoom = Math.max(0.1, Math.min(10, this.viewport.zoom * factor));
const scale = newZoom / this.viewport.zoom;
this.viewport.x = cx - (cx - this.viewport.x) * scale;
this.viewport.y = cy - (cy - this.viewport.y) * scale;
this.viewport.zoom = newZoom;
}
function setZoom(zoom: number) {
viewport.zoom = Math.max(0.1, Math.min(10, zoom));
setZoom(zoom: number) {
this.viewport.zoom = Math.max(0.1, Math.min(10, zoom));
}
function resetViewport() {
animateViewport(0, 0, 1);
resetViewport() {
this.animateViewport(0, 0, 1);
}
function hasFlag(key: string): boolean {
return !!manifest.flags?.[key];
hasFlag(key: string): boolean {
return !!this.manifest.flags?.[key];
}
function setFlag(key: string) {
if (!manifest.flags) manifest.flags = {};
manifest.flags[key] = { x: viewport.x, y: viewport.y, zoom: viewport.zoom };
save();
setFlag(key: string) {
if (!this.manifest.flags) this.manifest.flags = {};
this.manifest.flags[key] = { x: this.viewport.x, y: this.viewport.y, zoom: this.viewport.zoom };
this.save();
}
function clearFlag(key: string) {
if (manifest.flags?.[key]) {
delete manifest.flags[key];
save();
clearFlag(key: string) {
if (this.manifest.flags?.[key]) {
delete this.manifest.flags[key];
this.save();
}
}
function easeInOutCubic(t: number): number {
private easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function animateViewport(
targetX: number,
targetY: number,
targetZoom: number,
) {
if (animationId) cancelAnimationFrame(animationId);
private animateViewport(targetX: number, targetY: number, targetZoom: number) {
if (this.animationId) cancelAnimationFrame(this.animationId);
const startX = viewport.x;
const startY = viewport.y;
const startZoom = viewport.zoom;
const startX = this.viewport.x;
const startY = this.viewport.y;
const startZoom = this.viewport.zoom;
const startTime = performance.now();
const duration = 600;
function tick(now: number) {
const tick = (now: number) => {
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const eased = easeInOutCubic(t);
const eased = this.easeInOutCubic(t);
viewport.x = startX + (targetX - startX) * eased;
viewport.y = startY + (targetY - startY) * eased;
viewport.zoom = startZoom + (targetZoom - startZoom) * eased;
this.viewport.x = startX + (targetX - startX) * eased;
this.viewport.y = startY + (targetY - startY) * eased;
this.viewport.zoom = startZoom + (targetZoom - startZoom) * eased;
if (t < 1) {
animationId = requestAnimationFrame(tick);
this.animationId = requestAnimationFrame(tick);
} else {
animationId = null;
this.animationId = null;
}
}
};
animationId = requestAnimationFrame(tick);
this.animationId = requestAnimationFrame(tick);
}
function gotoFlag(key: string) {
const flag = manifest.flags?.[key];
gotoFlag(key: string) {
const flag = this.manifest.flags?.[key];
if (flag) {
animateViewport(flag.x, flag.y, flag.zoom);
this.animateViewport(flag.x, flag.y, flag.zoom);
}
}
function reset() {
Object.values(assets).forEach((a) => URL.revokeObjectURL(a.url));
manifest = {
async reset(): Promise<void> {
Object.values(this.assets).forEach((a) => URL.revokeObjectURL(a.url));
this.manifest = {
version: 1,
items: [],
sharedCss: DEFAULT_SHARED_CSS,
appCss: DEFAULT_APP_CSS,
flags: {},
};
assets = {};
viewport = { x: 0, y: 0, zoom: 1 };
selectedIds = new SvelteSet();
editingId = null;
editingGlobal = false;
focusedId = null;
indexedDB.deleteDatabase(DB_NAME);
this.assets = {};
this.viewport = { x: 0, y: 0, zoom: 1 };
this.selectedIds = new SvelteSet();
this.editingId = null;
this.editingGlobal = false;
this.focusedId = null;
await new Promise<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase(DB_NAME);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async function load(newManifest: Manifest, newAssets: AssetStore): Promise<void> {
Object.values(assets).forEach((a) => URL.revokeObjectURL(a.url));
async load(newManifest: Manifest, newAssets: AssetStore): Promise<void> {
Object.values(this.assets).forEach((a) => URL.revokeObjectURL(a.url));
for (const item of newManifest.items) {
if (item.assetId && newAssets[item.assetId]) {
const newUrl = newAssets[item.assetId].url;
item.html = item.html.replace(/src="[^"]*"/g, `src="${newUrl}"`);
item.html = item.html.replace(/src="(blob:[^"]*|)"/g, `src="${newUrl}"`);
}
}
manifest = newManifest;
assets = newAssets;
viewport = { x: 0, y: 0, zoom: 1 };
selectedIds = new SvelteSet();
editingId = null;
editingGlobal = false;
focusedId = null;
await saveNow();
this.manifest = newManifest;
this.assets = newAssets;
this.viewport = { x: 0, y: 0, zoom: 1 };
this.selectedIds = new SvelteSet();
this.editingId = null;
this.editingGlobal = false;
this.focusedId = null;
await this.saveNow();
}
restore();
return {
get manifest() {
return manifest;
},
get assets() {
return assets;
},
get viewport() {
return viewport;
},
get selectedIds() {
return selectedIds;
},
get editingId() {
return editingId;
},
get editingGlobal() {
return editingGlobal;
},
get focusedId() {
return focusedId;
},
get locked() {
return locked;
},
get snapToGrid() {
return snapToGrid;
},
get persistenceError() {
return persistenceError;
},
get maxZIndex() {
return maxZIndex;
},
addItem,
addItems,
updateItem,
removeItem,
addAsset,
addAssets,
mergeData,
getItem,
select,
toggleSelection,
clearSelection,
edit,
editGlobal,
setLocked,
setSnapToGrid,
snap,
focus,
updateSharedCss,
updateAppCss,
bringToFront,
copySelected,
pasteItems,
duplicateSelected,
removeSelected,
moveSelected,
sendBackward,
bringForward,
pan,
zoomAt,
setZoom,
resetViewport,
hasFlag,
setFlag,
clearFlag,
gotoFlag,
reset,
load,
};
}
export const state = createState();
export const state = new BoardState();