feat: a reviewing pane, and a colour per reviewer

Every recorded change in document order beside the comments pane: author,
date, the text, accept or reject in place. A change split by a paragraph
boundary is one row, so acceptRevision/rejectRevision take an id. The
author colour is a decoration, since a mark cannot know the document's
other authors; it nests inside the mark's span and carries the under/
strikethrough with it. shortcutHint no longer throws on an id nothing
binds, which heading 7-10 had just made reachable.
This commit is contained in:
Steffen Becker committed 2026-08-15 09:40:26 +02:00
1 parent 147034730f
commit 1c4dec49f3
13 files changed
+326 -13

No files matched your search

+1 -1
View File
@@ -111,6 +111,7 @@
- Right-click context menu for text: cut / copy / paste / paste without formatting, link insert-edit-remove, clear formatting — with the spelling suggestions merged in on top. Shift+right-click keeps the browser's own menu; images, text boxes and header/footer stay with their existing UI
- Spell check in English and German with squiggles and suggestions in the context menu (add / ignore word); selectable document language. Powered by Hunspell (WASM), so German compound words (Fußgänger, Krankenversicherung …) are recognised; dictionaries are lazy-loaded per language on demand
- Word / character count statistics (whole document and selection)
- Reviewing pane for tracked changes (Review ▸ Revisions), beside the comments one: every recorded change in document order — author, date, what was inserted or deleted — click to select the text, accept or reject in place. A change a paragraph boundary splits is one row and is applied whole. Each **author** draws in their own colour (LibreOffice's author palette, handed out in order of first appearance), so several reviewers stay apart on the page
- Navigator pane (F5, or View ▸ Navigator): the document's chapters as an outline, click one to jump to it and the caret's own chapter stays marked. Beside each, LibreOffice's four chapter operations — move up / down and promote / demote a level, each carrying everything under the heading with it. A move swaps with the adjacent chapter at the same level and never crosses a heading above it, so a subheading stays in its own chapter. Below the outline, the tables, pictures and bookmarks LibreOffice's Navigator also lists, as jump targets
**UI & theming**
@@ -149,7 +150,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14.
- Connectors that attach to other shapes, and freeform curves — a connector is an anchoring model rather than a shape, and a freeform needs a drawing tool. Rotated shape text (`w:bodyPr vert`) too. All still dropped on import with a warning
**Missing while writing**
- One colour for every tracked change, where both word processors give each author their own — and no reviewing pane listing the revisions the way the comments pane lists comments
- Hyphenation's zone and ladder count (`fo:hyphenation-ladder-count`, `w:hyphenationZone`) — CSS exposes neither
- Restarting the page numbering **per section** (only the document's own format and start value exist)
- A bibliography's citation style: the list is set the one way ("key: author, title, year"), where both word processors offer APA, MLA, Chicago and the rest, and numbered citations (`[1]`) instead of the source's short name
+7
View File
@@ -50,6 +50,7 @@
import AboutDialog from './lib/components/AboutDialog.svelte';
import DocPropertiesDialog from './lib/components/DocPropertiesDialog.svelte';
import CommentsPane from './lib/components/CommentsPane.svelte';
import RevisionsPane from './lib/components/RevisionsPane.svelte';
import NavigatorPane from './lib/components/NavigatorPane.svelte';
import { OPEN_COMMENT_EVENT } from './lib/editor/extensions/comment';
import AutoCorrectDialog from './lib/components/AutoCorrectDialog.svelte';
@@ -173,6 +174,7 @@
let lineNumbering: LineNumbering = $state(loadLineNumbering());
let autoCorrectOpen = $state(false);
let commentsOpen = $state(false);
let revisionsOpen = $state(false);
let navigatorOpen = $state(false);
// Width of the hidden mirror span (below), so the title input grows/shrinks
@@ -1045,6 +1047,8 @@
onNewComment={addComment}
{commentsOpen}
onToggleComments={() => (commentsOpen = !commentsOpen)}
{revisionsOpen}
onToggleRevisions={() => (revisionsOpen = !revisionsOpen)}
{navigatorOpen}
onToggleNavigator={() => (navigatorOpen = !navigatorOpen)}
/>
@@ -1353,6 +1357,9 @@
{#if commentsOpen}
<CommentsPane {editor} {tick} author={docProps.author} onClose={() => (commentsOpen = false)} />
{/if}
{#if revisionsOpen}
<RevisionsPane {editor} {tick} author={docProps.author} onClose={() => (revisionsOpen = false)} />
{/if}
</div>
{#if findOpen && editor}
<div class="find-bar-anchor" style="top: {toolbarRegionH + 8}px;">
+7
View File
@@ -74,6 +74,13 @@ range, edit / resolve / remove in place. `App.svelte` owns the New-comment promp
both the Review tab and the context menu (`OPEN_COMMENT_EVENT`) fire; the author comes
from the document properties.
## Revisions (`RevisionsPane.svelte`)
The reviewing pane both word processors list revisions in, docked beside `CommentsPane`
and built the same way: one row per change **id** (a paragraph boundary splits one change
into several ranges), in the author's own colour, click to select the text, accept or
reject in place through `acceptRevision`/`rejectRevision`. Opened from the Review tab.
## Navigator (`NavigatorPane.svelte`, `editor/extensions/outline.ts`)
LibreOffice's Navigator and Word's Navigation pane: the outline, click to jump, and the
+150
View File
@@ -0,0 +1,150 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { TextSelection } from '@tiptap/pm/state';
import { revisions, authorColorIndex, REVISION_AUTHOR_COLORS, type Revision } from '../editor/extensions/trackChanges';
import { t } from '../i18n/i18n.svelte';
// The reviewing pane both word processors list revisions in, beside the comments one:
// every recorded change in document order, click to jump, accept or reject in place.
let { editor, tick, author, onClose }: {
editor: Editor | null;
tick: number;
author: string;
onClose: () => void;
} = $props();
// One row per change, not per range: a paragraph boundary splits one change in two.
let list = $derived.by<Revision[]>(() => {
if (tick < 0 || !editor) return [];
const seen = new Set<string>();
return revisions(editor.state.doc).filter((r) => !seen.has(r.id) && seen.add(r.id));
});
let colors = $derived(authorColorIndex(list));
function colorOf(r: Revision): string {
return REVISION_AUTHOR_COLORS[colors.get(r.author) ?? 0];
}
function jumpTo(r: Revision) {
if (!editor) return;
const { state, view } = editor;
view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, r.from, r.to)).scrollIntoView());
view.focus();
}
// The stored ISO date, in the reader's locale — an imported file may carry any format.
function when(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
}
</script>
<aside class="revisions-pane" aria-label={t().revisions.pane}>
<header>
<span class="title">{t().revisions.pane}</span>
<span class="count">{list.length}</span>
<button class="close" onclick={onClose} aria-label={t().common.close} title={t().common.close}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2.5 2.5l7 7M9.5 2.5l-7 7" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</button>
</header>
{#if !list.length}
<p class="empty">{t().revisions.empty}</p>
{/if}
<ul>
{#each list as r (r.id)}
<li>
<button class="card" onclick={() => jumpTo(r)}>
<div class="meta">
<b style:color={colorOf(r)}>{r.author || author || t().revisions.unknownAuthor}</b>
<span>{when(r.date)}</span>
</div>
<div class="kind">{r.kind === 'insertion' ? t().revisions.inserted : t().revisions.deleted}</div>
<div class="quote" class:struck={r.kind === 'deletion'} style:color={colorOf(r)}>{r.text}</div>
</button>
<div class="actions">
<button onclick={() => editor?.chain().focus().acceptRevision(r.id).run()}>{t().revisions.accept}</button>
<button onclick={() => editor?.chain().focus().rejectRevision(r.id).run()}>{t().revisions.reject}</button>
</div>
</li>
{/each}
</ul>
</aside>
<style>
.revisions-pane {
display: flex;
flex-direction: column;
flex: 0 0 260px;
min-width: 0;
overflow-y: auto;
border-left: 1px solid var(--color-border);
background: var(--color-surface);
font-family: var(--font-sans);
font-size: 0.8rem;
}
header {
display: flex;
align-items: center;
gap: 6px;
position: sticky;
top: 0;
padding: 8px 10px;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.title { flex: 1; font-weight: 600; }
.count { color: var(--color-text-muted); }
.close, .actions button {
border: 1px solid transparent;
border-radius: var(--radius);
background: none;
color: var(--color-text-muted);
padding: 2px 6px;
font: inherit;
cursor: pointer;
}
.close:hover, .actions button:hover { background: var(--color-btn-hover); color: var(--color-text); }
.empty { padding: 12px 10px; color: var(--color-text-muted); }
ul { list-style: none; margin: 0; padding: 0; }
li {
padding: 8px 10px;
border-bottom: 1px solid var(--color-border);
}
.card {
display: block;
width: 100%;
border: none;
background: none;
color: inherit;
padding: 0;
text-align: left;
font: inherit;
cursor: pointer;
}
.meta { display: flex; justify-content: space-between; gap: 8px; color: var(--color-text-muted); }
.kind { color: var(--color-text-muted); }
.quote {
margin: 3px 0;
padding-left: 6px;
border-left: 2px solid currentColor;
/* Two lines of the changed text is enough to recognise it. */
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.quote.struck { text-decoration: line-through; }
.actions { display: flex; gap: 4px; justify-content: flex-end; }
</style>
+4 -2
View File
@@ -63,7 +63,7 @@
onSelectTheme,
docxBusy = false,
pdfBusy = false,
onNew, onOpen, onSave, onSaveAs, onSaveDocx, onSaveTemplate, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onAutoCorrect, onNewComment, commentsOpen = false, onToggleComments,
onNew, onOpen, onSave, onSaveAs, onSaveDocx, onSaveTemplate, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onAutoCorrect, onNewComment, commentsOpen = false, onToggleComments, revisionsOpen = false, onToggleRevisions,
navigatorOpen = false, onToggleNavigator,
recentFiles = [], onOpenRecent, onForgetRecent,
}: {
@@ -116,6 +116,8 @@
onNewComment?: () => void;
commentsOpen?: boolean;
onToggleComments?: () => void;
revisionsOpen?: boolean;
onToggleRevisions?: () => void;
navigatorOpen?: boolean;
onToggleNavigator?: () => void;
} = $props();
@@ -336,7 +338,7 @@
{:else if tab === 'references'}
<ReferencesTab {editor} {tick} {hfActive} {onNoteOptions} />
{:else if tab === 'review'}
<ReviewTab {editor} {tick} {documentLanguage} {onLanguage} {onAutoCorrect} {onNewComment} {commentsOpen} {onToggleComments} />
<ReviewTab {editor} {tick} {documentLanguage} {onLanguage} {onAutoCorrect} {onNewComment} {commentsOpen} {onToggleComments} {revisionsOpen} {onToggleRevisions} />
{:else if tab === 'view'}
<ViewTab bind:showRuler bind:showFormattingMarks {zoom} {onZoom} {onDebugDump} {navigatorOpen} {onToggleNavigator} />
{:else if tab === 'tableDesign' || tab === 'tableLayout'}
@@ -10,7 +10,7 @@
import { revisions } from '../../../editor/extensions/trackChanges';
import { t } from '../../../i18n/i18n.svelte';
let { editor, tick, documentLanguage, onLanguage, onAutoCorrect, onNewComment, commentsOpen = false, onToggleComments }: {
let { editor, tick, documentLanguage, onLanguage, onAutoCorrect, onNewComment, commentsOpen = false, onToggleComments, revisionsOpen = false, onToggleRevisions }: {
editor: Editor | null;
tick: number;
documentLanguage: DocumentLanguage;
@@ -19,6 +19,8 @@
onNewComment?: () => void;
commentsOpen?: boolean;
onToggleComments?: () => void;
revisionsOpen?: boolean;
onToggleRevisions?: () => void;
} = $props();
let hasSelection = $derived(tick >= 0 && !!editor && !editor.state.selection.empty);
@@ -111,6 +113,14 @@
active={recordChanges()}
onclick={() => setRecordChanges(!recordChanges())}
/>
<RibbonButton
variant="big"
icon="comment"
label={t().revisions.pane}
title={t().revisions.showPane}
active={revisionsOpen}
onclick={() => onToggleRevisions?.()}
/>
<!-- The one at the cursor over the whole document, so the pair reads as one column. -->
<div class="rb-col">
<RibbonButton
+1 -1
View File
@@ -30,7 +30,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`bulletList.ts`** / **`orderedList.ts`** — the TipTap lists plus a marker-type attr: `setBulletChar` (innermost list, `null` = default cycle) and the ordered numbering cycle (`utils/orderedListTypes.ts`, multilevel targets the outermost list).
- **`link.ts`** — hyperlink mark; owns `Mod-k`, which fires the event `Toolbar.svelte` listens for to open the link dialog. A `#name` href targets a bookmark: `Editor.svelte`'s `handleClick` scrolls to it instead of opening a tab. The `plain` attr (`data-plain`, `editor.css`) suppresses the editor's own blue: Word paints a link through its **Hyperlink character style**, which arrives as an ordinary mark, so every DOCX link is plain and one carrying no style is drawn like the text around it — while LibreOffice paints every ODF `text:a` styled or not (probed), so those never are. The DOCX exporter paints `#0563C1` only on a non-plain link, which is how a round trip tells its own links from a file's.
- **`comment.ts`** — an annotation on a range of text, a mark like `bookmark`: the whole comment (author, date, body, resolved) rides its attrs, so two runs of one comment merge back together and no second store is needed. `commentRanges(doc)` walks every range (an id split by a paragraph boundary yields several, which is why the commands walk all of them), `comments(doc)` keeps one per id for `CommentsPane.svelte`. A point comment — LibreOffice's, made with nothing selected, written with no `office:name` and no end element — attaches on import to the run before it, else the one after: the editor's comment is a mark and needs a range.
- **`trackChanges.ts`** — the `insertion`/`deletion` marks plus the recording plugin. `appendTransaction` marks whatever a transaction added, and `adjacentInsertion` reuses the id of the run just before it when the author matches, so a typed sentence is one change and not one per keystroke. A delete never reaches the document: `filterTransaction` rejects it and dispatches a mark pass instead (the author's own pending insertion is removed for real, as both word processors do). Every transaction of our own carries the `RECORDING` meta — accept/reject included, or its `tr.delete` would be read as a fresh user deletion and marked up again. `revisions(doc)` merges adjacent runs of one id, so an export writes one change per id.
- **`trackChanges.ts`** — the `insertion`/`deletion` marks plus the recording plugin. `appendTransaction` marks whatever a transaction added, and `adjacentInsertion` reuses the id of the run just before it when the author matches, so a typed sentence is one change and not one per keystroke. A delete never reaches the document: `filterTransaction` rejects it and dispatches a mark pass instead (the author's own pending insertion is removed for real, as both word processors do). Every transaction of our own carries the `RECORDING` meta — accept/reject included, or its `tr.delete` would be read as a fresh user deletion and marked up again. `revisions(doc)` merges adjacent runs of one id, so an export writes one change per id. The **author's colour** is a decoration, not the mark: a mark cannot know the document's other authors, so a plugin walks the revisions, hands out `REVISION_AUTHOR_COLORS` (LibreOffice's own author palette) in order of first appearance and paints `pm-rev-ins`/`-del` + `pm-rev-a<n>`. That span nests *inside* the mark's, so it carries the underline/strikethrough too — drawn by the ancestor it would take the fallback colour. Cached on the doc; the walk is the whole document and `decorations` runs on every view update. `acceptRevision(id)`/`rejectRevision(id)` apply every range of one change, which is what `RevisionsPane.svelte` lists as one row.
- **`caption.ts`** — a caption is an ordinary paragraph in the `Caption` style (LibreOffice's, probed: 10pt italic, 0.212cm above and below) holding one `sequenceField` atom, its running number. `sequenceFields(doc)` counts one counter per category in document order; the cached `number` attr is what an export writes, and an `appendTransaction` (off the undo stack) plus an `onCreate` pass keep it true — a file's own cached numbers are unreadable wherever the format is not arabic. The counter's name differs per format: ODF `Illustration`/`Table`, Word `Figure`/`Table`, and `ODF_SEQ_CATEGORY` reads both back. The caption paragraph is **boxed to the frame it captions** (`captionPlacement`), so it stands under the picture instead of at the margin: LibreOffice frames picture and caption together, and a loose paragraph carries that over as `indent`/`indentRight` — the frame's own span in the column, measured (`frameSpanCm`), since that span comes from the wrap, the offset and the column, not from one attr. The indent is also what lifts the caption clear of a **side-wrapped** frame: a line with no room beside a float drops below it. It is centred in that box only under a frame that is itself centred; a block with no frame at all has only its own alignment to pass on. `getFrameDebug` reports the same two spans for the debug dump. `CaptionDialog.svelte` (References tab) owns the insert.
- **`bookmark.ts`** / **`crossReference.ts`** — a named range of text (a mark) and the live references to it (`REF`/`PAGEREF`). `ToolbarExpanded.svelte` owns both dialogs, as it does the link one.
- **`searchReplace.ts`** — Find & Replace: decorations highlight all matches, commands navigate/replace. Touches the document only for actual replacements. `useRegex` takes the term as a pattern instead of escaping it (a whole-word search then wraps it in a non-capturing group first, so the `\b` guards bound the alternation); each match keeps its captures so `expandGroups` can fill `$1`…`$9`/`$&` in the replacement.
+50
View File
@@ -1,6 +1,7 @@
import { Extension, Mark, mergeAttributes } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
// Recorded revisions, as both word processors keep them: an insertion is text marked as
// added, a deletion is text still in the document but marked as removed — nothing is
@@ -89,6 +90,21 @@ export function revisions(doc: PMNode): Revision[] {
return out;
}
// One colour per author, as both word processors draw revisions: LibreOffice's own author
// palette, handed out in order of first appearance. Beyond nine, authors share again.
export const REVISION_AUTHOR_COLORS = [
'#c69200', '#0646a2', '#579d1c', '#692b9d', '#c5000b', '#008080', '#8c8400', '#35556b', '#d17600',
];
/** Author → palette index, in the order the document's revisions first name them. */
export function authorColorIndex(list: Revision[]): Map<string, number> {
const order = new Map<string, number>();
for (const r of list) {
if (!order.has(r.author)) order.set(r.author, order.size % REVISION_AUTHOR_COLORS.length);
}
return order;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
trackChanges: {
@@ -96,6 +112,10 @@ declare module '@tiptap/core' {
acceptRevisions: (all?: boolean) => ReturnType;
/** Reject the change under the cursor, or every one when `all`. */
rejectRevisions: (all?: boolean) => ReturnType;
/** Accept every range of one change, wherever the reviewing pane lists it. */
acceptRevision: (id: string) => ReturnType;
/** Reject every range of one change. */
rejectRevision: (id: string) => ReturnType;
};
}
}
@@ -112,6 +132,15 @@ function targets(state: EditorState, all: boolean): Revision[] {
// Applying a change is one pass from the end, so earlier positions stay valid. The
// RECORDING meta keeps the plugin's hands off it: without it the delete below is read
// as a fresh user deletion and marked up again instead of running.
// One change by id — every range of it, which is what a paragraph boundary splits a
// change into and what the pane lists as a single revision.
function byId(state: EditorState, tr: Transaction, dispatch: unknown, id: string, accept: boolean): boolean {
const list = revisions(state.doc).filter((r) => r.id === id);
if (!list.length) return false;
if (dispatch) apply(state, tr, list, accept);
return true;
}
function apply(state: EditorState, tr: Transaction, list: Revision[], accept: boolean): void {
const drop = accept ? 'deletion' : 'insertion';
for (const r of [...list].sort((a, b) => b.from - a.from)) {
@@ -142,12 +171,33 @@ export const TrackChanges = Extension.create<{ recording: () => boolean; author:
if (dispatch) apply(state, tr, list, false);
return true;
},
acceptRevision: (id) => ({ state, tr, dispatch }) => byId(state, tr, dispatch, id, true),
rejectRevision: (id) => ({ state, tr, dispatch }) => byId(state, tr, dispatch, id, false),
};
},
addProseMirrorPlugins() {
const options = this.options;
// The mark cannot know the document's other authors, so the colour is a decoration:
// one class per palette slot, keyed by where the author first appears. Cached on the
// doc — the walk is the whole document, and this runs on every view update.
let cache: { doc: PMNode; set: DecorationSet } | null = null;
return [
new Plugin({
props: {
decorations(state) {
if (cache?.doc === state.doc) return cache.set;
const list = revisions(state.doc);
const order = authorColorIndex(list);
const set = DecorationSet.create(state.doc, list.map((r) =>
Decoration.inline(r.from, r.to, {
class: `pm-rev-${r.kind === 'insertion' ? 'ins' : 'del'} pm-rev-a${order.get(r.author) ?? 0}`,
})));
cache = { doc: state.doc, set };
return set;
},
},
}),
new Plugin({
key: RECORDING,
// Typing is intercepted here rather than by a keymap: every path that inserts
+4 -1
View File
@@ -97,7 +97,10 @@ export function matchesEvent(e: KeyboardEvent, combo: string): boolean {
// Tooltip hint: 'Mod-Alt-1' → 'Strg+Alt+1' (or '⌘+⌥+1' on Mac).
export function shortcutHint(id: ShortcutId): string {
const parts = DEFAULT_SHORTCUTS[id].split(/-(?!$)/);
// Callers build ids from a level or a style name, so an unbound one is ordinary.
const combo0 = DEFAULT_SHORTCUTS[id];
if (!combo0) return '';
const parts = combo0.split(/-(?!$)/);
const key = parts.pop() ?? '';
const combo = [
...parts.map((p) => (p === 'Mod' ? 'Ctrl' : p)),
+6
View File
@@ -618,6 +618,12 @@ const de: Messages = {
rejectHint: 'Die Änderung an der Einfügemarke verwerfen',
acceptAll: 'Alle annehmen',
rejectAll: 'Alle verwerfen',
pane: 'Änderungen',
showPane: 'Liste der aufgezeichneten Änderungen anzeigen',
empty: 'Keine aufgezeichneten Änderungen.',
unknownAuthor: 'Unbekannter Autor',
inserted: 'Eingefügt',
deleted: 'Gelöscht',
},
notesDialog: {
title: 'Fuß- und Endnoten',
+6
View File
@@ -622,6 +622,12 @@ const en = {
rejectHint: 'Reject the change at the cursor',
acceptAll: 'Accept all',
rejectAll: 'Reject all',
pane: 'Revisions',
showPane: 'Show the list of recorded changes',
empty: 'No recorded changes.',
unknownAuthor: 'Unknown author',
inserted: 'Inserted',
deleted: 'Deleted',
},
notesDialog: {
title: 'Footnotes and Endnotes',
+20 -7
View File
@@ -999,15 +999,28 @@
border-radius: 2px;
}
.tiptap .pm-insertion {
color: #b5179e;
text-decoration: underline;
/* A recorded revision. The mark only tints, so a DOM built without the decoration plugin
still shows one; the line comes from the decoration span ProseMirror nests INSIDE the
mark's, or an ancestor would draw it in the fallback colour instead of the author's. */
.tiptap .pm-insertion,
.tiptap .pm-deletion {
color: #c69200;
}
.tiptap .pm-deletion {
color: #b5179e;
text-decoration: line-through;
}
.tiptap .pm-rev-ins { text-decoration: underline; }
.tiptap .pm-rev-del { text-decoration: line-through; }
/* LibreOffice's author palette, handed out in order of first appearance (trackChanges.ts),
so several reviewers stay apart the way both word processors draw them. */
.tiptap .pm-rev-a0 { color: #c69200; }
.tiptap .pm-rev-a1 { color: #0646a2; }
.tiptap .pm-rev-a2 { color: #579d1c; }
.tiptap .pm-rev-a3 { color: #692b9d; }
.tiptap .pm-rev-a4 { color: #c5000b; }
.tiptap .pm-rev-a5 { color: #008080; }
.tiptap .pm-rev-a6 { color: #8c8400; }
.tiptap .pm-rev-a7 { color: #35556b; }
.tiptap .pm-rev-a8 { color: #d17600; }
/* Print styles: proper A4 pagination */
@media print {
+59
View File
@@ -0,0 +1,59 @@
// What the reviewing pane needs: a colour per author, in order of first appearance, and
// accept/reject by change id — a change split by a paragraph boundary is one row there
// and must be applied whole. Drives a real editor with just the track-changes extension.
import { describe, it, expect } from 'vitest';
import { Editor } from '@tiptap/core';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import { TrackChanges, Insertion, Deletion, revisions, authorColorIndex, REVISION_AUTHOR_COLORS } from '../../src/lib/editor/extensions/trackChanges';
type N = any;
const R = (kind: string, id: string, author: string, text: string): N =>
({ type: 'text', text, marks: [{ type: kind, attrs: { id, author, date: '2026-08-14T10:00:00.000Z' } }] });
function makeEditor(content: N) {
const el = document.createElement('div');
document.body.appendChild(el);
return new Editor({ element: el, extensions: [Document, Paragraph, Text, Insertion, Deletion, TrackChanges], content });
}
// One change (r1) split over two paragraphs, plus a second author's deletion.
const doc: N = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'a ' }, R('insertion', 'r1', 'Ada', 'one')] },
{ type: 'paragraph', content: [R('insertion', 'r1', 'Ada', 'two'), { type: 'text', text: ' b ' }, R('deletion', 'r2', 'Grace', 'gone')] },
],
};
describe('revisions for the reviewing pane', () => {
it('gives each author its own palette slot, in order of appearance', () => {
const editor = makeEditor(doc);
const order = authorColorIndex(revisions(editor.state.doc));
expect(order.get('Ada')).toBe(0);
expect(order.get('Grace')).toBe(1);
expect(REVISION_AUTHOR_COLORS[0]).not.toBe(REVISION_AUTHOR_COLORS[1]);
editor.destroy();
});
it('accepts every range of one change and leaves the others', () => {
const editor = makeEditor(doc);
editor.commands.acceptRevision('r1');
const left = revisions(editor.state.doc);
expect(left.map((r) => r.id)).toEqual(['r2']);
expect(editor.state.doc.textContent).toBe('a onetwo b gone');
editor.destroy();
});
it('rejects a change by id: an insertion goes, a deletion comes back', () => {
const editor = makeEditor(doc);
editor.commands.rejectRevision('r1');
expect(editor.state.doc.textContent).toBe('a b gone');
editor.commands.acceptRevision('r2');
expect(editor.state.doc.textContent).toBe('a b ');
expect(revisions(editor.state.doc)).toHaveLength(0);
editor.destroy();
});
});