feat: the editor records, shows and applies tracked changes

Two marks carry a revision with its author and date: an insertion is
marked text, a deletion is text kept and struck through. Consecutive
typing joins one change; accept/reject work at the cursor or on all.

ODF splits the pair — text:change-start/-end around an insertion, a
text:change where a deletion was, its text in the tracked-changes
registry. DOCX uses w:ins/w:del with w:delText. Both round-trip, and
accepting every change gives the document an unrecorded edit would.
This commit is contained in:
Steffen Becker committed 2026-08-14 07:48:10 +02:00
1 parent da438a7655
commit 5ee4eec5d7
18 files changed
+646 -16

No files matched your search

+3 -1
View File
@@ -28,6 +28,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
- **Mirror margins** (`applyMirrorMarginsDocx`, `export/docx.ts`): the `docx` package writes only the per-section `w:pgMar`, so Word's document-level `w:mirrorMargins` goes into `word/settings.xml` in the same post-pack pass; ODF's flag rides `rewriteStylesXml`. Both formats already hold left/right as the inner/outer pair, so only the flag has to travel.
- **Bookmarks & cross-references** (`replaceBookmarks`/`applyBookmarks`): the `BMS`/`BME`/`XRF` sentinels are spliced into the **run text** rather than emitted by a run builder, so they ride every odf-kit path (cells, custom paragraphs, lists) with no per-emitter change; one post-pass rewrites them to `<text:bookmark-start/>`/`<text:bookmark-end/>`/`<text:bookmark-ref>`. DOCX needs no pass at all — the `docx` package has `Bookmark`, `SimpleField` (`REF`/`PAGEREF`) and `InternalHyperlink`. See `docs/architecture/formatting.md`.
- **Captions** (`replaceSequenceFields`/`applySequenceFields`): a `SEQ` sentinel becomes a `<text:sequence>` carrying LibreOffice's own `text:formula="ooow:<name>+1"` (what makes its reader recount on load) plus a `<text:sequence-decl>` per counter used; DOCX gets a `SimpleField` `SEQ Figure \* ARABIC`. Both keep the editor's resolved rank as the field's cached result.
- **Tracked changes** (`replaceRevisions`/`applyRevisions`): ODF splits the pair. An insertion keeps its text inline and is bracketed by `TCI` sentinels → `<text:change-start/>`/`<text:change-end/>`; a deletion's text is **cut out of the run** and left as one `TCD` → `<text:change/>`, the text itself going into the `<text:tracked-changes>` registry `applyRevisions` builds right after `<office:text>` (a `<text:deletion>` holds the deleted paragraphs, a `<text:insertion>` only its `<office:change-info>`). The registry's `<dc:creator>`/`<dc:date>` need `ensureDcNamespace` — odf-kit declares no `dc:` prefix in content.xml. DOCX needs no string pass: `InsertedTextRun`/`DeletedTextRun` from the `docx` package emit `w:ins`/`w:del` + `w:delText`, with `docRevisionId` numbering them per document as it does comments.
- **`applyListItemStyles`** — per-item alignment/spacing/line-height for top-level lists (odf-kit's ListBuilder has no per-item options) via minted automatic styles.
- **`applyListIndents`** — a top-level list's whole-list `indent` (cm) added to its `L#` list-style per-level `fo:margin-left`/`text:list-tab-stop-position` (label-alignment mode ignores the paragraph margin, so the shift must live in the list style). Import reverses it in `convertList` (level-1 margin minus the 1.27cm base).
- **`applyNestedListTypes`** — odf-kit emits nested lists as bare `<text:list>` sharing the top-level list style, so a nested list of a different kind/format (e.g. ordered inside bullets) gets its own minted 6-level list style.
@@ -50,4 +51,5 @@ U+E009 (columns), U+E00A (date/time fields), `STY` U+E00D (paragraph styles),
(section breaks), `LEAD` U+E011 (a tab stop's leader, inside `style:type`), `MTH` U+E012 (formulas),
`BMS`/`BME` U+E013/U+E014 (a bookmark's range), `XRF` U+E015 (cross-references),
`CHP` U+E016 (the chapter field), `FNT` U+E017 (footnotes/endnotes),
`CMS`/`CME` U+E018/U+E019 (a comment's range), `SEQ` U+E01A (a caption's number).
`CMS`/`CME` U+E018/U+E019 (a comment's range), `SEQ` U+E01A (a caption's number),
`TCI` U+E01B (a tracked insertion's range), `TCD` U+E01C (where a tracked deletion was).
+49 -7
View File
@@ -3,7 +3,7 @@ import {
FootnoteReferenceRun, EndnoteReferenceRun,
TableOfContents,
Table, TableRow, TableCell, Header, Footer, PageNumber, SimpleField,
CommentRangeStart, CommentRangeEnd, CommentReference,
CommentRangeStart, CommentRangeEnd, CommentReference, InsertedTextRun, DeletedTextRun,
AlignmentType, LevelFormat, UnderlineType, BorderStyle, ShadingType,
WidthType, HeightRule, PageOrientation, LineRuleType, LineNumberRestartFormat, TableLayoutType, SectionType, NumberFormat,
HorizontalPositionAlign, VerticalPositionRelativeFrom, HorizontalPositionRelativeFrom,
@@ -319,21 +319,62 @@ function runPropsFromMarks(marks: TiptapNode['marks'] = [], force: TextProps = {
// A text node → one run; tab chars become <w:tab/> via mixed children (a literal \t
// would otherwise be dropped). Returned as an array so a link wrapper can adopt it.
function textNodeToRuns(node: TiptapNode, force: TextProps): TextRun[] {
function textNodeToRuns(node: TiptapNode, force: TextProps): Inline[] {
const text = node.text ?? '';
const props = runPropsFromMarks(node.marks, force);
if (!text.includes('\t')) return [new TextRun({ text, ...props })];
const parts = text.split('\t');
const opts = text.includes('\t')
? { children: tabbedChildren(text), ...props }
: { text, ...props };
// A recorded revision wraps the run: w:ins keeps its text, w:del turns it into
// w:delText — the package's own two run kinds.
const rev = revisionOf(node);
if (rev) {
const attrs = { id: docRevisionId(rev.attrs.id), author: rev.attrs.author, date: rev.attrs.date };
return [rev.kind === 'insertion'
? new InsertedTextRun({ ...opts, ...attrs })
: new DeletedTextRun({ ...opts, ...attrs })];
}
return [new TextRun(opts)];
}
function tabbedChildren(text: string): (string | Tab)[] {
const children: (string | Tab)[] = [];
parts.forEach((p, i) => {
text.split('\t').forEach((p, i) => {
if (i > 0) children.push(new Tab());
if (p) children.push(p);
});
return [new TextRun({ children, ...props })];
return children;
}
// The insertion/deletion mark on a run, if any (trackChanges.ts).
function revisionOf(node: TiptapNode): { kind: 'insertion' | 'deletion'; attrs: { id: string; author: string; date: string } } | null {
const m = node.marks?.find((x) => x.type === 'insertion' || x.type === 'deletion');
if (!m) return null;
const a = m.attrs ?? {};
return {
kind: m.type as 'insertion' | 'deletion',
attrs: {
id: String(a.id ?? ''),
author: String(a.author ?? '') || GENERATOR,
// Word wants a UTC stamp; a file's own is kept when it parses.
date: typeof a.date === 'string' && a.date ? a.date : new Date().toISOString(),
},
};
}
// Word numbers its revisions; the editor's ids are strings, so they are handed out in
// first-seen order and every run of one change keeps the same number.
let docRevisionIds = new Map<string, number>();
function docRevisionId(id: string): number {
const seen = docRevisionIds.get(id);
if (seen != null) return seen;
const next = docRevisionIds.size;
docRevisionIds.set(id, next);
return next;
}
type Inline = TextRun | ImageRun | ExternalHyperlink | InternalHyperlink | SimpleField | Bookmark
| CommentRangeStart | CommentRangeEnd;
| CommentRangeStart | CommentRangeEnd | InsertedTextRun | DeletedTextRun;
// A date/time field. A fixed field is plain text (Word has no fixed-date field); an
// auto field is a DATE/TIME field with the picture switch and a cached value.
@@ -1521,6 +1562,7 @@ export async function buildDocx(
docNoteIds = new Map();
docComments = [];
docCommentIds = new Map();
docRevisionIds = new Map();
collectComments(docJson);
const notesByClass: Record<NoteKind, Record<string, { children: Paragraph[] }>> = { footnote: {}, endnote: {} };
for (const note of noteBlocks) {
+97 -2
View File
@@ -184,6 +184,12 @@ const FNT = '\uE017';
// rewrites it to <text:sequence>. U+E01A.
const SEQ = '\uE01A';
// Sentinels for recorded revisions (trackChanges.ts): TCI{i}TCI brackets an insertion's
// runs, TCD{i}TCD stands where a deletion's text was cut out. applyRevisions rewrites
// both and builds the <text:tracked-changes> registry. U+E01B/U+E01C.
const TCI = '\uE01B';
const TCD = '\uE01C';
const EXT_BY_MIME: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
@@ -549,6 +555,94 @@ function replaceDateTimeFields(node: TiptapNode, fields: DateTimeFieldExport[]):
return { ...node, content };
}
// One recorded revision. Runs of the same change share an id, so the registry holds one
// entry per id and a deletion's text is the concatenation of its runs.
type RevisionExport = { kind: 'insertion' | 'deletion'; id: string; author: string; date: string; text: string };
// Bracket every insertion run with TCI sentinels and cut every deletion run out, leaving
// a TCD sentinel where it was — ODF keeps a deletion's text in the registry, not inline.
function replaceRevisions(node: TiptapNode, out: Map<string, RevisionExport>): TiptapNode {
if (!node.content?.length) return node;
const content: TiptapNode[] = [];
for (const child of node.content) {
const mark = child.type === 'text'
? child.marks?.find((m) => m.type === 'insertion' || m.type === 'deletion')
: undefined;
if (!mark) { content.push(replaceRevisions(child, out)); continue; }
const a = mark.attrs ?? {};
const id = String(a.id ?? '');
const kind = mark.type as 'insertion' | 'deletion';
const entry = out.get(id) ?? {
kind, id,
author: String(a.author ?? '') || GENERATOR,
date: typeof a.date === 'string' && a.date ? a.date : new Date().toISOString(),
text: '',
};
entry.text += child.text ?? '';
out.set(id, entry);
// The marks stay off the sentinel runs: a change is not formatting, and odf-kit
// would otherwise split the paragraph's runs around it.
const rest = child.marks?.filter((m) => m !== mark);
if (kind === 'deletion') {
content.push({ type: 'text', text: `${TCD}${id}${TCD}` });
continue;
}
content.push({ type: 'text', text: `${TCI}${id}${TCI}` });
content.push({ ...child, ...(rest?.length ? { marks: rest } : { marks: undefined }) });
content.push({ type: 'text', text: `${TCI}${id}${TCI}` });
}
return { ...node, content };
}
// Resolve the revision sentinels and prepend the registry the body points into.
function applyRevisions(odtBytes: Uint8Array, list: Map<string, RevisionExport>): Uint8Array {
if (!list.size) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
// ODF ids are per document; the editor's are opaque strings.
const odfId = new Map([...list.keys()].map((id, i) => [id, `ct${i + 1}`]));
let open = new Map<string, boolean>();
content = content
.replace(new RegExp(`${TCI}([^${TCI}]*)${TCI}`, 'g'), (_m, id: string) => {
const ct = odfId.get(id);
if (!ct) return '';
const isEnd = open.get(id) === true;
open.set(id, !isEnd);
return isEnd
? `<text:change-end text:change-id="${ct}"/>`
: `<text:change-start text:change-id="${ct}"/>`;
})
.replace(new RegExp(`${TCD}([^${TCD}]*)${TCD}`, 'g'), (_m, id: string) => {
const ct = odfId.get(id);
return ct ? `<text:change text:change-id="${ct}"/>` : '';
});
const regions = [...list.values()].map((r) => {
const ct = odfId.get(r.id)!;
const info = `<office:change-info><dc:creator>${escapeXml(r.author)}</dc:creator>`
+ `<dc:date>${escapeXml(r.date.replace(/\.\d+Z?$/, ''))}</dc:date></office:change-info>`;
const body = r.kind === 'deletion'
? `<text:deletion>${info}<text:p text:style-name="Standard">${escapeXml(r.text)}</text:p></text:deletion>`
: `<text:insertion>${info}</text:insertion>`;
return `<text:changed-region xml:id="${ct}" text:id="${ct}">${body}</text:changed-region>`;
}).join('');
content = content.replace(/(<office:text\b[^>]*>)/, `$1<text:tracked-changes>${regions}</text:tracked-changes>`);
content = ensureDcNamespace(content);
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// The change registry names its author with dc:creator; odf-kit declares no dc: prefix
// in content.xml, and an undeclared one loses the whole element.
function ensureDcNamespace(content: string): string {
if (content.includes('xmlns:dc=')) return content;
return content.replace(/<office:document-content\b/, '<office:document-content xmlns:dc="http://purl.org/dc/elements/1.1/"');
}
// One caption sequence field, collected by replaceSequenceFields and emitted by
// applySequenceFields. `number` is the rank the editor resolved; both formats cache it.
type SequenceExport = { category: SeqCategory; format: string; number: number };
@@ -3910,7 +4004,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const notes: NoteExport[] = [];
const commentList: CommentExport[] = [];
const seqFields: SequenceExport[] = [];
const sentinels = replaceSequenceFields(replaceComments(replaceBookmarks(replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceNotes(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns), notes)))), images), dateFields), formulas), crossRefs), commentList), seqFields);
const revisionList = new Map<string, RevisionExport>();
const sentinels = replaceRevisions(replaceSequenceFields(replaceComments(replaceBookmarks(replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceNotes(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns), notes)))), images), dateFields), formulas), crossRefs), commentList), seqFields), revisionList);
const raw = markTextEffects(bakeListCharStyles(sentinels, styles));
let headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
let footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
@@ -4110,7 +4205,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const withBreaks = applyInlineSentinels(applyTabLeaders(cleaned));
const withImages = applyImages(withBreaks, images);
const withDateFields = applyDateTimeFields(withImages, dateFields, language ?? null);
const withSequences = applySequenceFields(withDateFields, seqFields);
const withSequences = applyRevisions(applySequenceFields(withDateFields, seqFields), revisionList);
const withFormulas = applyFormulas(withSequences, formulas);
const withTextBoxes = applyTextBoxes(withFormulas, textBoxes);
const withColumns = applyColumns(withTextBoxes, columns);