diff --git a/src/lib/editor/contextMenuItems.ts b/src/lib/editor/contextMenuItems.ts
index d727a78..d6f37ef 100644
--- a/src/lib/editor/contextMenuItems.ts
+++ b/src/lib/editor/contextMenuItems.ts
@@ -3,6 +3,7 @@ import { t } from '../i18n/i18n.svelte';
import { withShortcut } from '../i18n/shortcut';
import { shortcutHint } from './shortcuts';
import { OPEN_LINK_DIALOG_EVENT } from './extensions/link';
+import { OPEN_COMMENT_EVENT } from './extensions/comment';
import { OPEN_BOOKMARK_DIALOG_EVENT, bookmarkNames } from './extensions/bookmark';
import { OPEN_CROSS_REF_DIALOG_EVENT } from './extensions/crossReference';
@@ -98,6 +99,12 @@ export function buildContextMenu(editor: Editor, opts: { spell?: SpellSection }
disabled: !hasSelection,
run: () => window.dispatchEvent(new CustomEvent(OPEN_BOOKMARK_DIALOG_EVENT)),
});
+ entries.push({
+ kind: 'item',
+ label: m.newComment,
+ disabled: !hasSelection,
+ run: () => window.dispatchEvent(new CustomEvent(OPEN_COMMENT_EVENT)),
+ });
entries.push({
kind: 'item',
label: m.insertCrossRef,
diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts
index 9992276..c4a8ba4 100644
--- a/src/lib/editor/extensions.ts
+++ b/src/lib/editor/extensions.ts
@@ -9,6 +9,7 @@ import Superscript from '@tiptap/extension-superscript';
import Highlight from '@tiptap/extension-highlight';
import { Link } from './extensions/link';
import { Bookmark } from './extensions/bookmark';
+import { Comment } from './extensions/comment';
import { TextStyle, FontFamily, FontSize } from '@tiptap/extension-text-style';
import { FontWeight } from './extensions/fontWeight';
import { FontColor } from './extensions/fontColor';
@@ -76,6 +77,9 @@ export const extensions = [
// Named range of text, the target of a cross-reference or an internal link;
// round-trips to ODF text:bookmark-start/-end and DOCX w:bookmarkStart/End.
Bookmark,
+ // An annotation on a range of text; round-trips to ODF office:annotation and DOCX
+ // w:commentRangeStart/-End + word/comments.xml.
+ Comment,
Subscript,
Superscript,
TextStyle,
diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md
index 8646101..504b867 100644
--- a/src/lib/editor/extensions/CLAUDE.md
+++ b/src/lib/editor/extensions/CLAUDE.md
@@ -28,6 +28,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`formattingMarks.ts`** (`FormattingMarks`) — decorations marking spaces (`·`) and tabs (`→`) when `.paper.show-formatting-marks` is set.
- **`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.
- **`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.
- **`spellCheck.ts`** — decorations from `spell/controller.ts`; `spellErrorAt` feeds the context menu.
diff --git a/src/lib/editor/extensions/comment.ts b/src/lib/editor/extensions/comment.ts
new file mode 100644
index 0000000..8a74af2
--- /dev/null
+++ b/src/lib/editor/extensions/comment.ts
@@ -0,0 +1,153 @@
+import { Mark, mergeAttributes } from '@tiptap/core';
+import type { Node as PMNode, Mark as PMMark } from '@tiptap/pm/model';
+
+// A comment: an annotation on a range of text. A mark, like `bookmark` — both formats
+// store a range (ODF office:annotation + office:annotation-end, DOCX
+// w:commentRangeStart/-End) and the pane has to find the text a comment points at.
+// The whole comment rides the mark's attrs, so it needs no store of its own: two runs of
+// the same comment carry identical attrs and merge back together.
+
+export type CommentRange = {
+ id: string;
+ author: string;
+ date: string;
+ text: string;
+ resolved: boolean;
+ from: number;
+ to: number;
+ // The commented text itself, for the pane's quote line.
+ quote: string;
+};
+
+// Fired by the context menu; App.svelte owns the prompt and the pane.
+export const OPEN_COMMENT_EVENT = 'odf-new-comment';
+
+export function newCommentId(): string {
+ return `c${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
+}
+
+function commentIdOf(marks: readonly PMMark[]): string | null {
+ const id = marks.find((m) => m.type.name === 'comment')?.attrs?.id;
+ return typeof id === 'string' && id ? id : null;
+}
+
+export function commentMarkAt(marks: readonly PMMark[]): PMMark | null {
+ return marks.find((m) => m.type.name === 'comment') ?? null;
+}
+
+// Every comment range in document order. Adjacent text nodes sharing an id merge into
+// one; an id split by a paragraph boundary yields several, which is why the commands
+// walk all of them and the pane (`comments`) keeps only the first per id.
+export function commentRanges(doc: PMNode): CommentRange[] {
+ const out: CommentRange[] = [];
+ let last: CommentRange | null = null;
+ doc.descendants((node, pos) => {
+ if (!node.isText) return true;
+ const mark = commentMarkAt(node.marks);
+ const id = mark ? commentIdOf(node.marks) : null;
+ if (!id || !mark) { last = null; return false; }
+ if (last && last.id === id && last.to === pos) {
+ last.to = pos + node.nodeSize;
+ last.quote += node.text ?? '';
+ return false;
+ }
+ last = {
+ id,
+ author: String(mark.attrs.author ?? ''),
+ date: String(mark.attrs.date ?? ''),
+ text: String(mark.attrs.text ?? ''),
+ resolved: mark.attrs.resolved === true,
+ from: pos,
+ to: pos + node.nodeSize,
+ quote: node.text ?? '',
+ };
+ out.push(last);
+ return false;
+ });
+ return out;
+}
+
+// One entry per comment, in document order — what the pane lists.
+export function comments(doc: PMNode): CommentRange[] {
+ const seen = new Set();
+ return commentRanges(doc).filter((c) => !seen.has(c.id) && seen.add(c.id));
+}
+
+declare module '@tiptap/core' {
+ interface Commands {
+ comment: {
+ /** Comment the selection; returns false on an empty one, as in Word. */
+ addComment: (opts: { author: string; text: string }) => ReturnType;
+ updateComment: (id: string, patch: { text?: string; resolved?: boolean }) => ReturnType;
+ removeComment: (id: string) => ReturnType;
+ };
+ }
+}
+
+export const Comment = Mark.create({
+ name: 'comment',
+ // The range is fixed: typing at either end stays outside it, as in Word.
+ inclusive: false,
+ // Above the formatting marks, so the highlight wraps them rather than splitting them.
+ excludes: '',
+
+ addAttributes() {
+ return {
+ id: { default: '', parseHTML: (el) => el.getAttribute('data-comment') || '', renderHTML: (a) => ({ 'data-comment': String(a.id ?? '') }) },
+ author: { default: '', parseHTML: (el) => el.getAttribute('data-comment-author') || '', renderHTML: (a) => ({ 'data-comment-author': String(a.author ?? '') }) },
+ date: { default: '', parseHTML: (el) => el.getAttribute('data-comment-date') || '', renderHTML: (a) => ({ 'data-comment-date': String(a.date ?? '') }) },
+ text: { default: '', parseHTML: (el) => el.getAttribute('data-comment-text') || '', renderHTML: (a) => ({ 'data-comment-text': String(a.text ?? '') }) },
+ resolved: {
+ default: false,
+ parseHTML: (el) => el.getAttribute('data-comment-resolved') === 'true',
+ renderHTML: (a) => (a.resolved ? { 'data-comment-resolved': 'true' } : {}),
+ },
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: 'span[data-comment]' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['span', mergeAttributes(HTMLAttributes, { class: 'pm-comment' }), 0];
+ },
+
+ addCommands() {
+ return {
+ addComment:
+ ({ author, text }) =>
+ ({ state, commands }) => {
+ if (state.selection.empty) return false;
+ return commands.setMark(this.name, {
+ id: newCommentId(), author, text, date: new Date().toISOString(), resolved: false,
+ });
+ },
+ updateComment:
+ (id, patch) =>
+ ({ tr, state, dispatch }) => {
+ const ranges = commentRanges(state.doc).filter((c) => c.id === id);
+ if (!ranges.length) return false;
+ if (dispatch) {
+ for (const r of ranges) {
+ tr.removeMark(r.from, r.to, state.schema.marks.comment);
+ tr.addMark(r.from, r.to, state.schema.marks.comment.create({
+ id: r.id, author: r.author, date: r.date,
+ text: patch.text ?? r.text,
+ resolved: patch.resolved ?? r.resolved,
+ }));
+ }
+ }
+ return true;
+ },
+ removeComment:
+ (id) =>
+ ({ tr, state, dispatch }) => {
+ const ranges = commentRanges(state.doc).filter((c) => c.id === id);
+ if (!ranges.length) return false;
+ if (dispatch) for (const r of ranges) tr.removeMark(r.from, r.to, state.schema.marks.comment);
+ return true;
+ },
+ };
+ },
+});
diff --git a/src/lib/editor/extensions/paragraphStyle.ts b/src/lib/editor/extensions/paragraphStyle.ts
index ff29108..3683c3b 100644
--- a/src/lib/editor/extensions/paragraphStyle.ts
+++ b/src/lib/editor/extensions/paragraphStyle.ts
@@ -15,6 +15,9 @@ const DIRECT_ATTRS = [
'backgroundColor', 'borderTop', 'borderRight', 'borderBottom', 'borderLeft',
];
+// Marks Ctrl+M keeps: they carry content, not formatting.
+const KEPT_MARKS = new Set(['link', 'bookmark', 'comment']);
+
declare module '@tiptap/core' {
interface Commands {
paragraphStyle: {
@@ -68,8 +71,10 @@ export const ParagraphStyle = Extension.create<{ types: string[]; sheet: () => S
const start = state.selection.$from.start();
const end = state.selection.$to.end();
const run = chain().focus().setTextSelection({ from: start, to: end });
+ // Formatting only: a link, a bookmark and a comment are content, not formatting,
+ // and neither word processor drops them here.
for (const mark of Object.keys(editor.schema.marks)) {
- if (mark !== 'link') run.unsetMark(mark);
+ if (!KEPT_MARKS.has(mark)) run.unsetMark(mark);
}
for (const type of this.options.types) run.resetAttributes(type, DIRECT_ATTRS);
if (start !== end) run.setTextSelection({ from, to });
diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts
index a0a3a35..3ada1ae 100644
--- a/src/lib/export/docx.ts
+++ b/src/lib/export/docx.ts
@@ -3,6 +3,7 @@ import {
FootnoteReferenceRun, EndnoteReferenceRun,
TableOfContents,
Table, TableRow, TableCell, Header, Footer, PageNumber, SimpleField,
+ CommentRangeStart, CommentRangeEnd, CommentReference,
AlignmentType, LevelFormat, UnderlineType, BorderStyle, ShadingType,
WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType, SectionType, NumberFormat,
HorizontalPositionAlign, VerticalPositionRelativeFrom, HorizontalPositionRelativeFrom,
@@ -115,6 +116,31 @@ let docFormulas: FormulaDocx[] = [];
// module-level for the same reason as docFormulas.
let docNoteIds = new Map();
+// Comments, numbered in document order before the runs are built — Word's ids are
+// integers and word/comments.xml has to exist before the Document is constructed.
+type CommentDocx = { id: number; author: string; date: Date; text: string };
+let docComments: CommentDocx[] = [];
+let docCommentIds = new Map();
+
+function collectComments(node: TiptapNode): void {
+ for (const child of node.content ?? []) {
+ const mark = child.type === 'text' ? child.marks?.find(m => m.type === 'comment') : undefined;
+ const id = mark ? String(mark.attrs?.id ?? '') : '';
+ if (id && !docCommentIds.has(id)) {
+ docCommentIds.set(id, docComments.length);
+ const raw = String(mark!.attrs?.date ?? '');
+ const date = new Date(raw);
+ docComments.push({
+ id: docComments.length,
+ author: String(mark!.attrs?.author ?? ''),
+ date: Number.isNaN(date.getTime()) ? new Date() : date,
+ text: String(mark!.attrs?.text ?? ''),
+ });
+ }
+ if (!id) collectComments(child);
+ }
+}
+
// Sentinel wrapping a text box's index in a marker paragraph (same trick as
// export/odt.ts): the docx library can't emit DrawingML shapes, so buildDocx swaps
// the marker for a hand-built in a zip post-processing pass.
@@ -302,7 +328,8 @@ function textNodeToRuns(node: TiptapNode, force: TextProps): TextRun[] {
return [new TextRun({ children, ...props })];
}
-type Inline = TextRun | ImageRun | ExternalHyperlink | InternalHyperlink | SimpleField | Bookmark;
+type Inline = TextRun | ImageRun | ExternalHyperlink | InternalHyperlink | SimpleField | Bookmark
+ | CommentRangeStart | CommentRangeEnd;
// 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.
@@ -328,6 +355,13 @@ function crossRefField(node: TiptapNode): SimpleField {
return new SimpleField(`${verb} ${String(a.name ?? '')} \\h`, String(a.text ?? ''));
}
+// The Word comment id of a run's comment mark, or null.
+const commentIdOf = (node: TiptapNode): number | null => {
+ const id = node.marks?.find((m) => m.type === 'comment')?.attrs?.id;
+ const n = typeof id === 'string' ? docCommentIds.get(id) : undefined;
+ return n ?? null;
+};
+
const bookmarkNameOf = (node: TiptapNode): string | null => {
const name = node.marks?.find((m) => m.type === 'bookmark')?.attrs?.name;
return typeof name === 'string' && name ? name : null;
@@ -351,7 +385,23 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline
open.children.push(...runs);
};
+ // A comment's range brackets its runs; the reference run at the end is what Word
+ // draws the bubble from. Both live outside any bookmark, so they flush it first.
+ let openComment: number | null = null;
+ const closeComment = () => {
+ if (openComment === null) return;
+ flush();
+ out.push(new CommentRangeEnd(openComment), new TextRun({ children: [new CommentReference(openComment)] }));
+ openComment = null;
+ };
+ const openCommentAt = (id: number | null) => {
+ if (id === openComment) return;
+ closeComment();
+ if (id !== null) { flush(); out.push(new CommentRangeStart(id)); openComment = id; }
+ };
+
for (const node of content) {
+ openCommentAt(commentIdOf(node));
const bookmark = bookmarkNameOf(node);
if (node.type === 'text' && node.text) {
const runs = textNodeToRuns(node, force);
@@ -395,6 +445,7 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline
}
}
}
+ closeComment();
return out;
}
@@ -1334,6 +1385,9 @@ export async function buildDocx(
const noteBlocks = (docJson.content ?? []).filter((n) => n.type === 'noteSection')
.flatMap((n) => n.content ?? []);
docNoteIds = new Map();
+ docComments = [];
+ docCommentIds = new Map();
+ collectComments(docJson);
const notesByClass: Record> = { footnote: {}, endnote: {} };
for (const note of noteBlocks) {
const kind: NoteKind = note.attrs?.kind === 'endnote' ? 'endnote' : 'footnote';
@@ -1427,6 +1481,14 @@ export async function buildDocx(
numbering: { config: num.config },
...(Object.keys(notesByClass.footnote).length ? { footnotes: notesByClass.footnote } : {}),
...(Object.keys(notesByClass.endnote).length ? { endnotes: notesByClass.endnote } : {}),
+ // word/comments.xml; the range and reference components in the runs point at these ids.
+ ...(docComments.length
+ ? { comments: { children: docComments.map((c) => ({
+ id: c.id, author: c.author, date: c.date,
+ initials: c.author.split(/\s+/).map((w) => w[0] ?? '').join('').slice(0, 3),
+ children: c.text.split('\n').map((line) => new Paragraph({ children: [new TextRun(line)] })),
+ })) } }
+ : {}),
// A columns group is its own section; w:type=continuous describes the break
// BEFORE a section, so it goes on every section but the first.
sections: groups.map((g, i) => ({
diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts
index 8084bae..bb570e8 100644
--- a/src/lib/export/odt.ts
+++ b/src/lib/export/odt.ts
@@ -165,6 +165,12 @@ const XRF = '\uE015';
// applyHfPostProcess rewrites it to in styles.xml. U+E016.
const CHP = '\uE016';
+// Sentinels bracketing a comment's text (CMS{i}CMS \u2026 CME{i}CME), spliced into the run
+// text like the bookmark pair; applyComments rewrites them to and
+// . U+E018/U+E019.
+const CMS = '\uE018';
+const CME = '\uE019';
+
// Sentinels for footnotes/endnotes (notes.ts): FNT A{i} FNT marks the anchor in the
// running text, FNT B{i} FNT opens the note's own hoisted paragraph. applyNotes cuts
// the body out and splices it into a at the anchor. U+E017.
@@ -540,6 +546,48 @@ type CrossRefExport = { name: string; format: 'text' | 'page' };
const bookmarkNameOf = (node: TiptapNode): string =>
String(node.marks?.find((m) => m.type === 'bookmark')?.attrs?.name ?? '');
+// One comment, collected by replaceComments and emitted by applyComments.
+type CommentExport = { name: string; author: string; date: string; text: string; resolved: boolean };
+
+// Bracket each comment's text with the CMS/CME sentinels, the same shape as the bookmark
+// pair — spliced into the run text, so a comment inside a cell or a list rides along too.
+function replaceComments(node: TiptapNode, out: CommentExport[]): TiptapNode {
+ if (!node.content?.length) return node;
+ const content: TiptapNode[] = [];
+ let openId = '';
+ let openIndex = -1;
+ let lastOfRange = -1;
+ const closeRange = () => {
+ if (!openId) return;
+ const run = content[lastOfRange];
+ content[lastOfRange] = { ...run, text: `${run.text ?? ''}${CME}${openIndex}${CME}` };
+ openId = '';
+ };
+ for (const child of node.content) {
+ const mark = child.type === 'text' ? child.marks?.find(m => m.type === 'comment') : undefined;
+ const id = mark ? String(mark.attrs?.id ?? '') : '';
+ if (id !== openId) closeRange();
+ if (id && !openId) {
+ openId = id;
+ openIndex = out.length;
+ out.push({
+ name: id,
+ author: String(mark!.attrs?.author ?? ''),
+ date: String(mark!.attrs?.date ?? ''),
+ text: String(mark!.attrs?.text ?? ''),
+ resolved: mark!.attrs?.resolved === true,
+ });
+ content.push({ ...child, text: `${CMS}${openIndex}${CMS}${child.text ?? ''}` });
+ lastOfRange = content.length - 1;
+ continue;
+ }
+ content.push(id ? child : replaceComments(child, out));
+ if (id) lastOfRange = content.length - 1;
+ }
+ closeRange();
+ return { ...node, content };
+}
+
// Bracket each bookmark's text with the BMS/BME sentinels and every `crossRef` node with
// an XRF-sentinel run; applyBookmarks resolves them after serialization. Spliced into the
// run text, not a run emitter, so both ride every odf-kit path (cells included).
@@ -3466,6 +3514,45 @@ function applyBookmarks(odtBytes: Uint8Array, refs: CrossRefExport[]): Uint8Arra
return rezipOdt(files);
}
+// CMS/CME sentinels → (author, date and body) and
+// . content.xml declares neither dc: nor loext:, so the two
+// namespaces are added on the root when a document actually has comments.
+function applyComments(odtBytes: Uint8Array, list: CommentExport[]): Uint8Array {
+ if (!list.length) return odtBytes;
+ const files = unzipSync(odtBytes);
+ const contentBytes = files['content.xml'];
+ if (!contentBytes) return odtBytes;
+
+ let content = strFromU8(contentBytes);
+ if (!content.includes(CMS)) return odtBytes;
+
+ content = content
+ .replace(new RegExp(`${CMS}(\\d+)${CMS}`, 'g'), (_m, idx: string) => {
+ const c = list[Number(idx)];
+ if (!c) return '';
+ // LibreOffice reads its own resolved flag off loext: and writes it either way;
+ // its comment bodies carry the pool's Comment paragraph style.
+ const body = c.text.split('\n').map(line => `${escapeXml(line)}`).join('');
+ return ``
+ + (c.author ? `${escapeXml(c.author)}` : '')
+ + (c.date ? `${escapeXml(c.date)}` : '')
+ + (body || '')
+ + '';
+ })
+ .replace(new RegExp(`${CME}(\\d+)${CME}`, 'g'), (_m, idx: string) => {
+ const c = list[Number(idx)];
+ return c ? `` : '';
+ })
+ .replace(
+ ' at the anchor sentinel. Runs last of the content.xml passes, so the note
// text carries everything the earlier ones resolved inside it.
@@ -3582,7 +3669,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const formulas: FormulaExport[] = [];
const crossRefs: CrossRefExport[] = [];
const notes: NoteExport[] = [];
- const sentinels = replaceBookmarks(replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceNotes(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns), notes)))), images), dateFields), formulas), crossRefs);
+ const commentList: CommentExport[] = [];
+ const sentinels = replaceComments(replaceBookmarks(replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceNotes(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns), notes)))), images), dateFields), formulas), crossRefs), commentList);
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;
@@ -3785,7 +3873,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const withFormulas = applyFormulas(withDateFields, formulas);
const withTextBoxes = applyTextBoxes(withFormulas, textBoxes);
const withColumns = applyColumns(withTextBoxes, columns);
- const withBookmarks = applyBookmarks(withColumns, crossRefs);
+ const withBookmarks = applyComments(applyBookmarks(withColumns, crossRefs), commentList);
// After every other content.xml pass: the note's own runs are hoisted into the body,
// so they must be fully resolved before they are cut out and moved into .
const withNotes = applyNotes(withBookmarks, notes);
diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts
index 7d01e88..991e825 100644
--- a/src/lib/i18n/locales/de.ts
+++ b/src/lib/i18n/locales/de.ts
@@ -40,6 +40,17 @@ const de: Messages = {
urls: '',
},
},
+ comments: {
+ title: 'Kommentare',
+ empty: 'Noch keine Kommentare. Text markieren und einen hinzufügen.',
+ edit: 'Bearbeiten',
+ resolve: 'Erledigt',
+ reopen: 'Wieder öffnen',
+ prompt: 'Kommentar zum markierten Text:',
+ needsSelection: 'Zuerst den zu kommentierenden Text markieren',
+ showPane: 'Kommentare anzeigen',
+ newComment: 'Neuer Kommentar',
+ },
appearance: {
language: 'Sprache',
title: 'Darstellung',
@@ -235,6 +246,7 @@ const de: Messages = {
bookmarkNeedsSelection: 'Markiere den Text, den das Lesezeichen umfassen soll',
bookmarkNotInHf: 'Lesezeichen sind in Kopf-/Fußzeilen nicht verfügbar',
insertCrossRef: 'Querverweis einfügen',
+ newComment: 'Neuer Kommentar',
crossRefNeedsBookmark: 'Erst ein Lesezeichen setzen — ein Querverweis zeigt darauf',
insertFootnote: 'Fußnote einfügen',
insertEndnote: 'Endnote einfügen',
@@ -389,6 +401,7 @@ const de: Messages = {
removeLink: 'Link entfernen',
insertBookmark: 'Lesezeichen…',
insertCrossRef: 'Querverweis…',
+ newComment: 'Kommentar…',
clearFormatting: 'Direkte Formatierung löschen',
},
about: {
diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts
index 983a160..3dc168e 100644
--- a/src/lib/i18n/locales/en.ts
+++ b/src/lib/i18n/locales/en.ts
@@ -41,6 +41,17 @@ const en = {
urls: '',
},
},
+ comments: {
+ title: 'Comments',
+ empty: 'No comments yet. Select some text and add one.',
+ edit: 'Edit',
+ resolve: 'Resolve',
+ reopen: 'Reopen',
+ prompt: 'Comment on the selected text:',
+ needsSelection: 'Select the text to comment on first',
+ showPane: 'Show comments',
+ newComment: 'New comment',
+ },
appearance: {
language: 'Language',
title: 'Appearance',
@@ -239,6 +250,7 @@ const en = {
bookmarkNeedsSelection: 'Select the text the bookmark should cover',
bookmarkNotInHf: 'Bookmarks are not available in headers/footers',
insertCrossRef: 'Insert cross-reference',
+ newComment: 'New comment',
crossRefNeedsBookmark: 'Add a bookmark first — a cross-reference points at one',
insertFootnote: 'Insert footnote',
insertEndnote: 'Insert endnote',
@@ -393,6 +405,7 @@ const en = {
removeLink: 'Remove Link',
insertBookmark: 'Bookmark…',
insertCrossRef: 'Cross-reference…',
+ newComment: 'Comment…',
clearFormatting: 'Clear Formatting',
},
about: {
diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts
index cc32c0e..1b0ec6b 100644
--- a/src/lib/import/docx.ts
+++ b/src/lib/import/docx.ts
@@ -67,6 +67,10 @@ type Ctx = {
// Bookmarks open at this point of the walk (w:id → name). A range may start beside a
// paragraph and end inside a later one, so the state outlives both walks.
openBookmarks: Map;
+ // Comment ranges currently open, by Word's numeric id → the mark's attrs.
+ openComments: Map>;
+ // word/comments.xml, by id.
+ commentDefs: Map>;
// word/footnotes.xml and endnotes.xml by w:id, and the notes the body referenced, in
// anchor order — the editor keeps them in one section at the document end (notes.ts).
noteParts: Record>;
@@ -168,7 +172,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
const sectPr = fc(body, 'sectPr');
const contentWidthCm = sectionContentWidthCm(sectPr);
const leftMarginCm = twipToCm(intAttr(fc(sectPr, 'pgMar'), W, 'left') ?? 1440);
- const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), notes: [], noteParts: {
+ const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), openComments: new Map(), commentDefs: docxComments(files), notes: [], noteParts: {
footnote: noteParts(files, 'footnotes', 'footnote'),
endnote: noteParts(files, 'endnotes', 'endnote'),
} };
@@ -388,6 +392,29 @@ function trackBookmark(el: Element, ctx: Ctx): boolean {
return false;
}
+// w:commentRangeStart/-End bracket the annotated runs; w:commentReference marks the
+// anchor and needs no handling. Returns true when the element was one of the three.
+function trackComment(el: Element, ctx: Ctx): boolean {
+ if (el.localName === 'commentRangeStart') {
+ const id = el.getAttributeNS(W, 'id');
+ const def = id ? ctx.commentDefs.get(id) : undefined;
+ if (id && def) ctx.openComments.set(id, def);
+ return true;
+ }
+ if (el.localName === 'commentRangeEnd') {
+ const id = el.getAttributeNS(W, 'id');
+ if (id) ctx.openComments.delete(id);
+ return true;
+ }
+ return el.localName === 'commentReference';
+}
+
+// A mark can hold one comment, so overlapping ranges collapse to the outermost.
+function openCommentMark(ctx: Ctx): Mark | null {
+ const attrs = ctx.openComments.values().next().value;
+ return attrs ? { type: 'comment', attrs } : null;
+}
+
// A mark can hold one bookmark, so overlapping ranges collapse to the outermost.
function openBookmarkMark(ctx: Ctx): Mark | null {
const name = ctx.openBookmarks.values().next().value;
@@ -440,7 +467,7 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
for (const el of children) {
if (el.namespaceURI !== W) continue;
- if (trackBookmark(el, ctx)) continue;
+ if (trackBookmark(el, ctx) || trackComment(el, ctx)) continue;
if (el.localName === 'p') {
// A paragraph owned by a TOC field is skipped; the field emits one node.
const startedInToc = tocState.tocDepth >= 0;
@@ -1100,9 +1127,10 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
const pushText = (text: string, marks: Mark[]) => {
if (!text) return;
- // The one-paragraph header/footer schema has no bookmark mark.
+ // The one-paragraph header/footer schema has neither a bookmark nor a comment mark.
const bookmark = hfFields ? null : openBookmarkMark(ctx);
- const all = bookmark ? [...marks, bookmark] : marks;
+ const comment = hfFields ? null : openCommentMark(ctx);
+ const all = [...marks, ...(bookmark ? [bookmark] : []), ...(comment ? [comment] : [])];
const node: Node = { type: 'text', text };
if (all.length) node.marks = all;
out.push(node);
@@ -1242,7 +1270,7 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
continue;
}
if (el.namespaceURI !== W) continue;
- if (trackBookmark(el, ctx)) continue;
+ if (trackBookmark(el, ctx) || trackComment(el, ctx)) continue;
switch (el.localName) {
case 'r': handleRun(el); break;
case 'hyperlink': {
@@ -2246,6 +2274,28 @@ function docAutoHyphenation(files: Record): boolean {
}
}
+// word/comments.xml → the comment mark's attrs, by Word's numeric id.
+function docxComments(files: Record): Map> {
+ const out = new Map>();
+ const bytes = files['word/comments.xml'];
+ if (!bytes) return out;
+ let doc: Document;
+ try { doc = parseXml(strFromU8(bytes)); } catch { return out; }
+ for (const c of Array.from(doc.getElementsByTagNameNS(W, 'comment'))) {
+ const id = c.getAttributeNS(W, 'id');
+ if (!id) continue;
+ out.set(id, {
+ id: `w${id}`,
+ author: c.getAttributeNS(W, 'author') ?? '',
+ date: c.getAttributeNS(W, 'date') ?? '',
+ text: fcAll(c, 'p').map((p) => p.textContent ?? '').join('\n').trim(),
+ // Word 365 marks a resolved comment in commentsExtended.xml; not read.
+ resolved: false,
+ });
+ }
+ return out;
+}
+
// docProps/core.xml → the document's descriptive properties (Word's File ▸ Info).
const DC_NS = 'http://purl.org/dc/elements/1.1/';
const CP_NS = 'http://schemas.openxmlformats.org/package/2006/metadata/core-properties';
diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts
index 6ac34b9..0f96a55 100644
--- a/src/lib/import/odt.ts
+++ b/src/lib/import/odt.ts
@@ -23,6 +23,7 @@ import type { HfDoc, HfSet } from '../storage/headerFooter';
import type { NoteKind, NoteSettings } from '../storage/noteSettings';
import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties';
import { clampPageStart, type PageNumbering } from '../storage/pageNumbering';
+import { newCommentId } from '../editor/extensions/comment';
import type { EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
@@ -125,6 +126,8 @@ type Ctx = {
// Bookmark ranges open at this point of the walk, outermost first; a range may end in
// a later paragraph than it started in, so the set outlives one convertInline call.
openBookmarks: Set;
+ // Ranged comments currently open, by their office:name — same shape as openBookmarks.
+ openComments: 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 }[];
@@ -600,7 +603,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, masterPages: [], masterBlocks: new Map(), openBookmarks: new Set(), notes: [] };
+ const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, masterPages: [], masterBlocks: new Map(), openBookmarks: new Set(), openComments: 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)));
@@ -1630,6 +1633,8 @@ function numberStyleKind(tokens: Token[]): 'date' | 'time' | null {
function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: BlockDefaults, hfFields = false): Node[] {
const out: Node[] = [];
+ // Point comments, with the run index they were seen at — resolved after the walk.
+ const points: { at: number; attrs: Record }[] = [];
// The named character style of the enclosing span, if any: its formatting belongs to
// the style, so the run only keeps what goes beyond it.
@@ -1657,6 +1662,8 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
// The one-paragraph header/footer schema has no bookmark mark.
const bookmark = hfFields ? undefined : ctx.openBookmarks.values().next().value;
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 node: Node = { type: 'text', text: clean };
if (marks.length) node.marks = marks;
out.push(node);
@@ -1807,17 +1814,54 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl
else if (conv?.block) ctx.pendingBlocks.push(conv.block);
continue;
}
- if (e.namespaceURI === NS.office && e.localName === 'annotation') {
- ctx.warnings.add('Comments were removed');
+ if (e.namespaceURI === NS.office && (e.localName === 'annotation' || e.localName === 'annotation-end')) {
+ // A ranged comment brackets its text with a named annotation/annotation-end pair;
+ // a point one (LibreOffice's comment with nothing selected) carries no name and
+ // sits after the text it was made on. The zone schema has no comment mark.
+ if (hfFields) continue;
+ if (e.localName === 'annotation-end') {
+ const name = e.getAttributeNS(NS.office, 'name');
+ if (name) ctx.openComments.delete(name);
+ continue;
+ }
+ const attrs = odfCommentAttrs(e);
+ const name = e.getAttributeNS(NS.office, 'name');
+ if (name) ctx.openComments.set(name, attrs);
+ else points.push({ at: out.length, attrs });
continue;
}
}
};
walk(root, baseProps);
+ // A point comment attaches to the run before it, else to the one after — it is anchored
+ // at a caret, and the editor's comment is a mark that needs a range.
+ for (const p of points) {
+ const target = (out[p.at - 1]?.type === 'text' ? out[p.at - 1] : out[p.at]) as Node | undefined;
+ if (target?.type !== 'text') continue;
+ target.marks = [...(target.marks ?? []), { type: 'comment', attrs: p.attrs }];
+ }
return mergeAdjacentText(out);
}
+// → the comment mark's attrs. `office:name` is the id where the file
+// has one (a ranged comment); a point one gets a minted id.
+function odfCommentAttrs(el: Element): Record {
+ const child = (ns: string, name: string) =>
+ Array.from(el.children).find(c => c.namespaceURI === ns && c.localName === name);
+ const body = Array.from(el.children)
+ .filter(c => c.namespaceURI === NS.text && c.localName === 'p')
+ .map(p => p.textContent ?? '')
+ .join('\n');
+ return {
+ id: el.getAttributeNS(NS.office, 'name') || newCommentId(),
+ author: child(NS.dc, 'creator')?.textContent?.trim() ?? '',
+ date: child(NS.dc, 'date')?.textContent?.trim() ?? '',
+ text: body.trim(),
+ resolved: el.getAttributeNS(NS.loext, 'resolved') === 'true',
+ };
+}
+
// text + text:s + text would otherwise emit three identically-marked nodes.
function mergeAdjacentText(nodes: Node[]): Node[] {
const out: Node[] = [];
diff --git a/src/styles/editor.css b/src/styles/editor.css
index c6787ab..d81bbd8 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -994,6 +994,19 @@
text-decoration-skip-ink: none;
}
+/* A commented range, as Word paints it: a tinted band the text still reads through,
+ and a heavier tint on the one the pane has selected. A resolved comment keeps only a
+ faint underline, the way both word processors grey a handled one out. */
+.tiptap .pm-comment {
+ background: rgba(255, 200, 60, 0.28);
+ border-bottom: 1px solid rgba(200, 140, 0, 0.7);
+}
+
+.tiptap .pm-comment[data-comment-resolved='true'] {
+ background: none;
+ border-bottom-color: rgba(140, 140, 140, 0.6);
+}
+
/* Print styles: proper A4 pagination */
@media print {
.editor {
diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts
index a7d4fb7..1697a8c 100644
--- a/tests/roundtrip.test.ts
+++ b/tests/roundtrip.test.ts
@@ -1837,3 +1837,45 @@ describe('Leg 15: page numbering (format + start value)', () => {
check('no page-number', !strFromU8(files['content.xml']).includes('style:page-number='));
});
});
+
+describe('Leg 16: comments (office:annotation / w:comment)', () => {
+ const CM = (text: string, attrs: N): N => T(text, { type: 'comment', attrs });
+ const attrs = { id: 'c1', author: 'A. Muster', date: '2026-08-14T10:00:00.000Z', text: 'Bitte prüfen', resolved: false };
+ const cDoc: N = { type: 'doc', content: [P(null, T('vor '), CM('markiert', attrs), T(' nach'))] };
+
+ it('ODT: a named annotation brackets the text and comes back', async () => {
+ const bytes = await buildOdt(cDoc, margins, 'portrait');
+ const content = strFromU8(unzipSync(bytes)['content.xml']);
+ check('annotation opens the range', content.includes('A. Muster'));
+ check('carries the body', content.includes('Bitte prüfen'));
+ check('closes the range', content.includes(''));
+ check('no leftover sentinel', !/[]/.test(content));
+
+ const back = importOdt(bytes).content as N;
+ const runs = back.content[0].content as N[];
+ const marked = runs.find((r: N) => r.marks?.some((m: N) => m.type === 'comment'));
+ check('the marked run is the annotated text', marked?.text === 'markiert', runs.map((r: N) => r.text));
+ const m = marked?.marks.find((x: N) => x.type === 'comment');
+ check('attrs round-trip', m?.attrs.author === 'A. Muster' && m?.attrs.text === 'Bitte prüfen', m?.attrs);
+ check('neighbours stay uncommented', runs.filter((r: N) => r.marks?.some((x: N) => x.type === 'comment')).length === 1);
+ });
+
+ it('DOCX: the range brackets the runs and word/comments.xml holds the body', async () => {
+ const bytes = await buildDocx(cDoc, margins, 'portrait');
+ const files = unzipSync(bytes);
+ const doc = strFromU8(files['word/document.xml']);
+ check('range start', doc.includes(' r.marks?.some((m: N) => m.type === 'comment'));
+ check('the marked run is the annotated text', marked?.text === 'markiert', runs.map((r: N) => r.text));
+ const m = marked?.marks.find((x: N) => x.type === 'comment');
+ check('author and body round-trip', m?.attrs.author === 'A. Muster' && m?.attrs.text === 'Bitte prüfen', m?.attrs);
+ });
+});