feat(website): session-scoped undo/redo for the editor

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 23:21:42 +02:00
parent 4f40fdafae
commit 81c0d8cfc0
4 changed files with 381 additions and 7 deletions

View file

@ -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<string, unknown>);
const p = patch as Record<string, unknown>;
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<unknown> {

View file

@ -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<void>;
redo: () => Promise<void>;
}
export interface EditorHistory {
readonly canUndo: boolean;
readonly canRedo: boolean;
readonly undoLabel: string | null;
readonly redoLabel: string | null;
clear(): void;
undo(): Promise<void>;
redo(): Promise<void>;
addBlock(input: AddBlockInput): Promise<LocalWebsiteBlock>;
updateBlockProps(id: string, patch: Record<string, unknown>): Promise<void>;
deleteBlock(id: string): Promise<void>;
moveBlockUp(id: string): Promise<void>;
moveBlockDown(id: string): Promise<void>;
}
const HISTORY_CONTEXT_KEY = Symbol('website-editor-history');
export function createEditorHistory(limit = 100): EditorHistory {
let undoStack = $state<HistoryEntry[]>([]);
let redoStack = $state<HistoryEntry[]>([]);
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<EditorHistory | null>(HISTORY_CONTEXT_KEY) ?? null;
}

View file

@ -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'],
});
},
/**

View file

@ -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();
}
}
</script>
<svelte:window onkeydown={onKeyDown} />
<div class="wb-editor-layout">
{#if site}
<PublishBar {site} />
{/if}
<div class="wb-toolbar">
<button
class="wb-toolbar__btn"
onclick={() => history.undo()}
disabled={!history.canUndo}
title={history.undoLabel ? `Rückgängig: ${history.undoLabel}` : 'Rückgängig (⌘Z)'}
aria-label="Rückgängig"
>
</button>
<button
class="wb-toolbar__btn"
onclick={() => history.redo()}
disabled={!history.canRedo}
title={history.redoLabel ? `Wiederholen: ${history.redoLabel}` : 'Wiederholen (⌘⇧Z)'}
aria-label="Wiederholen"
>
</button>
</div>
<div class="wb-editor">
<main class="wb-editor__center">
{#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;