hopefully fixing issues
This commit is contained in:
152
buboard-dist/assets/index-BhcwbkF3.js
Normal file
152
buboard-dist/assets/index-BhcwbkF3.js
Normal file
File diff suppressed because one or more lines are too long
1
buboard-dist/assets/index-CVqZQDnN.css
Normal file
1
buboard-dist/assets/index-CVqZQDnN.css
Normal file
File diff suppressed because one or more lines are too long
BIN
buboard-dist/fonts/DepartureMono-Regular.woff
Normal file
BIN
buboard-dist/fonts/DepartureMono-Regular.woff
Normal file
Binary file not shown.
BIN
buboard-dist/fonts/DepartureMono-Regular.woff2
Normal file
BIN
buboard-dist/fonts/DepartureMono-Regular.woff2
Normal file
Binary file not shown.
14
buboard-dist/index.html
Normal file
14
buboard-dist/index.html
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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, '"');
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user