From 5ee4eec5d73643c02e705030bbf99979901b679e Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 14 Aug 2026 07:48:10 +0200 Subject: [PATCH] feat: the editor records, shows and applies tracked changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 3 +- src/lib/components/ribbon/icons.ts | 2 + .../components/ribbon/tabs/ReviewTab.svelte | 44 +++ src/lib/editor/extensions.ts | 8 + src/lib/editor/extensions/CLAUDE.md | 1 + src/lib/editor/extensions/trackChanges.ts | 262 ++++++++++++++++++ src/lib/export/CLAUDE.md | 4 +- src/lib/export/docx.ts | 56 +++- src/lib/export/odt.ts | 99 ++++++- src/lib/i18n/locales/de.ts | 11 + src/lib/i18n/locales/en.ts | 11 + src/lib/import/CLAUDE.md | 1 + src/lib/import/docx.ts | 22 +- src/lib/import/odt.ts | 57 +++- src/lib/storage/CLAUDE.md | 1 + src/lib/storage/trackChanges.svelte.ts | 18 ++ src/styles/editor.css | 13 + tests/roundtrip.test.ts | 49 ++++ 18 files changed, 646 insertions(+), 16 deletions(-) create mode 100644 src/lib/editor/extensions/trackChanges.ts create mode 100644 src/lib/storage/trackChanges.svelte.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c6078e0..51a1d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ - Page numbering options (Layout ▸ Page numbers): the five formats both word processors offer (1 / i / I / a / A) and a start value. Round-trips to ODF `style:num-format` on the page layout plus `style:page-number` on the first paragraph — where LibreOffice keeps the start, ODF having no document-level one — and to Word's `w:pgNumType` - Table of contents: generated from headings (H1–H5) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field - Comments: annotate a selection (Review tab or the context menu) and work through them in the reviewing pane — edit, resolve, remove, click one to jump to the text it marks. Round-trips to ODF `office:annotation`/`office:annotation-end` (with LibreOffice's `loext:resolved`) and to Word's `w:commentRangeStart`/`-End` + `word/comments.xml`; a LibreOffice point comment, which has no range, attaches to the run before it +- Track changes (Review ▸ Record changes): while recording, typed text is marked as an insertion and deleted text stays in the document struck through instead of going away, each carrying its author and date; consecutive typing is one change, and accept / reject work on the change at the cursor or on all of them. Round-trips to ODF's `text:tracked-changes` registry — `text:change-start`/`-end` around an insertion, `text:change` where a deletion was, its text kept in the registry — and to Word's `w:ins`/`w:del` + `w:delText` - Footnotes and endnotes (Ctrl+Alt+F / Ctrl+Alt+D): a footnote is drawn at the foot of the page its anchor sits on, with the separator line above it and the body text moved up to make room; endnotes are collected on their own page at the document end. Notes renumber themselves as anchors move, and deleting an anchor deletes its note. An options dialog covers numbering format, start value, restart, position, prefix/suffix, the two styles and the separator's length, thickness, spacing, alignment and colour. Round-trips to ODF `text:note` + `text:notes-configuration` and to Word's `word/footnotes.xml`/`endnotes.xml` + `w:footnotePr` - Formulas: dialog with a LaTeX field and live preview; only the LaTeX is stored, the MathML the browser typesets, the ODF formula object and the OMML are derived from it. Inline or as a centered display line, double-click to edit. Round-trips as a real embedded ODF formula object (`draw:object` + `Formula{n}/content.xml`, our LaTeX kept in the MathML `annotation`) and as Word's `m:oMath`. STIX Two Math is bundled, so stretched brackets and ∑/∫ look the same on every platform - Hyperlinks: create / edit / remove (toolbar + Ctrl+K), Ctrl/Cmd+click to open, hover hint showing the URL; ODF `text:a` round-trip @@ -129,12 +130,12 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08. **Content an imported document loses** -- Track changes / revisions: no recording, no accept/reject, no author colors (`text:tracked-changes`, `w:ins`/`w:del`); imported revisions are flattened to their current state - Charts are **drawn** from the file (`import/chart.ts`: DrawingML `chartN.xml` and ODF `chart:chart`), but as a picture, not a chart object — a re-export carries the drawing and the numbers behind it are no longer editable. The same holds for an **EMF** metafile (`import/emf.ts`): it is drawn, but as the SVG picture it was rebuilt into, and only from the record set a plot consists of — a hatched brush, a clipping region or a rotated bitmap is skipped. **WMF/SVM** metafiles and OLE objects still keep their box and a placeholder label, and export writes that back out: WMF is a different (16-bit) record format, SVM is StarOffice-proprietary, and an OLE object cannot be rendered without its application - Shapes beyond rect / round-rect / ellipse: lines, arrows, connectors, polygons, freeform, and rotated shape text (dropped on import with a warning) - Text boxes in the DOCX export: lists inside a box are flattened to literal-marker paragraphs (`•` / `1.`) and images inside a box are dropped — the box XML is injected by a post-pack string pass (`export/docx.ts` `applyTextBoxesDocx`) that mints no numbering.xml or media/rels entries. The ODT export has neither limitation **Missing while writing** +- One colour for every tracked change, where both word processors give each author their own — and no reviewing pane listing the revisions the way the comments pane lists comments - Hyphenation beyond the document switch: a per-paragraph "don't hyphenate", and the zone / ladder count (`fo:hyphenation-ladder-count`, `w:hyphenationZone`) — CSS exposes neither - Restarting the page numbering **per section** (only the document's own format and start value exist) - Heading levels 7–10 (Word and LibreOffice go that far; HTML stops at `h6`) diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts index bb2451e..25590cb 100644 --- a/src/lib/components/ribbon/icons.ts +++ b/src/lib/components/ribbon/icons.ts @@ -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. diff --git a/src/lib/components/ribbon/tabs/ReviewTab.svelte b/src/lib/components/ribbon/tabs/ReviewTab.svelte index f2615c8..437f057 100644 --- a/src/lib/components/ribbon/tabs/ReviewTab.svelte +++ b/src/lib/components/ribbon/tabs/ReviewTab.svelte @@ -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(() => { if (tick < 0 || !editor) return { words: 0, charsWithSpaces: 0, charsNoSpaces: 0, paragraphs: 0 }; @@ -98,6 +101,47 @@
+ + setRecordChanges(!recordChanges())} + /> + editor?.chain().focus().acceptRevisions().run()} + /> + editor?.chain().focus().rejectRevisions().run()} + /> + editor?.chain().focus().acceptRevisions(true).run()} + /> + editor?.chain().focus().rejectRevisions(true).run()} + /> + + +
+
diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 948336b..401293d 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -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 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 diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index 57fd262..145cf7b 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -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. diff --git a/src/lib/editor/extensions/trackChanges.ts b/src/lib/editor/extensions/trackChanges.ts new file mode 100644 index 0000000..382b409 --- /dev/null +++ b/src/lib/editor/extensions/trackChanges.ts @@ -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 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 / instead, keeping a deletion's text in . 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('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 { + 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 | 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; +} diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 12ada43..8120c81 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -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 ``/``/``. 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 `` carrying LibreOffice's own `text:formula="ooow:+1"` (what makes its reader recount on load) plus a `` 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 → ``/``; a deletion's text is **cut out of the run** and left as one `TCD` → ``, the text itself going into the `` registry `applyRevisions` builds right after `` (a `` holds the deleted paragraphs, a `` only its ``). The registry's ``/`` 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 `` 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). diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 2d43dbb..cec992f 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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 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(); +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> = { footnote: {}, endnote: {} }; for (const note of noteBlocks) { diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index de7aaf2..4fc1523 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -184,6 +184,12 @@ const FNT = '\uE017'; // rewrites it to . 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 registry. U+E01B/U+E01C. +const TCI = '\uE01B'; +const TCD = '\uE01C'; + const EXT_BY_MIME: Record = { '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): 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): 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(); + 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 + ? `` + : ``; + }) + .replace(new RegExp(`${TCD}([^${TCD}]*)${TCD}`, 'g'), (_m, id: string) => { + const ct = odfId.get(id); + return ct ? `` : ''; + }); + + const regions = [...list.values()].map((r) => { + const ct = odfId.get(r.id)!; + const info = `${escapeXml(r.author)}` + + `${escapeXml(r.date.replace(/\.\d+Z?$/, ''))}`; + const body = r.kind === 'deletion' + ? `${info}${escapeXml(r.text)}` + : `${info}`; + return `${body}`; + }).join(''); + content = content.replace(/(]*>)/, `$1${regions}`); + 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(/(); + 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); diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 7aac5cf..73b44c3 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -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', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index d8275fc..5878b71 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -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', diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 41b2da1..6eae338 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -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 `` 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`). diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 01d5027..320fab7 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index ad92648..ac0c446 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -138,6 +138,10 @@ type Ctx = { openBookmarks: Set; // Ranged comments currently open, by their office:name — same shape as openBookmarks. openComments: Map>; + // Recorded revisions: the registry by change id, and the + // insertions whose has been seen but not their end. + revisions: Map; + openInsertions: Map>; // 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 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 { + const out = new Map(); + 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; +} + // → 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. diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 20d4f3a..c29dc80 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -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` `` 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 `` in `office:styles`, Word a `` 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). diff --git a/src/lib/storage/trackChanges.svelte.ts b/src/lib/storage/trackChanges.svelte.ts new file mode 100644 index 0000000..1a8bf48 --- /dev/null +++ b/src/lib/storage/trackChanges.svelte.ts @@ -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); +} diff --git a/src/styles/editor.css b/src/styles/editor.css index d81bbd8..ccc6e7c 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -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 { diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 7d2bfd5..833c95b 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -2141,3 +2141,52 @@ describe('Leg 22: line numbering', () => { check('no w:lnNumType', !strFromU8(unzipSync(await buildDocx(doc, margins, 'portrait'))['word/document.xml']).includes(' { + const rev = (type: string, id: string): N => + ({ type, attrs: { id, author: 'Steffen', date: '2026-08-14T06:57:08.000Z' } }); + const doc: N = { type: 'doc', content: [P(null, + T('Der '), + T('NEUER TEXT ', rev('insertion', 'r1')), + T('unveraenderte S'), + T('atz bleib', rev('deletion', 'r2')), + T('t stehen.'), + )] }; + const runs = (back: N) => back.content[0].content.map( + (r: N) => `${r.text}|${(r.marks ?? []).map((m: N) => m.type).join(',')}`).join(' '); + + it('ODT: the registry plus change-start/-end and a deletion marker, and back', async () => { + const bytes = await buildOdt(doc, margins, 'portrait'); + const content = strFromU8(unzipSync(bytes)['content.xml']); + check('registry emitted', /NEUER TEXT ]*>[\s\S]*?<\/text:p>/g)?.slice(-1)); + // ODF keeps a deletion's text in the registry, never inline. + check('deletion lifted out', content.includes('') + && !/Der [\s\S]*?atz bleib/.test(content), + content.match(/[\s\S]*?<\/text:deletion>/)?.[0]); + // dc:creator needs its prefix declared or LibreOffice drops the change-info. + check('dc namespace declared', content.includes('xmlns:dc=')); + check('round-trips', runs(importOdt(bytes).content as N) + === 'Der | NEUER TEXT |insertion unveraenderte S| atz bleib|deletion t stehen.|', + runs(importOdt(bytes).content as N)); + }); + + it('DOCX: w:ins and w:del/w:delText, and back', async () => { + const bytes = await buildDocx(doc, margins, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['word/document.xml']); + check('w:ins emitted', /]*w:author="Steffen"/.test(xml), xml.match(/]*>/g)); + check('w:del keeps its text', xml.includes('/)?.[0]); + check('round-trips', runs(importDocx(bytes).content as N) + === 'Der | NEUER TEXT |insertion unveraenderte S| atz bleib|deletion t stehen.|', + runs(importDocx(bytes).content as N)); + }); + + it('a document with no revisions writes no registry', async () => { + const plain: N = { type: 'doc', content: [P(null, T('plain'))] }; + check('no ODF registry', !strFromU8(unzipSync(await buildOdt(plain, margins, 'portrait'))['content.xml']).includes('tracked-changes')); + const xml = strFromU8(unzipSync(await buildDocx(plain, margins, 'portrait'))['word/document.xml']); + check('no w:ins/w:del', !xml.includes('