From 81c0d8cfc0f8b677d86b79a91c75cc8998a4d6f6 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 23 Apr 2026 23:21:42 +0200 Subject: [PATCH] feat(website): session-scoped undo/redo for the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a per-session history stack for the website editor — Cmd+Z / Cmd+Shift+Z, plus ↶ / ↷ buttons in a small toolbar above the canvas. Scoped to a single page's editing session: cleared on page switch and unmount. No persistence across reloads, no cross-device replay. Covers block-level ops: add, update props, delete, move up/down. Each mutation records a (undo, redo) pair so both directions are replayable; a fresh action branches off the redo timeline. Architecture: - history.svelte.ts — session-only stack exposed via Svelte context. The wrapped methods (addBlock, updateBlockProps, …) call through to blocksStore and push the inverse pair onto the stack. limit=100 to bound memory; past the cap the oldest entry is dropped. - blocksStore primitives — restoreBlock(snapshot), setBlockProps(id, full), setBlockOrder(id, order). Needed because redo of add wants the exact id back (so selection references stay valid), and redo of reorder wants the numeric order, not a new fractional-index insert. - reorderBlock now emits WebsiteBlockUpdated with fields:['order'] — fixes an audit finding that order changes were silently skipping the event log. - BlockInspector reads the history from context and routes all four of its mutations through it; falls back to the raw store if no history is mounted (keeps the inspector reusable). UX choices: - Undo/Redo is suppressed when focus is in an INPUT/TEXTAREA/contenteditable so the browser's native text-undo wins inside form fields. - Toolbar buttons show the pending label in the tooltip ("Rückgängig: Text-Block ändern") so users see what Cmd+Z will actually revert. - Page switch clears the stack because undoing across pages would step into a block the user can no longer see — confusing and error-prone. Why session-only (not event-log based): Before-snapshots in _events would bloat the store (prop updates with embedded images easily hit 100KB/row) and cross-device undo isn't a real use case for a single-user editor. See the thread that preceded this commit for the full tradeoff between session-stack, event-log replay, and a hybrid persist layer. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../website/components/BlockInspector.svelte | 19 +- .../src/lib/modules/website/history.svelte.ts | 194 ++++++++++++++++++ .../modules/website/stores/blocks.svelte.ts | 83 ++++++++ .../modules/website/views/EditorView.svelte | 92 ++++++++- 4 files changed, 381 insertions(+), 7 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/website/history.svelte.ts diff --git a/apps/mana/apps/web/src/lib/modules/website/components/BlockInspector.svelte b/apps/mana/apps/web/src/lib/modules/website/components/BlockInspector.svelte index c3fd52a55..6c87a6ca8 100644 --- a/apps/mana/apps/web/src/lib/modules/website/components/BlockInspector.svelte +++ b/apps/mana/apps/web/src/lib/modules/website/components/BlockInspector.svelte @@ -2,6 +2,7 @@ import type { Component } from 'svelte'; import { getBlockSpec, type Block, type BlockInspectorProps } from '@mana/website-blocks'; import { blocksStore, InvalidBlockPropsError } from '../stores/blocks.svelte'; + import { getEditorHistoryContext } from '../history.svelte'; import type { WebsiteBlock } from '../types'; import ImageInspector from './ImageInspector.svelte'; import GalleryInspector from './GalleryInspector.svelte'; @@ -14,6 +15,11 @@ let { block, siblings, onDeleted }: Props = $props(); + // Route all mutations through the editor's history layer when it's + // mounted (always, in the current EditorView). Falls back to the raw + // store so the inspector stays usable on surfaces without history. + const history = getEditorHistoryContext(); + const siblingIndex = $derived(siblings.findIndex((b) => b.id === block.id)); const canMoveUp = $derived(siblingIndex > 0); const canMoveDown = $derived(siblingIndex >= 0 && siblingIndex < siblings.length - 1); @@ -46,7 +52,9 @@ async function onChange(patch: unknown) { lastError = null; try { - await blocksStore.updateBlockProps(block.id, patch as Record); + const p = patch as Record; + if (history) await history.updateBlockProps(block.id, p); + else await blocksStore.updateBlockProps(block.id, p); } catch (err) { if (err instanceof InvalidBlockPropsError) { lastError = `Validation failed: ${err.message}`; @@ -58,18 +66,21 @@ async function onDelete() { if (!confirm('Diesen Block löschen?')) return; - await blocksStore.deleteBlock(block.id); + if (history) await history.deleteBlock(block.id); + else await blocksStore.deleteBlock(block.id); onDeleted?.(); } async function onMoveUp() { if (!canMoveUp) return; - await blocksStore.moveBlockUp(block.id); + if (history) await history.moveBlockUp(block.id); + else await blocksStore.moveBlockUp(block.id); } async function onMoveDown() { if (!canMoveDown) return; - await blocksStore.moveBlockDown(block.id); + if (history) await history.moveBlockDown(block.id); + else await blocksStore.moveBlockDown(block.id); } function asRegistryBlock(b: WebsiteBlock): Block { diff --git a/apps/mana/apps/web/src/lib/modules/website/history.svelte.ts b/apps/mana/apps/web/src/lib/modules/website/history.svelte.ts new file mode 100644 index 000000000..21c93a540 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/history.svelte.ts @@ -0,0 +1,194 @@ +/** + * Editor-scoped undo/redo. + * + * Session-only history — lives as long as the EditorView component is + * mounted on a given page, cleared on page switch and unmount. Stores a + * `(undo, redo)` pair per user action so both directions are replayable. + * Does not persist across reloads and does not survive cross-device + * editing (LWW sync wins in the unlikely conflict case — the editor is + * practically single-user per session). + * + * Why session-only: users expect Cmd+Z to wind back their current work + * session, not to act as a historical audit log. Cross-session undo + * would need Before-snapshots in `_events` and a redo cursor, which is a + * much larger design (see "Option B" in the plan discussion). + * + * The history object is passed down via Svelte context — components that + * need history-aware mutations (BlockInspector, InsertPalette wiring) + * call `getEditorHistory()` and route through its wrapped methods. The + * raw `blocksStore` methods remain available as a fallback for non-editor + * surfaces where no history is mounted. + */ + +import { getContext, setContext } from 'svelte'; +import { requireBlockSpec } from '@mana/website-blocks'; +import { websiteBlocksTable } from './collections'; +import type { LocalWebsiteBlock } from './types'; +import { blocksStore, type AddBlockInput } from './stores/blocks.svelte'; + +interface HistoryEntry { + label: string; + undo: () => Promise; + redo: () => Promise; +} + +export interface EditorHistory { + readonly canUndo: boolean; + readonly canRedo: boolean; + readonly undoLabel: string | null; + readonly redoLabel: string | null; + + clear(): void; + undo(): Promise; + redo(): Promise; + + addBlock(input: AddBlockInput): Promise; + updateBlockProps(id: string, patch: Record): Promise; + deleteBlock(id: string): Promise; + moveBlockUp(id: string): Promise; + moveBlockDown(id: string): Promise; +} + +const HISTORY_CONTEXT_KEY = Symbol('website-editor-history'); + +export function createEditorHistory(limit = 100): EditorHistory { + let undoStack = $state([]); + let redoStack = $state([]); + + function push(entry: HistoryEntry) { + undoStack.push(entry); + if (undoStack.length > limit) undoStack.shift(); + // Any fresh action branches off the redo timeline. + redoStack = []; + } + + return { + get canUndo() { + return undoStack.length > 0; + }, + get canRedo() { + return redoStack.length > 0; + }, + get undoLabel() { + return undoStack[undoStack.length - 1]?.label ?? null; + }, + get redoLabel() { + return redoStack[redoStack.length - 1]?.label ?? null; + }, + + clear() { + undoStack = []; + redoStack = []; + }, + + async undo() { + const entry = undoStack[undoStack.length - 1]; + if (!entry) return; + try { + await entry.undo(); + undoStack.pop(); + redoStack.push(entry); + } catch (err) { + // Keep the entry on the stack so the user can retry; the most + // likely failure is a Zod revalidation error where the old + // props no longer fit a changed schema. + console.error('[website/history] undo failed', err); + } + }, + + async redo() { + const entry = redoStack[redoStack.length - 1]; + if (!entry) return; + try { + await entry.redo(); + redoStack.pop(); + undoStack.push(entry); + } catch (err) { + console.error('[website/history] redo failed', err); + } + }, + + async addBlock(input) { + const block = await blocksStore.addBlock(input); + const spec = requireBlockSpec(block.type); + const snapshot = { ...block }; + push({ + label: `${spec.label} hinzufügen`, + undo: () => blocksStore.deleteBlock(block.id), + redo: async () => { + await blocksStore.restoreBlock(snapshot); + }, + }); + return block; + }, + + async updateBlockProps(id, patch) { + const before = await websiteBlocksTable.get(id); + if (!before) return; + const prevProps = before.props; + await blocksStore.updateBlockProps(id, patch); + const after = await websiteBlocksTable.get(id); + if (!after) return; + const nextProps = after.props; + const spec = requireBlockSpec(before.type); + push({ + label: `${spec.label} ändern`, + undo: () => blocksStore.setBlockProps(id, prevProps), + redo: () => blocksStore.setBlockProps(id, nextProps), + }); + }, + + async deleteBlock(id) { + const snapshot = await websiteBlocksTable.get(id); + if (!snapshot) return; + const spec = requireBlockSpec(snapshot.type); + await blocksStore.deleteBlock(id); + push({ + label: `${spec.label} löschen`, + undo: async () => { + await blocksStore.restoreBlock(snapshot); + }, + redo: () => blocksStore.deleteBlock(id), + }); + }, + + async moveBlockUp(id) { + const before = await websiteBlocksTable.get(id); + if (!before) return; + const prevOrder = before.order; + await blocksStore.moveBlockUp(id); + const after = await websiteBlocksTable.get(id); + // Silent no-op at the boundary — don't record an entry. + if (!after || after.order === prevOrder) return; + const nextOrder = after.order; + push({ + label: 'Block verschieben', + undo: () => blocksStore.setBlockOrder(id, prevOrder), + redo: () => blocksStore.setBlockOrder(id, nextOrder), + }); + }, + + async moveBlockDown(id) { + const before = await websiteBlocksTable.get(id); + if (!before) return; + const prevOrder = before.order; + await blocksStore.moveBlockDown(id); + const after = await websiteBlocksTable.get(id); + if (!after || after.order === prevOrder) return; + const nextOrder = after.order; + push({ + label: 'Block verschieben', + undo: () => blocksStore.setBlockOrder(id, prevOrder), + redo: () => blocksStore.setBlockOrder(id, nextOrder), + }); + }, + }; +} + +export function setEditorHistoryContext(history: EditorHistory): void { + setContext(HISTORY_CONTEXT_KEY, history); +} + +export function getEditorHistoryContext(): EditorHistory | null { + return getContext(HISTORY_CONTEXT_KEY) ?? null; +} diff --git a/apps/mana/apps/web/src/lib/modules/website/stores/blocks.svelte.ts b/apps/mana/apps/web/src/lib/modules/website/stores/blocks.svelte.ts index d6333f2a4..8abec90d1 100644 --- a/apps/mana/apps/web/src/lib/modules/website/stores/blocks.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/website/stores/blocks.svelte.ts @@ -159,6 +159,89 @@ export const blocksStore = { updatedAt: now, }); await touchSiteForPage(existing.pageId); + + emitDomainEvent('WebsiteBlockUpdated', 'website', 'websiteBlocks', id, { + blockId: id, + pageId: existing.pageId, + fields: ['order'], + }); + }, + + /** + * Restore a soft-deleted block to the exact snapshot captured before + * the delete. Used by undo/redo — writes the record back wholesale so + * child blocks (whose parentBlockId points at this id) reattach as if + * nothing happened. Emits `WebsiteBlockAdded` because the outcome for + * the event log is the same as a fresh insert. + */ + async restoreBlock(snapshot: LocalWebsiteBlock) { + const now = new Date().toISOString(); + const restored: LocalWebsiteBlock = { + ...snapshot, + deletedAt: undefined, + updatedAt: now, + }; + await websiteBlocksTable.put(restored); + await touchSiteForPage(snapshot.pageId); + + emitDomainEvent('WebsiteBlockAdded', 'website', 'websiteBlocks', snapshot.id, { + blockId: snapshot.id, + pageId: snapshot.pageId, + type: snapshot.type, + }); + + return restored; + }, + + /** + * Overwrite a block's props without merging — redo/undo target that + * needs to bypass the merge-and-revalidate path of updateBlockProps. + * Props are still validated against the block's Zod schema. + */ + async setBlockProps(id: string, fullProps: unknown) { + const existing = await websiteBlocksTable.get(id); + if (!existing) throw new Error(`Block ${id} not found`); + + const validated = safeValidateBlockProps(existing.type, fullProps); + if (!validated.success) { + throw new InvalidBlockPropsError(existing.type, validated.error); + } + + const now = new Date().toISOString(); + await websiteBlocksTable.update(id, { + props: validated.data, + updatedAt: now, + }); + await touchSiteForPage(existing.pageId); + + emitDomainEvent('WebsiteBlockUpdated', 'website', 'websiteBlocks', id, { + blockId: id, + pageId: existing.pageId, + fields: ['props'], + }); + }, + + /** + * Set a block's order to an exact value — used by undo/redo of the + * fractional-index reorder path where we need the prior numeric order + * back, not an "insert between" operation. + */ + async setBlockOrder(id: string, order: number) { + const existing = await websiteBlocksTable.get(id); + if (!existing) return; + + const now = new Date().toISOString(); + await websiteBlocksTable.update(id, { + order, + updatedAt: now, + }); + await touchSiteForPage(existing.pageId); + + emitDomainEvent('WebsiteBlockUpdated', 'website', 'websiteBlocks', id, { + blockId: id, + pageId: existing.pageId, + fields: ['order'], + }); }, /** diff --git a/apps/mana/apps/web/src/lib/modules/website/views/EditorView.svelte b/apps/mana/apps/web/src/lib/modules/website/views/EditorView.svelte index 415c7cb50..462de5d49 100644 --- a/apps/mana/apps/web/src/lib/modules/website/views/EditorView.svelte +++ b/apps/mana/apps/web/src/lib/modules/website/views/EditorView.svelte @@ -8,13 +8,13 @@ pagesForSite, blocksForPage, } from '../queries'; - import { blocksStore } from '../stores/blocks.svelte'; import BlockRenderer from '../components/BlockRenderer.svelte'; import BlockInspector from '../components/BlockInspector.svelte'; import InsertPalette from '../components/InsertPalette.svelte'; import PageList from '../components/PageList.svelte'; import PublishBar from '../components/PublishBar.svelte'; import SiteSettingsDialog from '../components/SiteSettingsDialog.svelte'; + import { createEditorHistory, setEditorHistoryContext } from '../history.svelte'; interface Props { siteId: string; @@ -23,6 +23,9 @@ let props: Props = $props(); + const history = createEditorHistory(); + setEditorHistoryContext(history); + const sites = useAllSites(); const pages = useAllPages(); const blocks = useAllBlocks(); @@ -47,13 +50,16 @@ return pageBlocks.filter((b) => (b.parentBlockId ?? null) === parentId); }); - // Clear selection when switching page. + // Clear selection + history when switching page. History is scoped to + // a single page's editing session — undoing into a block that belongs + // to another page would be confusing and error-prone. $effect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions props.pageId; untrack(() => { selectedBlockId = null; activeTab = 'pages'; + history.clear(); }); }); @@ -69,16 +75,62 @@ }); async function addBlock(type: string) { - const block = await blocksStore.addBlock({ pageId: props.pageId, type }); + const block = await history.addBlock({ pageId: props.pageId, type }); selectedBlockId = block.id; } + + function isTextEditingTarget(el: EventTarget | null): boolean { + if (!(el instanceof HTMLElement)) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + return el.isContentEditable; + } + + function onKeyDown(e: KeyboardEvent) { + if (!(e.metaKey || e.ctrlKey)) return; + // Don't intercept undo/redo inside form fields — the browser's + // native text-undo is almost always what the user wants there. + if (isTextEditingTarget(e.target)) return; + + const k = e.key.toLowerCase(); + if (k === 'z' && !e.shiftKey) { + e.preventDefault(); + void history.undo(); + } else if ((k === 'z' && e.shiftKey) || k === 'y') { + e.preventDefault(); + void history.redo(); + } + } + +
{#if site} {/if} +
+ + +
+
{#if pageBlocks.length === 0} @@ -185,6 +237,40 @@ flex-direction: column; height: 100%; } + .wb-toolbar { + display: flex; + gap: 0.25rem; + padding: 0.35rem 0.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + background: rgb(12, 15, 20); + flex: 0 0 auto; + } + .wb-toolbar__btn { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.12); + color: inherit; + width: 1.75rem; + height: 1.75rem; + padding: 0; + font-size: 1rem; + line-height: 1; + border-radius: 0.375rem; + cursor: pointer; + opacity: 0.75; + display: inline-flex; + align-items: center; + justify-content: center; + } + .wb-toolbar__btn:hover:not(:disabled) { + background: rgba(99, 102, 241, 0.15); + border-color: rgba(99, 102, 241, 0.4); + opacity: 1; + } + .wb-toolbar__btn:disabled { + opacity: 0.25; + cursor: not-allowed; + } .wb-editor { display: grid; grid-template-columns: 1fr 18rem;