diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f154fd..7eb0744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/App.svelte b/src/App.svelte index f06da73..fe7cce8 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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} (commentsOpen = false)} /> {/if} + {#if revisionsOpen} + (revisionsOpen = false)} /> + {/if} {#if findOpen && editor}
diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index 7bfc904..ba38fd0 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -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 diff --git a/src/lib/components/RevisionsPane.svelte b/src/lib/components/RevisionsPane.svelte new file mode 100644 index 0000000..a93d43e --- /dev/null +++ b/src/lib/components/RevisionsPane.svelte @@ -0,0 +1,150 @@ + + + + + diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index cad91b0..c8d97b4 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -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'} {:else if tab === 'review'} - + {:else if tab === 'view'} {:else if tab === 'tableDesign' || tab === 'tableLayout'} diff --git a/src/lib/components/ribbon/tabs/ReviewTab.svelte b/src/lib/components/ribbon/tabs/ReviewTab.svelte index b651761..ee0bc1a 100644 --- a/src/lib/components/ribbon/tabs/ReviewTab.svelte +++ b/src/lib/components/ribbon/tabs/ReviewTab.svelte @@ -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())} /> + onToggleRevisions?.()} + />
`. 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. diff --git a/src/lib/editor/extensions/trackChanges.ts b/src/lib/editor/extensions/trackChanges.ts index 382b409..8582053 100644 --- a/src/lib/editor/extensions/trackChanges.ts +++ b/src/lib/editor/extensions/trackChanges.ts @@ -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 { + const order = new Map(); + 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 { 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 diff --git a/src/lib/editor/shortcuts.ts b/src/lib/editor/shortcuts.ts index d8d18a0..8117121 100644 --- a/src/lib/editor/shortcuts.ts +++ b/src/lib/editor/shortcuts.ts @@ -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)), diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 88d31e2..180cc88 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -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', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 4df5d6c..8e1237e 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -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', diff --git a/src/styles/editor.css b/src/styles/editor.css index 58c0544..73e4e07 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -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 { diff --git a/tests/unit/revisions-pane.test.ts b/tests/unit/revisions-pane.test.ts new file mode 100644 index 0000000..dba9d89 --- /dev/null +++ b/tests/unit/revisions-pane.test.ts @@ -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(); + }); +});