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:
1 parent
da438a7655
commit
5ee4eec5d7
18 files changed
+646
-16
No files matched your search
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user