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