mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
4f40fdafae
commit
81c0d8cfc0
4 changed files with 381 additions and 7 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
194
apps/mana/apps/web/src/lib/modules/website/history.svelte.ts
Normal file
194
apps/mana/apps/web/src/lib/modules/website/history.svelte.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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'],
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue