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:
1 parent
147034730f
commit
1c4dec49f3
13 files changed
+326
-13
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user