feat: comments, with a reviewing pane

The comment is a mark carrying its own author, date, body and resolved
flag — the bookmark pattern, so no second store and no schema node. The
pane lists them in document order and jumps to the text each marks.
ODF gets office:annotation + office:annotation-end and LibreOffice's
loext:resolved, DOCX the range pair plus word/comments.xml; a point
comment, which LibreOffice writes without a range, attaches to the run
before it. Ctrl+M no longer strips a comment, a bookmark or a link.
This commit is contained in:
Steffen Becker committed 2026-08-14 01:23:54 +02:00
1 parent a7092cfd1d
commit b2d1ddfb66
20 files changed
+772 -16

No files matched your search

+1 -1
View File
@@ -71,6 +71,7 @@
- Date and time fields: picker with 7 date and 4 time formats (live samples) and an "update automatically" toggle — fixed fields keep the inserted moment, auto fields refresh on open. Round-trips to ODF `text:date`/`text:time` (minted `number:date/time-style`) and DOCX `DATE`/`TIME` fields; the field carries the surrounding font
- Page numbering options (Layout ▸ Page numbers): the five formats both word processors offer (1 / i / I / a / A) and a start value. Round-trips to ODF `style:num-format` on the page layout plus `style:page-number` on the first paragraph — where LibreOffice keeps the start, ODF having no document-level one — and to Word's `w:pgNumType`
- Table of contents: generated from headings (H1–H5) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field
- Comments: annotate a selection (Review tab or the context menu) and work through them in the reviewing pane — edit, resolve, remove, click one to jump to the text it marks. Round-trips to ODF `office:annotation`/`office:annotation-end` (with LibreOffice's `loext:resolved`) and to Word's `w:commentRangeStart`/`-End` + `word/comments.xml`; a LibreOffice point comment, which has no range, attaches to the run before it
- Footnotes and endnotes (Ctrl+Alt+F / Ctrl+Alt+D): a footnote is drawn at the foot of the page its anchor sits on, with the separator line above it and the body text moved up to make room; endnotes are collected on their own page at the document end. Notes renumber themselves as anchors move, and deleting an anchor deletes its note. An options dialog covers numbering format, start value, restart, position, prefix/suffix, the two styles and the separator's length, thickness, spacing, alignment and colour. Round-trips to ODF `text:note` + `text:notes-configuration` and to Word's `word/footnotes.xml`/`endnotes.xml` + `w:footnotePr`
- Formulas: dialog with a LaTeX field and live preview; only the LaTeX is stored, the MathML the browser typesets, the ODF formula object and the OMML are derived from it. Inline or as a centered display line, double-click to edit. Round-trips as a real embedded ODF formula object (`draw:object` + `Formula{n}/content.xml`, our LaTeX kept in the MathML `annotation`) and as Word's `m:oMath`. STIX Two Math is bundled, so stretched brackets and ∑/∫ look the same on every platform
- Hyperlinks: create / edit / remove (toolbar + Ctrl+K), Ctrl/Cmd+click to open, hover hint showing the URL; ODF `text:a` round-trip
@@ -122,7 +123,6 @@
The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08.
**Content an imported document loses**
- Comments / annotations: dropped on import (`office:annotation`, `w:comment`)
- Track changes / revisions: no recording, no accept/reject, no author colors (`text:tracked-changes`, `w:ins`/`w:del`); imported revisions are flattened to their current state
- Charts are **drawn** from the file (`import/chart.ts`: DrawingML `chartN.xml` and ODF `chart:chart`), but as a picture, not a chart object — a re-export carries the drawing and the numbers behind it are no longer editable. The same holds for an **EMF** metafile (`import/emf.ts`): it is drawn, but as the SVG picture it was rebuilt into, and only from the record set a plot consists of — a hatched brush, a clipping region or a rotated bitmap is skipped. **WMF/SVM** metafiles and OLE objects still keep their box and a placeholder label, and export writes that back out: WMF is a different (16-bit) record format, SVM is StarOffice-proprietary, and an OLE object cannot be rendered without its application
- Shapes beyond rect / round-rect / ellipse: lines, arrows, connectors, polygons, freeform, and rotated shape text (dropped on import with a warning)
+35
View File
@@ -44,6 +44,8 @@
import UiLanguagePicker from './lib/components/UiLanguagePicker.svelte';
import AboutDialog from './lib/components/AboutDialog.svelte';
import DocPropertiesDialog from './lib/components/DocPropertiesDialog.svelte';
import CommentsPane from './lib/components/CommentsPane.svelte';
import { OPEN_COMMENT_EVENT } from './lib/editor/extensions/comment';
import AutoCorrectDialog from './lib/components/AutoCorrectDialog.svelte';
import StyleManagerDialog from './lib/components/StyleManagerDialog.svelte';
import NoteOptionsDialog from './lib/components/NoteOptionsDialog.svelte';
@@ -157,6 +159,7 @@
let hyphenate = $state(loadHyphenation());
let pageNumbering: PageNumbering = $state(loadPageNumbering());
let autoCorrectOpen = $state(false);
let commentsOpen = $state(false);
// Width of the hidden mirror span (below), so the title input grows/shrinks
// with its text instead of sitting in a fixed-width box.
@@ -616,6 +619,22 @@
}
}
// Word's New Comment: the selected text is annotated and the pane opens on it. A
// prompt rather than a dialog — the pane is where a comment is really written.
function addComment() {
if (!editor || editor.state.selection.empty) return;
const text = prompt(t().comments.prompt, '');
if (text === null) return;
editor.chain().focus().addComment({ author: docProps.author.trim(), text: text.trim() }).run();
commentsOpen = true;
}
$effect(() => {
const open = () => addComment();
window.addEventListener(OPEN_COMMENT_EVENT, open);
return () => window.removeEventListener(OPEN_COMMENT_EVENT, open);
});
async function handleOpen() {
if (!editor) return;
if (fsSupported) {
@@ -918,6 +937,9 @@
onAbout={() => (aboutOpen = true)}
onDocProperties={() => (docPropsOpen = true)}
onAutoCorrect={() => (autoCorrectOpen = true)}
onNewComment={addComment}
{commentsOpen}
onToggleComments={() => (commentsOpen = !commentsOpen)}
/>
</div>
{:else}
@@ -1165,6 +1187,7 @@
class="file-input"
onchange={handleImportFile}
/>
<div class="editor-row">
<EditorComponent
{documentEpoch}
{pageRtl}
@@ -1198,6 +1221,10 @@
orientation={pageOrientation}
{pageFormat}
/>
{#if commentsOpen}
<CommentsPane {editor} {tick} author={docProps.author} onClose={() => (commentsOpen = false)} />
{/if}
</div>
{#if findOpen && editor}
<div class="find-bar-anchor" style="top: {toolbarRegionH + 8}px;">
<FindReplaceBar {editor} {tick} mode={findMode} focusNonce={findNonce} onClose={closeFind} />
@@ -1279,6 +1306,14 @@
position: relative;
}
/* The document scroller and the comments pane side by side; min-height keeps the
scroller from growing past the row instead of scrolling inside it. */
.editor-row {
display: flex;
flex: 1;
min-height: 0;
}
/* Find & Replace bar: floats at the top-right of the editing area, just under the
toolbar (top is set inline from the toolbar height). Below the toolbar's z-index. */
.find-bar-anchor {
+9
View File
@@ -65,6 +65,15 @@ One header and one footer (`HfDoc` = a single-paragraph TipTap doc per zone, per
- **Fields:** the bar beside the active zone inserts the `pageField.ts` atoms — page number, page count, chapter (the running head, level 1). Each is patched per page, so the same zone shows a different value on every page.
- **Rendering:** the zone's own font is the document's **default paragraph style** (`styleCss` gives `.paper .hf-layer .hf-zone` its text half): both formats base the Header/Footer style on it, so a file whose body is Arial 10pt has an Arial footer, not the editor's serif. `HeaderFooterLayer.svelte` mounts inside the scaled `.paper` (like `.band-layer`), positioning per-page zone boxes in unscaled doc px. A zone's out-of-flow frame (`wrap` ≠ inline) is hidden in the box and painted per page by `.hf-bg-layer` at `z-index: -1` — hence `.paper`, not `.tiptap`, paints the page surface, so the image lands between the sheet and the body text. Inactive zones render as static HTML (`generateHTML` + `hfExtensions`); the active zone hosts the single live TipTap editor. `App.svelte`'s `activeEditor`/`activeTick` route the top toolbars to `hfEditor` while a zone is active, so all body formatting works on the header/footer with no toolbar changes.
## Comments (`CommentsPane.svelte`)
Word's Reviewing Pane, not its margin bubbles: the page here fills its own scroller, so a
pane beside it (`.editor-row` in `App.svelte`) keeps the sheet at its true width. It lists
`comments(editor.state.doc)` in document order — click a card to select the annotated
range, edit / resolve / remove in place. `App.svelte` owns the New-comment prompt, which
both the Review tab and the context menu (`OPEN_COMMENT_EVENT`) fire; the author comes
from the document properties.
## Debug tooling (dev only)
In dev builds a **Debug** button (`App.svelte`) downloads a JSON snapshot combining `getPageBreakDebug(view)` (leaves, placements, rendered spacers, table-break bands, live overlay geometry), `getTableCellDebug(view)` (per cell: the `verticalAlign` attr beside the computed value, cell/row height, the gap above and below the content, and every block's margins/`--space-before` — cell alignment reads as broken whenever the content fills the box, so the numbers that decide that travel with it), the style sheet + spacing model, and `getColorDebug(editor)` (selection marks, text runs, document colors, DOM spans).
+187
View File
@@ -0,0 +1,187 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { TextSelection } from '@tiptap/pm/state';
import { comments, type CommentRange } from '../editor/extensions/comment';
import { t } from '../i18n/i18n.svelte';
// Word's Reviewing Pane: every comment in document order, click to jump to the text it
// annotates. Margin bubbles are what Word draws beside the page, but the page here fills
// its own scroller — a pane keeps the sheet at its true width.
let { editor, tick, author, onClose }: {
editor: Editor | null;
tick: number;
author: string;
onClose: () => void;
} = $props();
let list = $derived.by<CommentRange[]>(() => {
if (tick < 0 || !editor) return [];
return comments(editor.state.doc);
});
let editingId = $state<string | null>(null);
let draft = $state('');
function jumpTo(c: CommentRange) {
if (!editor) return;
const { state, view } = editor;
const tr = state.tr.setSelection(TextSelection.create(state.doc, c.from, c.to)).scrollIntoView();
view.dispatch(tr);
view.focus();
}
function startEdit(c: CommentRange) {
editingId = c.id;
draft = c.text;
}
function commit(id: string) {
editor?.chain().focus().updateComment(id, { text: draft.trim() }).run();
editingId = null;
}
// The stored ISO date, in the reader's locale — an imported file may carry any format.
function when(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
}
</script>
<aside class="comments-pane" aria-label={t().comments.title}>
<header>
<span class="title">{t().comments.title}</span>
<span class="count">{list.length}</span>
<button class="close" onclick={onClose} aria-label={t().common.close} title={t().common.close}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2.5 2.5l7 7M9.5 2.5l-7 7" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</button>
</header>
{#if !list.length}
<p class="empty">{t().comments.empty}</p>
{/if}
<ul>
{#each list as c (c.id)}
<li class:resolved={c.resolved}>
<button class="card" onclick={() => jumpTo(c)}>
<div class="meta">
<b>{c.author || author}</b>
<span>{when(c.date)}</span>
</div>
<div class="quote">{c.quote}</div>
</button>
{#if editingId === c.id}
<textarea
rows="3"
bind:value={draft}
onkeydown={(e) => { if (e.key === 'Escape') editingId = null; }}
></textarea>
<div class="actions">
<button onclick={() => (editingId = null)}>{t().common.cancel}</button>
<button class="primary" onclick={() => commit(c.id)}>{t().common.ok}</button>
</div>
{:else}
<p class="body">{c.text}</p>
<div class="actions">
<button onclick={() => startEdit(c)}>{t().comments.edit}</button>
<button onclick={() => editor?.chain().focus().updateComment(c.id, { resolved: !c.resolved }).run()}>
{c.resolved ? t().comments.reopen : t().comments.resolve}
</button>
<button onclick={() => editor?.chain().focus().removeComment(c.id).run()}>{t().common.remove}</button>
</div>
{/if}
</li>
{/each}
</ul>
</aside>
<style>
.comments-pane {
display: flex;
flex-direction: column;
flex: 0 0 260px;
min-width: 0;
overflow-y: auto;
border-left: 1px solid var(--color-border);
background: var(--color-surface);
font-family: var(--font-sans);
font-size: 0.8rem;
}
header {
display: flex;
align-items: center;
gap: 6px;
position: sticky;
top: 0;
padding: 8px 10px;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.title { flex: 1; font-weight: 600; }
.count { color: var(--color-text-muted); }
.close, .actions button {
border: 1px solid transparent;
border-radius: var(--radius);
background: none;
color: var(--color-text-muted);
padding: 2px 6px;
font: inherit;
cursor: pointer;
}
.close:hover, .actions button:hover { background: var(--color-btn-hover); color: var(--color-text); }
.actions .primary { border-color: var(--color-primary); background: var(--color-primary); color: #fff; }
.empty { padding: 12px 10px; color: var(--color-text-muted); }
ul { list-style: none; margin: 0; padding: 0; }
li {
padding: 8px 10px;
border-bottom: 1px solid var(--color-border);
}
li.resolved { opacity: 0.55; }
.card {
display: block;
width: 100%;
border: none;
background: none;
color: inherit;
padding: 0;
text-align: left;
font: inherit;
cursor: pointer;
}
.meta { display: flex; justify-content: space-between; gap: 8px; color: var(--color-text-muted); }
.meta b { color: var(--color-text); }
.quote {
margin: 3px 0;
padding-left: 6px;
border-left: 2px solid rgba(200, 140, 0, 0.7);
color: var(--color-text-muted);
/* Two lines of the annotated text is enough to recognise it. */
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.body { margin: 4px 0; white-space: pre-wrap; }
textarea {
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 4px 6px;
font: inherit;
resize: vertical;
}
.actions { display: flex; gap: 4px; justify-content: flex-end; }
</style>
+5 -2
View File
@@ -58,7 +58,7 @@
onSelectTheme,
docxBusy = false,
pdfBusy = false,
onNew, onOpen, onSave, onSaveAs, onSaveDocx, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onAutoCorrect,
onNew, onOpen, onSave, onSaveAs, onSaveDocx, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onAutoCorrect, onNewComment, commentsOpen = false, onToggleComments,
}: {
editor: Editor | null;
tick: number;
@@ -99,6 +99,9 @@
onAbout?: () => void;
onDocProperties?: () => void;
onAutoCorrect?: () => void;
onNewComment?: () => void;
commentsOpen?: boolean;
onToggleComments?: () => void;
} = $props();
const TABS = ['home', 'insert', 'layout', 'references', 'review', 'view'] as const;
@@ -298,7 +301,7 @@
{:else if tab === 'references'}
<ReferencesTab {editor} {tick} {hfActive} {onNoteOptions} />
{:else if tab === 'review'}
<ReviewTab {editor} {tick} {documentLanguage} {onLanguage} {onAutoCorrect} />
<ReviewTab {editor} {tick} {documentLanguage} {onLanguage} {onAutoCorrect} {onNewComment} {commentsOpen} {onToggleComments} />
{:else if tab === 'view'}
<ViewTab bind:showRuler bind:showFormattingMarks {zoom} {onZoom} {onDebugDump} />
{:else if tab === 'tableDesign' || tab === 'tableLayout'}
+2
View File
@@ -78,6 +78,8 @@ export const STROKED = {
toc: ['M2 3h6M2 6.5h5M2 10h6M2 13.5h4', 'M11 3h3M10 6.5h4M11 10h3M10 13.5h4'],
tocLevels: ['M2.5 3.5h11M4.5 7h9M6.5 10.5h7M8.5 14h5'],
wordCount: ['M2.5 2.5h11v11h-11z', 'M5 6h6M5 9h4'],
// A speech bubble with its tail, the shape both word processors use for a comment.
comment: ['M2 3.25h12v7.5H7.5L4.5 13.5v-2.75H2z', 'M4.75 6h6.5M4.75 8.25h4'],
// A word broken at a hyphen: two stubs with the dash between them.
hyphenation: ['M2 8h3M11 8h3', 'M6.5 8h3', 'M2 4.5h12M2 11.5h6'],
// An A with a tick beside it: the letter AutoCorrect fixes, corrected.
@@ -8,14 +8,19 @@
import type { DocumentLanguage } from '../../../storage/documentLanguage';
import { t } from '../../../i18n/i18n.svelte';
let { editor, tick, documentLanguage, onLanguage, onAutoCorrect }: {
let { editor, tick, documentLanguage, onLanguage, onAutoCorrect, onNewComment, commentsOpen = false, onToggleComments }: {
editor: Editor | null;
tick: number;
documentLanguage: DocumentLanguage;
onLanguage: (code: DocumentLanguage) => void;
onAutoCorrect?: () => void;
onNewComment?: () => void;
commentsOpen?: boolean;
onToggleComments?: () => void;
} = $props();
let hasSelection = $derived(tick >= 0 && !!editor && !editor.state.selection.empty);
let stats = $derived.by<TextStats>(() => {
if (tick < 0 || !editor) return { words: 0, charsWithSpaces: 0, charsNoSpaces: 0, paragraphs: 0 };
const { doc } = editor.state;
@@ -73,6 +78,26 @@
<div class="ribbon-sep"></div>
<RibbonGroup label={t().comments.title}>
<RibbonButton
variant="big"
icon="comment"
label={t().comments.newComment}
title={hasSelection ? t().comments.newComment : t().comments.needsSelection}
disabled={!editor || !hasSelection}
onclick={() => onNewComment?.()}
/>
<RibbonButton
icon="comment"
label={t().comments.title}
title={t().comments.showPane}
active={commentsOpen}
onclick={() => onToggleComments?.()}
/>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.language}>
<div class="rb-captioned" use:captionClicks>
<LanguagePicker value={documentLanguage} onChange={onLanguage} />
+7
View File
@@ -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,
+4
View File
@@ -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,
+1
View File
@@ -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.
+153
View File
@@ -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<string>();
return commentRanges(doc).filter((c) => !seen.has(c.id) && seen.add(c.id));
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
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;
},
};
},
});
+6 -1
View File
@@ -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<ReturnType> {
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 });
+63 -1
View File
@@ -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<string, { id: number; kind: NoteKind }>();
// 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<string, number>();
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 <w:drawing><wps:wsp> 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<NoteKind, Record<string, { children: Paragraph[] }>> = { 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) => ({
+90 -2
View File
@@ -165,6 +165,12 @@ const XRF = '\uE015';
// applyHfPostProcess rewrites it to <text:chapter> 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 <office:annotation> and
// <office:annotation-end/>. 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 <text:note> 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 → <office:annotation> (author, date and body) and
// <office:annotation-end/>. 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 => `<text:p text:style-name="Comment">${escapeXml(line)}</text:p>`).join('');
return `<office:annotation office:name="${escapeXml(c.name)}" loext:resolved="${c.resolved}">`
+ (c.author ? `<dc:creator>${escapeXml(c.author)}</dc:creator>` : '')
+ (c.date ? `<dc:date>${escapeXml(c.date)}</dc:date>` : '')
+ (body || '<text:p text:style-name="Comment"/>')
+ '</office:annotation>';
})
.replace(new RegExp(`${CME}(\\d+)${CME}`, 'g'), (_m, idx: string) => {
const c = list[Number(idx)];
return c ? `<office:annotation-end office:name="${escapeXml(c.name)}"/>` : '';
})
.replace(
'<office:document-content ',
'<office:document-content xmlns:dc="http://purl.org/dc/elements/1.1/"'
+ ' xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0" ',
);
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// Cut each hoisted note paragraph out of content.xml and splice its runs into a
// <text:note> 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 <text:note>.
const withNotes = applyNotes(withBookmarks, notes);
+13
View File
@@ -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: {
+13
View File
@@ -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: {
+55 -5
View File
@@ -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<string, string>;
// Comment ranges currently open, by Word's numeric id → the mark's attrs.
openComments: Map<string, Record<string, unknown>>;
// word/comments.xml, by id.
commentDefs: Map<string, Record<string, unknown>>;
// 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<NoteKind, Map<string, Element>>;
@@ -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<string, Uint8Array>): boolean {
}
}
// word/comments.xml → the comment mark's attrs, by Word's numeric id.
function docxComments(files: Record<string, Uint8Array>): Map<string, Record<string, unknown>> {
const out = new Map<string, Record<string, unknown>>();
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';
+47 -3
View File
@@ -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<string>;
// Ranged comments currently open, by their office:name — same shape as openBookmarks.
openComments: 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 }[];
@@ -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<string, unknown> }[] = [];
// 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);
}
// <office:annotation> → 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<string, unknown> {
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[] = [];
+13
View File
@@ -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 {
+42
View File
@@ -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('<office:annotation office:name="c1"'), content.slice(0, 300));
check('carries the author', content.includes('<dc:creator>A. Muster</dc:creator>'));
check('carries the body', content.includes('Bitte prüfen'));
check('closes the range', content.includes('<office:annotation-end office:name="c1"/>'));
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('<w:commentRangeStart'), doc.slice(0, 200));
check('range end', doc.includes('<w:commentRangeEnd'));
check('reference run', doc.includes('<w:commentReference'));
check('comments part exists', !!files['word/comments.xml']);
check('body in the part', strFromU8(files['word/comments.xml'] ?? new Uint8Array()).includes('Bitte prüfen'));
const back = importDocx(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('author and body round-trip', m?.attrs.author === 'A. Muster' && m?.attrs.text === 'Bitte prüfen', m?.attrs);
});
});