diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index 841adae..6e6803f 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -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. **Loading a document carries it too** (`App.svelte`'s `loadContent`, both for a new document and for an import): a `setContent` is one transaction that inserts the whole file, so without the meta an opened document arrives wholly marked as the reader's own insertion, underlined in the author colour. Both word processors record what is typed after an open, never the file. `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`. 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. +- **`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). A **replacement** — typing or pasting over a selection — goes the same way (`replacedRange`), the new content landing after the struck-out text it replaces; recorded as the insertion alone, its other half was simply dropped and a reject lost the original. 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. **Loading a document carries it too** (`App.svelte`'s `loadContent`, both for a new document and for an import): a `setContent` is one transaction that inserts the whole file, so without the meta an opened document arrives wholly marked as the reader's own insertion, underlined in the author colour. Both word processors record what is typed after an open, never the file. `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`. 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. - **`ruby.ts`** — a reading printed over its base (LibreOffice's Format ▸ Asian Phonetic Guide, Word's Phonetic Guide). An inline **atom** carrying both halves, because that is what both formats write: one element holding a base and a reading, edited as a unit (`RubyDialog.svelte`). Rendered as a real ``, so the browser sets it. Alignment and position are both products' defaults and are not offered. - **`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. diff --git a/src/lib/editor/extensions/trackChanges.ts b/src/lib/editor/extensions/trackChanges.ts index 8582053..da921ed 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 type { Node as PMNode, Slice } from '@tiptap/pm/model'; +import { Plugin, PluginKey, TextSelection, type EditorState, type Transaction } from '@tiptap/pm/state'; +import { ReplaceStep } from '@tiptap/pm/transform'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; // Recorded revisions, as both word processors keep them: an insertion is text marked as @@ -238,9 +239,14 @@ export const TrackChanges = Extension.create<{ recording: () => boolean; author: // what both word processors do. filterTransaction: (tr, state) => { if (!options.recording() || tr.getMeta(RECORDING)) return true; - if (!tr.docChanged || !isPureDelete(tr, state)) return true; + if (!tr.docChanged) return true; + // A replacement — typing or pasting over a selection — is a deletion and an + // insertion at once; without this its half of the text would just be dropped. + const swap = replacedRange(tr); + if (!isPureDelete(tr, state) && !swap) return true; const ranges: [number, number][] = []; - for (const step of tr.steps) { + if (swap) ranges.push([swap.from, swap.to]); + else for (const step of tr.steps) { const map = step.getMap(); map.forEach((oldStart, oldEnd) => { if (oldEnd > oldStart) ranges.push([oldStart, oldEnd]); }); } @@ -256,6 +262,16 @@ export const TrackChanges = Extension.create<{ recording: () => boolean; author: // The author's own unaccepted insertion goes for real. for (const own of [...ownInsertions(state, from, to)].reverse()) marked.delete(own[0], own[1]); } + if (swap) { + // The new content lands after the text it replaces, which stays struck out. + const at = marked.mapping.map(swap.to); + const before = marked.doc.content.size; + marked.replace(at, at, swap.slice); + const end = at + (marked.doc.content.size - before); + marked.addMark(at, end, state.schema.marks.insertion.create({ ...attrs, id: newRevisionId() })); + marked.setSelection(TextSelection.near(marked.doc.resolve(end))); + any = true; + } if (!any && !marked.docChanged) return true; queueMicrotask(() => this.editor.view.dispatch(marked.setMeta(RECORDING, true))); return false; @@ -274,6 +290,15 @@ function adjacentInsertion(state: EditorState, pos: number, author: string): Rec return { id: String(mark.attrs.id), author, date: String(mark.attrs.date) }; } +// The one step that replaces a range with new content (typing or pasting over a +// selection). Anything more involved is left to run as it is. +function replacedRange(tr: Transaction): { from: number; to: number; slice: Slice } | null { + if (tr.steps.length !== 1) return null; + const step = tr.steps[0]; + if (!(step instanceof ReplaceStep) || step.to <= step.from || step.slice.size === 0) return null; + return { from: step.from, to: step.to, slice: step.slice }; +} + // A transaction that only removes content (backspace, delete, cut) — a replacement // inserts as well, and its insertion half must still run. function isPureDelete(tr: Transaction, state: EditorState): boolean { diff --git a/tests/unit/track-changes-load.test.ts b/tests/unit/track-changes-load.test.ts index 3a515d7..9680448 100644 --- a/tests/unit/track-changes-load.test.ts +++ b/tests/unit/track-changes-load.test.ts @@ -37,10 +37,13 @@ describe('loading a document while recording', () => { editor.destroy(); }); - it('records a bare setContent, so the load has to say it is one', () => { + it('records a bare setContent, so the load has to say it is one', async () => { const editor = recordingEditor(); editor.commands.setContent('

a file full of text

'); - expect(revisions(editor.state.doc)).toHaveLength(1); + // A replacement: rejected and re-dispatched a microtask later as both halves — + // the document it replaced, struck out, and the new text. + await Promise.resolve(); + expect(revisions(editor.state.doc).map((r) => r.kind)).toEqual(['deletion', 'insertion']); editor.destroy(); }); }); diff --git a/tests/unit/track-changes-replace.test.ts b/tests/unit/track-changes-replace.test.ts new file mode 100644 index 0000000..a827bd9 --- /dev/null +++ b/tests/unit/track-changes-replace.test.ts @@ -0,0 +1,56 @@ +// Typing or pasting over a selection is a deletion and an insertion at once. Recording +// it as the insertion alone dropped the replaced text, so rejecting lost the original. +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 { TextSelection } from '@tiptap/pm/state'; +import { TrackChanges, Insertion, Deletion, revisions } from '../../src/lib/editor/extensions/trackChanges'; + +function editorOver(text: string) { + const el = document.createElement('div'); + document.body.appendChild(el); + return new Editor({ + element: el, + extensions: [Document, Paragraph, Text, Insertion, Deletion, + TrackChanges.configure({ recording: () => true, author: () => 'Ada' })], + content: `

${text}

`, + }); +} + +// Replace "quick" (positions 5–10 in "The quick fox") with "slow". +async function replaceQuick(editor: Editor) { + const { state, view } = editor; + view.dispatch(state.tr + .setSelection(TextSelection.create(state.doc, 5, 10)) + .replaceSelectionWith(state.schema.text('slow'), false)); + await Promise.resolve(); +} + +describe('recording a replacement', () => { + it('keeps the replaced text as a deletion beside the insertion', async () => { + const editor = editorOver('The quick fox'); + await replaceQuick(editor); + expect(editor.state.doc.textContent).toBe('The quickslow fox'); + expect(revisions(editor.state.doc).map((r) => [r.kind, r.text])) + .toEqual([['deletion', 'quick'], ['insertion', 'slow']]); + editor.destroy(); + }); + + it('restores the original on reject', async () => { + const editor = editorOver('The quick fox'); + await replaceQuick(editor); + editor.commands.rejectRevisions(true); + expect(editor.state.doc.textContent).toBe('The quick fox'); + editor.destroy(); + }); + + it('keeps only the new text on accept', async () => { + const editor = editorOver('The quick fox'); + await replaceQuick(editor); + editor.commands.acceptRevisions(true); + expect(editor.state.doc.textContent).toBe('The slow fox'); + editor.destroy(); + }); +});