feat: add undo history
This commit is contained in:
1 parent
872414ef23
commit
f447982439
5 files changed
+405
-18
No files matched your search
+2
-1
@@ -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,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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in new issue
Block a user