feat: add undo history

This commit is contained in:
Steffen Becker committed 2026-06-10 14:46:42 +02:00
1 parent 872414ef23
commit f447982439
5 files changed
+405 -18

No files matched your search

+2 -1
View File
@@ -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
+9 -1
View File
@@ -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);
});
+228
View File
@@ -0,0 +1,228 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { historyLog, undoSteps, redoSteps } from './historyLog.svelte';
let { editor, tick, direction }: { editor: Editor | null; tick: number; direction: 'undo' | 'redo' } = $props();
// The matching stack, most-recent first (so the top row is the next thing reverted).
let entries = $derived(direction === 'undo' ? historyLog.undo : historyLog.redo);
let displayed = $derived([...entries].reverse());
// Re-evaluates on every transaction via `tick` (editor.state isn't reactive).
let canRun = $derived(
tick >= 0 && (direction === 'undo' ? !!editor?.can().undo() : !!editor?.can().redo()),
);
let verb = $derived(direction === 'undo' ? 'Undo' : 'Redo');
let open = $state(false);
let hoverIndex = $state(-1);
function toggle() {
open = !open;
hoverIndex = -1;
}
function close() {
open = false;
hoverIndex = -1;
}
function runSingle() {
if (!editor) return;
if (direction === 'undo') editor.chain().focus().undo().run();
else editor.chain().focus().redo().run();
}
// Display index i (0 = most recent) ⇒ jump i+1 real steps.
function jump(i: number) {
if (!editor) return;
if (direction === 'undo') undoSteps(editor, i + 1);
else redoSteps(editor, i + 1);
close();
}
// Close if the stack drains out from under an open menu.
$effect(() => {
if (open && displayed.length === 0) close();
});
// Close on outside-click or Escape (mirrors TablePicker's clickOutside action).
function popover(node: HTMLElement) {
function onDown(e: MouseEvent) {
if (!node.contains(e.target as Node)) close();
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') close();
}
window.addEventListener('mousedown', onDown);
window.addEventListener('keydown', onKey);
return {
destroy() {
window.removeEventListener('mousedown', onDown);
window.removeEventListener('keydown', onKey);
},
};
}
</script>
<div class="history-split" use:popover>
<button
class="history-main"
onclick={runSingle}
disabled={!canRun}
title={direction === 'undo' ? 'Undo (Ctrl+Z)' : 'Redo (Ctrl+Shift+Z)'}
>
{direction === 'undo' ? '↩' : '↪'}
</button>
<button
class="history-caret"
onclick={toggle}
disabled={displayed.length === 0}
aria-haspopup="menu"
aria-expanded={open}
title={`${verb} history`}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 2.5 L4 5.5 L7 2.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
{#if open && displayed.length > 0}
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<div class="history-dropdown">
<div class="history-list" role="menu" tabindex="-1" onmouseleave={() => (hoverIndex = -1)}>
{#each displayed as entry, i}
<button
class="history-item"
class:hot={i <= hoverIndex}
role="menuitem"
onmouseover={() => (hoverIndex = i)}
onfocus={() => (hoverIndex = i)}
onclick={() => jump(i)}
>
<span class="history-arrow">{direction === 'undo' ? '↩' : '↪'}</span>
<span class="history-label">{entry.label}</span>
</button>
{/each}
</div>
<div class="history-footer">
{#if hoverIndex >= 0}
{verb} {hoverIndex + 1} action{hoverIndex > 0 ? 's' : ''}
{:else}
{displayed.length} action{displayed.length !== 1 ? 's' : ''}
{/if}
</div>
</div>
{/if}
</div>
<style>
.history-split {
position: relative;
display: inline-flex;
align-items: stretch;
}
.history-main,
.history-caret {
display: inline-flex;
align-items: center;
justify-content: center;
height: 2rem;
border: none;
background: transparent;
color: var(--color-text);
font-size: 0.85rem;
font-family: var(--font-sans);
cursor: pointer;
transition: background 0.15s;
}
.history-main {
min-width: 2rem;
padding: 0 0.4rem;
border-radius: var(--radius) 0 0 var(--radius);
}
.history-caret {
min-width: 1.1rem;
padding: 0 0.2rem;
border-radius: 0 var(--radius) var(--radius) 0;
}
.history-main:hover:not(:disabled),
.history-caret:hover:not(:disabled),
.history-caret[aria-expanded='true'] {
background: var(--color-btn-hover);
}
.history-main:disabled,
.history-caret:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.history-dropdown {
position: absolute;
top: calc(100% + 3px);
left: 0;
min-width: 190px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: 200;
overflow: hidden;
}
.history-list {
max-height: 320px;
overflow-y: auto;
padding: 4px;
}
.history-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 0.35rem 0.5rem;
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
font-size: 0.82rem;
font-family: var(--font-sans);
text-align: left;
cursor: pointer;
}
/* Cumulative highlight: every row up to (and including) the hovered one. */
.history-item.hot {
background: var(--color-btn-hover);
}
.history-arrow {
flex: none;
width: 1rem;
text-align: center;
color: var(--color-text-muted);
}
.history-label {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-footer {
padding: 0.35rem 0.6rem;
border-top: 1px solid var(--color-border);
font-size: 0.72rem;
font-family: var(--font-sans);
color: var(--color-text-muted);
user-select: none;
}
</style>
+8 -16
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import HistoryButton from './HistoryButton.svelte';
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
@@ -17,8 +18,6 @@
let isStrike = $derived(tick >= 0 && !!editor?.isActive('strike'));
let isBulletList = $derived(tick >= 0 && !!editor?.isActive('bulletList'));
let isOrderedList= $derived(tick >= 0 && !!editor?.isActive('orderedList'));
let canUndo = $derived(tick >= 0 && !!editor?.can().undo());
let canRedo = $derived(tick >= 0 && !!editor?.can().redo());
</script>
<div class="toolbar">
@@ -128,20 +127,13 @@
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<button
onclick={() => editor?.chain().focus().undo().run()}
disabled={!canUndo}
title="Undo (Ctrl+Z)"
>
↩
</button>
<button
onclick={() => editor?.chain().focus().redo().run()}
disabled={!canRedo}
title="Redo (Ctrl+Shift+Z)"
>
↪
</button>
<HistoryButton {editor} {tick} direction="undo" />
</div>
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<HistoryButton {editor} {tick} direction="redo" />
</div>
{/if}
</div>
+158
View File
@@ -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<string, string> = {
bold: 'Bold',
italic: 'Italic',
underline: 'Underline',
strike: 'Strikethrough',
highlight: 'Highlight',
subscript: 'Subscript',
superscript: 'Superscript',
};
function describeMark(name: string, attrs: Record<string, unknown> | 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';
}