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

+2
View File
@@ -79,6 +79,8 @@ export const STROKED = {
tocLevels: ['M2.5 3.5h11M4.5 7h9M6.5 10.5h7M8.5 14h5'],
caption: ['M2 2.5h12v7.5H2z', 'M2 13h7'],
watermark: ['M2.5 2h11v12h-11z', 'M4.5 11.5 11.5 4.5'],
trackChanges: ['M2 4h12M2 8h7', 'M9.5 13.5 14 9l-1.6-1.6L7.9 12z', 'M7.9 12 7.4 14.1l2.1-.6'],
close: ['M3.5 3.5 12.5 12.5M12.5 3.5 3.5 12.5'],
lineNumbers: ['M6 3h8.5M6 6.5h8.5M6 10h8.5M6 13.5h8.5', 'M2 2.2h1.1v3.3M1.6 5.5h1.9', 'M1.5 9.6c.2-.4.6-.5 1-.4.5.1.6.6.3 1L1.6 11.6h1.9'],
wordCount: ['M2.5 2.5h11v11h-11z', 'M5 6h6M5 9h4'],
// A speech bubble with its tail, the shape both word processors use for a comment.
@@ -6,6 +6,8 @@
import { captionClicks, anchored, clickOutside, isMenuOpen, toggleMenu } from '../menu.svelte';
import { countText, type TextStats } from '../../../utils/wordCount';
import type { DocumentLanguage } from '../../../storage/documentLanguage';
import { recordChanges, setRecordChanges } from '../../../storage/trackChanges.svelte';
import { revisions } from '../../../editor/extensions/trackChanges';
import { t } from '../../../i18n/i18n.svelte';
let { editor, tick, documentLanguage, onLanguage, onAutoCorrect, onNewComment, commentsOpen = false, onToggleComments }: {
@@ -20,6 +22,7 @@
} = $props();
let hasSelection = $derived(tick >= 0 && !!editor && !editor.state.selection.empty);
let hasRevisions = $derived(tick >= 0 && !!editor && revisions(editor.state.doc).length > 0);
let stats = $derived.by<TextStats>(() => {
if (tick < 0 || !editor) return { words: 0, charsWithSpaces: 0, charsNoSpaces: 0, paragraphs: 0 };
@@ -98,6 +101,47 @@
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.revisions}>
<RibbonButton
variant="big"
icon="trackChanges"
label={t().revisions.record}
title={t().revisions.recordHint}
active={recordChanges()}
onclick={() => setRecordChanges(!recordChanges())}
/>
<RibbonButton
icon="check"
label={t().revisions.accept}
title={t().revisions.acceptHint}
disabled={!hasRevisions}
onclick={() => editor?.chain().focus().acceptRevisions().run()}
/>
<RibbonButton
icon="close"
label={t().revisions.reject}
title={t().revisions.rejectHint}
disabled={!hasRevisions}
onclick={() => editor?.chain().focus().rejectRevisions().run()}
/>
<RibbonButton
variant="small"
icon="check"
label={t().revisions.acceptAll}
disabled={!hasRevisions}
onclick={() => editor?.chain().focus().acceptRevisions(true).run()}
/>
<RibbonButton
variant="small"
icon="close"
label={t().revisions.rejectAll}
disabled={!hasRevisions}
onclick={() => editor?.chain().focus().rejectRevisions(true).run()}
/>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.language}>
<div class="rb-captioned" use:captionClicks>
<LanguagePicker value={documentLanguage} onChange={onLanguage} />
+8
View File
@@ -64,6 +64,9 @@ import { AutoCorrect } from './extensions/autoCorrect';
import { HEADING_LEVELS } from '../export/odt';
import { styleSheet } from '../styles/sheet.svelte';
import { noteSettings } from '../storage/notes.svelte';
import { recordChanges } from '../storage/trackChanges.svelte';
import { loadDocProperties } from '../storage/docProperties';
import { Insertion, Deletion, TrackChanges } from './extensions/trackChanges';
export const extensions = [
// textBox and columns have their own groups so only the document (not
@@ -195,6 +198,11 @@ export const extensions = [
FormattingMarks,
SpellCheck,
// Last of the typing extensions: its handleTextInput runs after every input rule.
// Recorded revisions: an insertion is marked text, a deletion is text kept and
// marked. Round-trips to ODF <text:tracked-changes> and DOCX w:ins/w:del.
Insertion,
Deletion,
TrackChanges.configure({ recording: recordChanges, author: () => loadDocProperties().author.trim() }),
AutoCorrect,
SearchReplace,
// Word/LibreOffice key bindings that aren't TipTap defaults; body:true adds the
+1
View File
@@ -30,6 +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.
- **`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. `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.
+262
View File
@@ -0,0 +1,262 @@
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';
// 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
// dropped until a change is accepted. Two marks carry it (author, date and a shared id
// per change), so a revision survives every editing operation the way a comment does.
//
// ODF stores the pair in a <text:tracked-changes> registry and points at it from the
// body; a deletion's text lives in the registry, an insertion's stays inline. Word wraps
// runs in <w:ins>/<w:del> instead, keeping a deletion's text in <w:delText>. Both are
// probed — see src/lib/export/CLAUDE.md.
export type RevisionKind = 'insertion' | 'deletion';
export type Revision = {
kind: RevisionKind;
id: string;
author: string;
date: string;
from: number;
to: number;
/** The text the change covers — a deletion's is what would come back on reject. */
text: string;
};
export function newRevisionId(): string {
return `r${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
}
// Whether the editor is recording. A document-level switch, like LibreOffice's Edit ▸
// Track Changes ▸ Record; the extension reads it through this option so the toolbar can
// flip it without rebuilding the editor.
export const RECORDING = new PluginKey<boolean>('trackChangesRecording');
function revisionMark(name: RevisionKind, attr: string) {
return Mark.create({
name,
// The range is fixed: typing at either end is its own change, as in Word.
inclusive: false,
excludes: '',
addAttributes() {
return {
id: { default: '', parseHTML: (el) => el.getAttribute(attr) || '', renderHTML: (a) => ({ [attr]: String(a.id ?? '') }) },
author: { default: '', parseHTML: (el) => el.getAttribute('data-rev-author') || '', renderHTML: (a) => ({ 'data-rev-author': String(a.author ?? '') }) },
date: { default: '', parseHTML: (el) => el.getAttribute('data-rev-date') || '', renderHTML: (a) => ({ 'data-rev-date': String(a.date ?? '') }) },
};
},
parseHTML() {
return [{ tag: `span[${attr}]` }];
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes, { class: `pm-${name}` }), 0];
},
});
}
export const Insertion = revisionMark('insertion', 'data-insertion');
export const Deletion = revisionMark('deletion', 'data-deletion');
// Every recorded change in document order, adjacent runs of one id merged.
export function revisions(doc: PMNode): Revision[] {
const out: Revision[] = [];
let last: Revision | null = null;
doc.descendants((node, pos) => {
if (!node.isText) { last = null; return true; }
const mark = node.marks.find((m) => m.type.name === 'insertion' || m.type.name === 'deletion');
if (!mark) { last = null; return false; }
const kind = mark.type.name as RevisionKind;
const id = String(mark.attrs.id ?? '');
if (last && last.id === id && last.kind === kind && last.to === pos) {
last.to = pos + node.nodeSize;
last.text += node.text ?? '';
return false;
}
last = {
kind, id,
author: String(mark.attrs.author ?? ''),
date: String(mark.attrs.date ?? ''),
from: pos, to: pos + node.nodeSize, text: node.text ?? '',
};
out.push(last);
return false;
});
return out;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
trackChanges: {
/** Accept the change under the cursor, or every one when `all`. */
acceptRevisions: (all?: boolean) => ReturnType;
/** Reject the change under the cursor, or every one when `all`. */
rejectRevisions: (all?: boolean) => ReturnType;
};
}
}
// The revisions a command applies to: all of them, or the ones the selection touches
// (an empty selection takes the change the caret sits in).
function targets(state: EditorState, all: boolean): Revision[] {
const list = revisions(state.doc);
if (all) return list;
const { from, to } = state.selection;
return list.filter((r) => (from === to ? r.from <= from && from <= r.to : r.from < to && from < r.to));
}
// 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.
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)) {
if (r.kind === drop) tr.delete(r.from, r.to);
else tr.removeMark(r.from, r.to, state.schema.marks[r.kind]);
}
tr.setMeta(RECORDING, true);
}
export const TrackChanges = Extension.create<{ recording: () => boolean; author: () => string }>({
name: 'trackChanges',
addOptions() {
return { recording: () => false, author: () => '' };
},
addCommands() {
return {
acceptRevisions: (all = false) => ({ state, tr, dispatch }) => {
const list = targets(state, all);
if (!list.length) return false;
if (dispatch) apply(state, tr, list, true);
return true;
},
rejectRevisions: (all = false) => ({ state, tr, dispatch }) => {
const list = targets(state, all);
if (!list.length) return false;
if (dispatch) apply(state, tr, list, false);
return true;
},
};
},
addProseMirrorPlugins() {
const options = this.options;
return [
new Plugin({
key: RECORDING,
// Typing is intercepted here rather than by a keymap: every path that inserts
// text — a paste, an input rule, a drop — goes through appendTransaction, and
// each would otherwise need its own hook.
appendTransaction: (transactions, oldState, newState) => {
if (!options.recording()) return null;
if (!transactions.some((t) => t.docChanged)) return null;
// Our own accept/reject and the mark pass below must not recurse.
if (transactions.some((t) => t.getMeta(RECORDING))) return null;
const tr = newState.tr;
let touched = false;
const author = options.author();
for (const t of transactions) {
if (!t.docChanged) continue;
for (const step of t.steps) {
const map = step.getMap();
map.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
if (newEnd <= newStart) return;
const from = tr.mapping.map(newStart);
const to = tr.mapping.map(newEnd);
// Anything freshly in the document is an insertion; a delete never gets
// this far, because it was turned into a mark before it ran (below).
// Typing joins the run it continues, so a sentence is one change and not
// one per keystroke.
tr.addMark(from, to, newState.schema.marks.insertion.create(
adjacentInsertion(newState, from, author) ?? { id: newRevisionId(), author, date: new Date().toISOString() },
));
touched = true;
});
}
}
return touched ? tr.setMeta(RECORDING, true).setMeta('addToHistory', false) : null;
},
// A deletion must not reach the document: mark the range instead. Text already
// marked as an insertion is the author's own and is simply removed, which is
// what both word processors do.
filterTransaction: (tr, state) => {
if (!options.recording() || tr.getMeta(RECORDING)) return true;
if (!tr.docChanged || !isPureDelete(tr, state)) return true;
const ranges: [number, number][] = [];
for (const step of tr.steps) {
const map = step.getMap();
map.forEach((oldStart, oldEnd) => { if (oldEnd > oldStart) ranges.push([oldStart, oldEnd]); });
}
if (!ranges.length) return true;
const marked = state.tr;
const attrs = { id: newRevisionId(), author: options.author(), date: new Date().toISOString() };
let any = false;
for (const [from, to] of ranges) {
for (const keep of keptRanges(state, from, to)) {
marked.addMark(keep[0], keep[1], state.schema.marks.deletion.create(attrs));
any = true;
}
// 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 (!any && !marked.docChanged) return true;
queueMicrotask(() => this.editor.view.dispatch(marked.setMeta(RECORDING, true)));
return false;
},
}),
];
},
});
// The attrs of the insertion the text just before `pos` belongs to, when the same author
// made it — what makes consecutive typing one change rather than one per keystroke.
function adjacentInsertion(state: EditorState, pos: number, author: string): Record<string, string> | null {
const before = pos > 0 ? state.doc.resolve(pos).nodeBefore : null;
const mark = before?.marks.find((m) => m.type.name === 'insertion');
if (!mark || String(mark.attrs.author ?? '') !== author) return null;
return { id: String(mark.attrs.id), author, date: String(mark.attrs.date) };
}
// 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 {
if (!tr.steps.length) return false;
return tr.doc.content.size < state.doc.content.size
&& tr.steps.every((step) => {
let inserted = 0;
step.getMap().forEach((_os, _oe, ns, ne) => { inserted += ne - ns; });
return inserted === 0;
});
}
// The parts of [from,to) that are not the author's own pending insertion — the ones a
// delete turns into a deletion mark.
function keptRanges(state: EditorState, from: number, to: number): [number, number][] {
const own = ownInsertions(state, from, to);
const out: [number, number][] = [];
let cursor = from;
for (const [s, e] of own) {
if (s > cursor) out.push([cursor, s]);
cursor = Math.max(cursor, e);
}
if (cursor < to) out.push([cursor, to]);
return out;
}
function ownInsertions(state: EditorState, from: number, to: number): [number, number][] {
const out: [number, number][] = [];
state.doc.nodesBetween(from, to, (node, pos) => {
if (!node.isText) return true;
if (!node.marks.some((m) => m.type.name === 'insertion')) return false;
out.push([Math.max(from, pos), Math.min(to, pos + node.nodeSize)]);
return false;
});
return out;
}
+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);
+11
View File
@@ -544,6 +544,16 @@ const de: Messages = {
interval: 'Jede n-te Zeile nummerieren',
countEmpty: 'Leerzeilen mitzählen',
},
revisions: {
record: 'Änderungen verfolgen',
recordHint: 'Jede Bearbeitung als Änderung aufzeichnen',
accept: 'Annehmen',
acceptHint: 'Die Änderung an der Einfügemarke annehmen',
reject: 'Verwerfen',
rejectHint: 'Die Änderung an der Einfügemarke verwerfen',
acceptAll: 'Alle annehmen',
rejectAll: 'Alle verwerfen',
},
notesDialog: {
title: 'Fuß- und Endnoten',
tabFootnotes: 'Fußnoten',
@@ -607,6 +617,7 @@ const de: Messages = {
pageSetup: 'Seite einrichten',
toc: 'Inhaltsverzeichnis',
notes: 'Fußnoten',
revisions: 'Nachverfolgung',
proofing: 'Rechtschreibung',
language: 'Sprache',
show: 'Anzeigen',
+11
View File
@@ -548,6 +548,16 @@ const en = {
interval: 'Number every nth line',
countEmpty: 'Count empty lines',
},
revisions: {
record: 'Track changes',
recordHint: 'Record every edit as a revision',
accept: 'Accept',
acceptHint: 'Accept the change at the cursor',
reject: 'Reject',
rejectHint: 'Reject the change at the cursor',
acceptAll: 'Accept all',
rejectAll: 'Reject all',
},
notesDialog: {
title: 'Footnotes and Endnotes',
tabFootnotes: 'Footnotes',
@@ -611,6 +621,7 @@ const en = {
pageSetup: 'Page Setup',
toc: 'Table of Contents',
notes: 'Footnotes',
revisions: 'Tracking',
proofing: 'Proofing',
language: 'Language',
show: 'Show',
+1
View File
@@ -31,6 +31,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
- **A page-anchored frame** (`text:anchor-type="page"`) is out of the flow: it rides a paragraph of its own that collapses to nothing, and the node view places it from its page's corner (`anchorPage`, `image.ts`) — that paragraph must stay `position: static` (`editor.css`, `!important` over the line-height rule's own `position: relative`), or it becomes the frame's containing block instead of the page and offsets it by a page margin. Behind the text by default (`style:run-through="background"`); a cover page's own graphic instead declares `"foreground"` (`inFront` attr) to sit in front and hide what the page hoists behind it (a redundant logo, a title paragraph the graphic already carries). In a cell it falls back to an ordinary floating frame.
- **A header/footer zone is fitted to its schema** (`fitZoneSchema`): the zone editor has fewer marks and nodes than the body, and one it doesn't know makes `generateHTML` throw — which renders the whole zone blank. Unknown marks are dropped, unknown nodes leave their text.
- **A hoisted text box keeps what its anchor paragraph gave it**: an as-char figure frame is centred by the paragraph it sits in, so `wrapAlign` carries that (or the frame's own `style:horizontal-pos`) onto the box. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default.
- **Tracked changes**: ODF points at a registry, so `odfRevisions(body)` reads `<text:tracked-changes>` first and the body walk resolves each `text:change-start`/`-end` and `text:change` against it (the open insertions live in `Ctx` — a range may close in a later paragraph). A `text:change` is a deletion whose text is *only* in the registry: it is emitted there and then, marked. Word needs no registry — `w:ins`/`w:del` wrap their runs, whose text sits in `w:delText` rather than `w:t` for a deletion.
- **Footnotes and endnotes** round-trip: `text:note` (ODF) and `word/footnotes.xml`/`endnotes.xml` (DOCX) become a `noteRef` anchor plus a `note` in the document-final `noteSection`, and the numbering configuration becomes `NoteSettings`. See `docs/architecture/notes.md`.
- **Graceful degradation** (collected as `warnings`, surfaced once): comments dropped, hyperlinks flattened to text, nested tables flattened to paragraphs, non-`Pictures` drawings dropped, unreadable pictures skipped (each with a warning). Page size is normalized to A4. Floating/wrapped images are imported as left/right/top-bottom wrap (free x/y positions collapse to the nearest side; see `image.ts`).
+21 -1
View File
@@ -1267,6 +1267,9 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
break;
}
case 'instrText': if (fieldMode === 'instr') fieldInstr += child.textContent ?? ''; break;
// w:delText is a deleted run's text — the same content, kept because the
// revision is only recorded, not applied.
case 'delText':
case 't':
if ((fieldCrossRef || fieldSeq) && fieldMode === 'result') fieldResultText += child.textContent ?? '';
else if (!skipResult()) pushText(child.textContent ?? '', marks);
@@ -1339,9 +1342,26 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
for (const r of fcAll(el, 'r')) handleRun(r); // body: keep the shown value
break;
}
case 'ins': // accepted tracked-change insertion → keep its runs
case 'ins':
case 'del': {
// A recorded revision. Its runs keep their formatting and take the mark; a
// deletion's text sits in w:delText, which handleRun reads as ordinary text.
const kind = el.localName === 'del' ? 'deletion' : 'insertion';
const attrs = {
id: el.getAttributeNS(W, 'id') ?? '',
author: el.getAttributeNS(W, 'author') ?? '',
date: el.getAttributeNS(W, 'date') ?? '',
};
const before = out.length;
for (const r of fcAll(el, 'r')) handleRun(r);
if (!hfFields) {
for (let i = before; i < out.length; i++) {
if (out[i].type !== 'text') continue;
out[i].marks = [...(out[i].marks ?? []), { type: kind, attrs }];
}
}
break;
}
case 'smartTag':
for (const r of fcAll(el, 'r')) handleRun(r);
break;
+53 -4
View File
@@ -138,6 +138,10 @@ type Ctx = {
openBookmarks: Set<string>;
// Ranged comments currently open, by their office:name — same shape as openBookmarks.
openComments: Map<string, Record<string, unknown>>;
// Recorded revisions: the <text:tracked-changes> registry by change id, and the
// insertions whose <text:change-start> has been seen but not their end.
revisions: Map<string, { kind: 'insertion' | 'deletion'; author: string; date: string; text: string }>;
openInsertions: Map<string, Record<string, unknown>>;
// Footnotes/endnotes in anchor order: the file stores each note's text at its anchor,
// the editor keeps them in one section at the document end (notes.ts).
notes: { id: string; kind: NoteKind; label: string | null; text: string; content: Node[]; styleName: string | null }[];
@@ -616,7 +620,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
const contentWidthCm = geo
? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right
: pageDimsCm('A4', 'portrait').w - 2 * 2.12;
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterBlocks: new Map(), openBookmarks: new Set(), openComments: new Map(), notes: [] };
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterBlocks: new Map(), openBookmarks: new Set(), openComments: new Map(), revisions: odfRevisions(body), openInsertions: new Map(), notes: [] };
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
pairAlignedFrames(blocks, Math.floor(cmToPx(contentWidthCm)));
@@ -1611,6 +1615,27 @@ function convertDateTimeField(e: Element, ctx: Ctx): Node | null {
return { type: 'dateTimeField', attrs: { kind, format, fixed, value: fieldValue(kind, raw) } };
}
// The <text:tracked-changes> registry: one entry per change id. A deletion carries the
// text it removed (the body has only a point marker), an insertion only who and when.
function odfRevisions(body: Element): Map<string, { kind: 'insertion' | 'deletion'; author: string; date: string; text: string }> {
const out = new Map<string, { kind: 'insertion' | 'deletion'; author: string; date: string; text: string }>();
const registry = body.getElementsByTagNameNS(NS.text, 'tracked-changes')[0];
for (const region of Array.from(registry?.children ?? [])) {
const id = region.getAttributeNS(NS.text, 'id') ?? region.getAttribute('xml:id');
const kindEl = Array.from(region.children).find((c) => c.localName === 'insertion' || c.localName === 'deletion');
if (!id || !kindEl) continue;
const info = kindEl.getElementsByTagNameNS(NS.office, 'change-info')[0];
const paras = Array.from(kindEl.children).filter((c) => c.namespaceURI === NS.text && c.localName === 'p');
out.set(id, {
kind: kindEl.localName === 'deletion' ? 'deletion' : 'insertion',
author: info?.getElementsByTagNameNS(NS.dc, 'creator')[0]?.textContent?.trim() ?? '',
date: info?.getElementsByTagNameNS(NS.dc, 'date')[0]?.textContent?.trim() ?? '',
text: paras.map((n) => n.textContent ?? '').join('\n'),
});
}
return out;
}
// <text:sequence> → a caption's running number. The counter's name is the category
// (LibreOffice's "Illustration"/"Table"); the element text is the file's cached value,
// which the editor's own numbering overwrites on the first edit.
@@ -1710,6 +1735,8 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
if (bookmark) marks.push({ type: 'bookmark', attrs: { name: bookmark } });
const comment = hfFields ? undefined : ctx.openComments.values().next().value;
if (comment) marks.push({ type: 'comment', attrs: comment });
const insertion = hfFields ? undefined : ctx.openInsertions.values().next().value;
if (insertion) marks.push({ type: 'insertion', attrs: insertion });
const node: Node = { type: 'text', text: clean };
if (marks.length) node.marks = marks;
out.push(node);
@@ -1792,12 +1819,34 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
if (e.textContent) pushText(e.textContent, props, linkHref);
continue;
}
// A point bookmark has no range to mark; a soft page break is the
// producer's own pagination, which the editor recomputes.
case 'bookmark':
case 'soft-page-break':
case 'change':
case 'change-start':
case 'change-end':
continue;
case 'change-start':
case 'change-end': {
// A recorded insertion brackets its runs; the registry holds who and when.
const id = e.getAttributeNS(NS.text, 'change-id');
const rev = id ? ctx.revisions.get(id) : undefined;
if (!hfFields && id && rev?.kind === 'insertion') {
if (e.localName === 'change-start') ctx.openInsertions.set(id, { id, author: rev.author, date: rev.date });
else ctx.openInsertions.delete(id);
}
continue;
}
case 'change': {
// A deletion is a point marker: its text lives only in the registry, and the
// editor keeps it in the document marked as removed.
const id = e.getAttributeNS(NS.text, 'change-id');
const rev = id ? ctx.revisions.get(id) : undefined;
if (!hfFields && id && rev?.kind === 'deletion' && rev.text) {
const marks = marksFor(props, ctx.resolver, defaults);
marks.push({ type: 'deletion', attrs: { id, author: rev.author, date: rev.date } });
out.push({ type: 'text', text: rev.text, marks });
}
continue;
}
default:
// In headers/footers, page fields stay live fields (pageField.ts). The atom
// carries the run's marks so its digits render in the run's font/size.
+1
View File
@@ -14,6 +14,7 @@
- **`pageNumbering.ts`** — the page-number field's format and start value (`edentext-page-numbering`, absent at the 1/1 default). `HeaderFooterLayer.svelte` formats each page through `formatOrdinal`; the page **count** stays decimal, as the field both word processors write does. ODF keeps the format on the page layout and the start on the first paragraph's own style (`style:page-number` — probed, ODF has no document-level start); Word keeps both in the first section's `w:pgNumType`.
- **`hyphenation.ts`** — automatic hyphenation for the whole document (`edentext-hyphenation`, off by default as in both word processors). `Editor.svelte` puts `hyphens: auto` and the document `lang` on the body host — the browser needs the language to pick its patterns. ODF keeps it as `fo:hyphenate` in the **text** properties of the base paragraph style (probed: in paragraph-properties LibreOffice ignores it and drops it on the next save), Word as `w:autoHyphenation` in settings.xml.
- **`pageDecor.ts`** — the page's own decoration (`edentext-page-decor`, the key removed when all three are off): background colour, a border around the text area at `paddingCm` from it, and a text watermark (LibreOffice's Format ▸ Watermark, field for field). All three ride the ODF page layout — the watermark as a `fontwork-plain-text` `<draw:custom-shape>` named `PowerPlusWaterMarkObject` in the **master page's header**, the only place LibreOffice reads one from (probed: hung off the master page directly it is dropped, and an undeclared `xmlns:draw` in styles.xml drops it too). Word keeps the background on `w:document` (plus `w:displayBackgroundShape`), the border in `w:sectPr` **after `w:pgMar`** — the child order is fixed, and ahead of the header references Word's reader loses those — and the watermark as the VML `_x0000_t136` shape, whose definition has to travel or LibreOffice cannot resolve it. `PageDecorLayer.svelte` draws border and watermark per page; the background is a `--color-page-bg` override on `.paper`.
- **`trackChanges.svelte.ts`** — whether the editor records revisions (`edentext-record-changes`, the key removed when off, as it is by default in both word processors). A reactive singleton because the extension reads it on every transaction while the Review tab flips it; the recorded changes themselves live in the document, on the marks.
- **`lineNumbering.ts`** — numbers in the left margin (`edentext-line-numbering`, the key removed when off, as it is by default in both word processors). ODF keeps one `<text:linenumbering-configuration>` in `office:styles`, Word a `<w:lnNumType>` per `w:sectPr` (probed). `LineNumberLayer.svelte` measures the lines: CSS exposes no line boxes, so a Range per block is taken and its distinct client-rect tops are the lines.
- **`pageOrientation.ts`** — `'portrait' | 'landscape'`. `applyOrientationVars` sets `--user-page-{width,height}`; landscape swaps the A4 dimensions (matching odf-kit's automatic swap).
+18
View File
@@ -0,0 +1,18 @@
// Whether the editor records revisions, as a reactive singleton (same shape as
// autoCorrect.svelte.ts): the extension reads recordChanges() on every transaction, the
// Review tab flips it. Off by default, as in both word processors — and a document that
// records nothing writes no revision registry.
const KEY = 'edentext-record-changes';
let current = $state(localStorage.getItem(KEY) === 'true');
export function recordChanges(): boolean {
return current;
}
export function setRecordChanges(on: boolean): void {
current = on;
if (on) localStorage.setItem(KEY, 'true');
else localStorage.removeItem(KEY);
}
+13
View File
@@ -1007,6 +1007,19 @@
border-bottom-color: rgba(140, 140, 140, 0.6);
}
/* Recorded revisions (trackChanges.ts). Both word processors underline an insertion
and strike a deletion, in the reviewer's colour; one colour here, since the editor
has a single author. */
.tiptap .pm-insertion {
color: #b5179e;
text-decoration: underline;
}
.tiptap .pm-deletion {
color: #b5179e;
text-decoration: line-through;
}
/* Print styles: proper A4 pagination */
@media print {
.editor {