From f447982439798978f36076be335f27fef5b18d26 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 10 Jun 2026 14:46:42 +0200 Subject: [PATCH] feat: add undo history --- CHANGELOG.md | 3 +- src/lib/editor/Editor.svelte | 10 +- src/lib/editor/HistoryButton.svelte | 228 ++++++++++++++++++++++++++++ src/lib/editor/Toolbar.svelte | 24 +-- src/lib/editor/historyLog.svelte.ts | 158 +++++++++++++++++++ 5 files changed, 405 insertions(+), 18 deletions(-) create mode 100644 src/lib/editor/HistoryButton.svelte create mode 100644 src/lib/editor/historyLog.svelte.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d39ce4..83630b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,10 +35,11 @@ - margins - page orientation - tables +- undo history +- ... ### Not yet implemented - ODT import / open existing file -- undo history - Images - Custom right-click context menu (formatting, cut/copy) - Multi-document management diff --git a/src/lib/editor/Editor.svelte b/src/lib/editor/Editor.svelte index 67d4e3c..ee44ee7 100644 --- a/src/lib/editor/Editor.svelte +++ b/src/lib/editor/Editor.svelte @@ -9,6 +9,7 @@ import { applyMarginVars, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins'; import { applyOrientationVars, type Orientation } from '../storage/pageOrientation'; import { FORCE_PAGE_RECALC, type TableBreakBand } from './pageBreaks'; + import { recordTransaction, resetHistoryLog } from './historyLog.svelte'; import '../../styles/editor.css'; const DEFAULT_EDITOR_FONT = 'Georgia'; // must match ToolbarExpanded.svelte @@ -301,6 +302,8 @@ } onMount(() => { + // Start with empty history lists — loaded content is not an undoable edit. + resetHistoryLog(); const saved = loadDocument(); editor = new Editor({ @@ -317,8 +320,12 @@ return new Slice(applyFontToFragment(slice.content, textStyleType, font), slice.openStart, slice.openEnd); }, }, - onTransaction: () => { + onTransaction: ({ editor: e, transaction }) => { tick++; + // Mirror this transaction into the labelled undo/redo log for the toolbar's + // history dropdowns. e.state is the POST-transaction state, so the history + // depths recordTransaction reads already reflect this transaction. + recordTransaction(e.state, transaction); // Covers both doc and selection changes (entering/leaving a table, // adding/removing rows or columns). scheduleTableUi(); @@ -359,6 +366,7 @@ cancelAnimationFrame(marginRecalcRaf); cancelAnimationFrame(tableUiRaf); editor?.destroy(); + resetHistoryLog(); element?.removeEventListener('pm-pagecount', onPageCount); editorContainer?.removeEventListener('scroll', onEditorScroll); }); diff --git a/src/lib/editor/HistoryButton.svelte b/src/lib/editor/HistoryButton.svelte new file mode 100644 index 0000000..b9cee05 --- /dev/null +++ b/src/lib/editor/HistoryButton.svelte @@ -0,0 +1,228 @@ + + +
+ + + + {#if open && displayed.length > 0} + +
+ + +
+ {/if} +
+ + diff --git a/src/lib/editor/Toolbar.svelte b/src/lib/editor/Toolbar.svelte index 064519b..08928ac 100644 --- a/src/lib/editor/Toolbar.svelte +++ b/src/lib/editor/Toolbar.svelte @@ -1,5 +1,6 @@
@@ -128,20 +127,13 @@
- - + +
+ +
+ +
+
{/if}
diff --git a/src/lib/editor/historyLog.svelte.ts b/src/lib/editor/historyLog.svelte.ts new file mode 100644 index 0000000..056ae13 --- /dev/null +++ b/src/lib/editor/historyLog.svelte.ts @@ -0,0 +1,158 @@ +// A labelled mirror of the undo/redo stacks, for the toolbar's history dropdowns. +// +// ProseMirror's history (prosemirror-history, via @tiptap/extension-history) stores no +// human-readable labels for its undo groups — only `undoDepth`/`redoDepth` counts. So we +// keep a parallel list of best-effort labels here, fed one transaction at a time from +// Editor.svelte's onTransaction. The list LENGTHS are reconciled to the authoritative +// depths on every transaction, so jumping N steps is always exact even when a label is +// generic. See HistoryButton.svelte for the consumer. +import type { Editor } from '@tiptap/core'; +import type { EditorState, Transaction } from '@tiptap/pm/state'; +import { undoDepth, redoDepth, isHistoryTransaction } from '@tiptap/pm/history'; +import { ReplaceStep, ReplaceAroundStep, AddMarkStep, RemoveMarkStep, AttrStep } from '@tiptap/pm/transform'; + +export interface HistoryEntry { + label: string; +} + +// index 0 = oldest, last element = most recent (the next thing an undo would revert). +// `redo` mirrors it: last element = the next thing a redo would re-apply. +export const historyLog = $state<{ undo: HistoryEntry[]; redo: HistoryEntry[] }>({ undo: [], redo: [] }); + +// Last-seen depths, so we can tell new-edit vs undo vs redo vs a merged (continued-typing) +// transaction apart by the direction the counts moved. +let prevUndo = 0; +let prevRedo = 0; + +export function resetHistoryLog(): void { + historyLog.undo = []; + historyLog.redo = []; + prevUndo = 0; + prevRedo = 0; +} + +// Called once per transaction with the POST-transaction state (onTransaction runs after +// state.apply, so the history depths already reflect this transaction). +export function recordTransaction(state: EditorState, tr: Transaction): void { + const nu = undoDepth(state) as number; + const nr = redoDepth(state) as number; + + if (isHistoryTransaction(tr)) { + // This transaction was dispatched BY the history plugin — i.e. an undo or a redo. + // (Reliable even in the common "undo once, then type" case, which by depth deltas + // alone is indistinguishable from a redo.) Direction = which way the depth moved. + if (nu > prevUndo) { + // Redo: the top redo entry moves back onto the undo stack. + const moved = historyLog.redo.pop(); + if (moved) historyLog.undo.push(moved); + } else if (nu < prevUndo) { + // Undo: the top undo entry moves onto the redo stack. + const moved = historyLog.undo.pop(); + if (moved) historyLog.redo.push(moved); + } + } else if (nu > prevUndo) { + // A fresh user edit created a new undo group; the redo branch is now discarded. + historyLog.undo.push({ label: describeTransaction(tr) }); + historyLog.redo = []; + } + // else: a normal transaction that didn't add a group — a selection-only change, continued + // typing merged into the current group, or a meta-only transaction (FORCE_PAGE_RECALC). + // Nothing to record. + + // Reconcile lengths to the authoritative depths. This keeps COUNTS exact across the + // edge cases the delta logic can't see precisely: the history cap (oldest group dropped), + // or a single transaction that creates/removes more than one group. + while (historyLog.undo.length < nu) historyLog.undo.unshift({ label: 'Edit' }); + while (historyLog.undo.length > nu) historyLog.undo.shift(); + while (historyLog.redo.length < nr) historyLog.redo.unshift({ label: 'Edit' }); + while (historyLog.redo.length > nr) historyLog.redo.shift(); + + prevUndo = nu; + prevRedo = nr; +} + +// Jump multiple steps by looping the single-step commands — each dispatch fires +// onTransaction, so the log moves one entry per step and stays in sync. +export function undoSteps(editor: Editor, n: number): void { + for (let i = 0; i < n; i++) editor.commands.undo(); + editor.commands.focus(); +} + +export function redoSteps(editor: Editor, n: number): void { + for (let i = 0; i < n; i++) editor.commands.redo(); + editor.commands.focus(); +} + +// --- Best-effort transaction labelling (Word/LibreOffice style) ------------------------- + +const MARK_LABELS: Record = { + bold: 'Bold', + italic: 'Italic', + underline: 'Underline', + strike: 'Strikethrough', + highlight: 'Highlight', + subscript: 'Subscript', + superscript: 'Superscript', +}; + +function describeMark(name: string, attrs: Record | undefined): string { + if (name === 'textStyle') { + // TextStyle carries several attrs; label by whichever this step actually set. + if (attrs?.color) return 'Text color'; + if (attrs?.fontSize) return 'Font size'; + if (attrs?.fontFamily) return 'Font'; + if (attrs?.fontWeight) return 'Bold'; + return 'Formatting'; + } + return MARK_LABELS[name] ?? 'Formatting'; +} + +function describeTransaction(tr: Transaction): string { + let structural: string | null = null; // table / list / heading / paragraph — wins + let insertedText = ''; + let sawText = false; + let sawDelete = false; + let markLabel: string | null = null; + let sawAttr = false; + + for (const step of tr.steps) { + if (step instanceof ReplaceStep || step instanceof ReplaceAroundStep) { + const { content, size } = step.slice; + if (size === 0) { + sawDelete = true; + continue; + } + content.nodesBetween(0, content.size, (node) => { + const name = node.type.name; + if (!structural) { + if (name === 'table') structural = 'Insert table'; + else if (name === 'bulletList') structural = 'Bullet list'; + else if (name === 'orderedList') structural = 'Numbered list'; + else if (name === 'heading') structural = `Heading ${node.attrs.level ?? ''}`.trim(); + else if (step instanceof ReplaceAroundStep && name === 'paragraph') structural = 'Paragraph'; + } + if (node.isText) { + sawText = true; + insertedText += node.text ?? ''; + } + return true; + }); + } else if (step instanceof AddMarkStep || step instanceof RemoveMarkStep) { + if (!markLabel) markLabel = describeMark(step.mark.type.name, step.mark.attrs); + } else if (step instanceof AttrStep) { + sawAttr = true; + } + } + + if (structural) return structural; + if (sawText) { + const trimmed = insertedText.trim(); + if (!trimmed) return 'Typing'; + const snippet = trimmed.slice(0, 20); + return `Typing: “${snippet}${trimmed.length > 20 ? '…' : ''}”`; + } + if (sawDelete) return 'Delete'; + if (markLabel) return markLabel; + if (sawAttr) return 'Formatting'; + return 'Edit'; +}