fix: an opened document is nobody's tracked insertion

With revision recording on, setContent inserted the whole file in one
transaction, so the recorder marked every run as the reader's own
insertion — the document then rendered underlined in the author colour
(#c69200) from the first character. Measured: 19 843 of 19 843 characters
on an 11-page import, 436 revision spans.

Both loads (new document, import) now carry the RECORDING meta; what is
typed after the open is still recorded, as in both word processors.
This commit is contained in:
Steffen Becker committed 2026-08-15 16:42:28 +02:00
1 parent 41ab0ef06c
commit c771193c83
3 files changed
+58 -4

No files matched your search

+11 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, tick as domUpdated } from 'svelte';
import { cubicOut } from 'svelte/easing';
import type { Editor } from '@tiptap/core';
import type { Content, Editor } from '@tiptap/core';
import { EditorState } from '@tiptap/pm/state';
import EditorComponent from './lib/components/Editor.svelte';
import Toolbar from './lib/components/Toolbar.svelte';
@@ -16,6 +16,7 @@
import { importDocx } from './lib/import/docx';
import { convertUnsupportedImages } from './lib/import/imageFormats';
import { getPageBreakDebug } from './lib/editor/extensions/pageBreaks';
import { RECORDING } from './lib/editor/extensions/trackChanges';
import { getColumnsFlowDebug } from './lib/editor/extensions/columnsFlow';
import { getTextBoxDebug } from './lib/editor/extensions/textBox';
import { getTableCellDebug } from './lib/editor/extensions/tableCellAlign';
@@ -510,10 +511,17 @@
tick++;
}
// Putting a document in front of the reader is not an edit: while revisions are
// recorded, the RECORDING meta keeps the whole file from arriving as this author's
// insertion. Both word processors record what is typed after an open, not the file.
function loadContent(content: Content): void {
editor?.chain().setMeta(RECORDING, true).setContent(content).run();
}
function handleNew() {
if (!editor) return;
if (isDocNonEmpty() && !confirm(t().dialogs.confirmNew)) return;
editor.commands.setContent('<p></p>'); // onUpdate fires → autosave
loadContent('<p></p>'); // onUpdate fires → autosave
documentEpoch++;
resetHistory();
// Reset everything to defaults; the $effects persist these.
@@ -586,7 +594,7 @@
await registerEmbeddedFonts(result.fonts);
void saveEmbeddedFonts(result.fonts);
editor.commands.setContent(result.content); // onUpdate fires → autosave
loadContent(result.content); // onUpdate fires → autosave
documentEpoch++;
resetHistory();
// Adopt the opened file's name as the document name (drives the save filename).
+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. 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.
- **`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<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.
- **`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 `<ruby>`, 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.
+46
View File
@@ -0,0 +1,46 @@
// Opening a document while revisions are recorded: the file itself is not this author's
// insertion, only what is typed after it. App.svelte marks the load with the RECORDING
// meta; without it the whole document arrives underlined in the author's colour.
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, RECORDING, revisions } from '../../src/lib/editor/extensions/trackChanges';
function recordingEditor() {
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: '<p>old</p>',
});
}
describe('loading a document while recording', () => {
it('leaves the loaded document unmarked', () => {
const editor = recordingEditor();
editor.chain().setMeta(RECORDING, true).setContent('<p>a file full of text</p>').run();
expect(revisions(editor.state.doc)).toHaveLength(0);
editor.destroy();
});
it('still records what is typed into it', () => {
const editor = recordingEditor();
editor.chain().setMeta(RECORDING, true).setContent('<p>a file</p>').run();
editor.commands.insertContentAt(7, 'typed');
const list = revisions(editor.state.doc);
expect(list).toHaveLength(1);
expect(editor.state.doc.textContent.slice(list[0].from - 1, list[0].to - 1)).toBe('typed');
editor.destroy();
});
it('records a bare setContent, so the load has to say it is one', () => {
const editor = recordingEditor();
editor.commands.setContent('<p>a file full of text</p>');
expect(revisions(editor.state.doc)).toHaveLength(1);
editor.destroy();
});
});