feat: a comment or change says which line it belongs to

Three pairings, the way both word processors draw them: a bar in the margin
beside every line a change or an open comment covers (ChangeBarLayer), the
reviewing pane's card marked and scrolled into view, and a dashed leader from
the text to that card (ConnectorLayer). The leader is drawn for the one at the
caret only, and dropped where a second pane sits between text and card.

commentIdAt/revisionIdAt compare ranges, not the marks at the caret: both marks
are non-inclusive, so the range a click on a card selects would match nothing.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:02 +02:00
1 parent 8c080b1d16
commit eb7f3c731d
11 files changed
+379 -7

No files matched your search

+6
View File
@@ -56,6 +56,7 @@
import DocPropertiesDialog from './lib/components/DocPropertiesDialog.svelte';
import CommentsPane from './lib/components/CommentsPane.svelte';
import RevisionsPane from './lib/components/RevisionsPane.svelte';
import ConnectorLayer from './lib/components/ConnectorLayer.svelte';
import NavigatorPane from './lib/components/NavigatorPane.svelte';
import { OPEN_COMMENT_EVENT } from './lib/editor/extensions/comment';
import AutoCorrectDialog from './lib/components/AutoCorrectDialog.svelte';
@@ -1511,6 +1512,9 @@
{#if revisionsOpen}
<RevisionsPane {editor} {tick} author={docProps.author} onClose={() => (revisionsOpen = false)} />
{/if}
{#if commentsOpen || revisionsOpen}
<ConnectorLayer {editor} {tick} />
{/if}
</div>
{#if findOpen && editor}
<div class="find-bar-anchor" style="top: {toolbarRegionH + 8}px;">
@@ -1602,6 +1606,8 @@
display: flex;
flex: 1;
min-height: 0;
/* The origin ConnectorLayer draws in — it spans the scroller and the panes. */
position: relative;
}
/* Find & Replace bar: floats at the top-right of the editing area, just under the
+29 -1
View File
@@ -138,7 +138,9 @@ pane beside it (`.editor-row` in `App.svelte`) keeps the sheet at its true width
`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.
from the document properties. The card of the comment the selection lies within (`commentIdAt`)
is marked and scrolled into view, the pane half of the pairing `pm-comment-active`,
`ChangeBarLayer` and `ConnectorLayer` draw on the text.
## Revisions (`RevisionsPane.svelte`)
@@ -146,6 +148,8 @@ The reviewing pane both word processors list revisions in, docked beside `Commen
and built the same way: one row per change **id** (a paragraph boundary splits one change
into several ranges), in the author's own colour, click to select the text, accept or
reject in place through `acceptRevision`/`rejectRevision`. Opened from the Review tab.
The row of the change the selection lies within (`revisionIdAt`) is marked with an inset accent in the
author's colour — the same colour its margin bar takes — and scrolled into view.
## AutoText (`AutoTextDialog.svelte`, `editor/extensions/autoText.ts`)
@@ -235,6 +239,30 @@ floating frame's band is no line of its anchor paragraph. A line past the page s
skipped, or its number would print into the page gap. Re-measured on each edit and each
pagination settle — the whole document each time, which is the ceiling noted in the file.
## Change bars (`ChangeBarLayer.svelte`)
The changed-lines bar both word processors draw in the margin: a stroke beside every line
a recorded change or an unresolved comment covers, in the revision author's colour or the
comment amber, so a pane entry can be found in the text. Each range's ends give it
(`coordsAtPos`), clipped to every page's text area — a range crossing a page break strokes
once per page instead of through the gap. Inside the scaled `.paper` like the other
layers, and inside the line numbers, which sit further out. The change or comment at the
caret is drawn heavier and last, or a neighbour on the same line would cover it. Always
mounted: with nothing marked it renders nothing.
## Reviewing leader (`ConnectorLayer.svelte`)
The dashed leader both word processors draw from an annotated range to its balloon, here
from the text out to the reviewing pane's card: a stub along the anchor's line, then a
diagonal onto the card's head. Only for the comment and the change **at the caret** — a
line per comment would cross the whole page, and one is what pairs card and text. It sits
in `.editor-row` (hence that row's `position: relative`), the one box spanning the
scroller and the panes both, and reads the active card straight off the DOM
(`li.active`), so nothing has to be threaded through `App.svelte`. Redrawn on every
scroller (a capturing `scroll` listener), on resize and on each edit; dropped when the
anchor scrolls out of the page view, and when a *second* pane is docked between the text
and the target card — the card is still marked and the margin bar still names the line.
## 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), `getFrameDebug(view)` (`caption.ts` — per picture: its attrs, its rendered span in the text column and its caption's; every other section measures the flow downwards, and a misplaced caption is a sideways question), the style sheet + spacing model, and `getColorDebug(editor)` (selection marks, text runs, document colors, DOM spans, and `painted`: the colour the browser actually computes per rendered run, the element that declares it, the page's colour variables and the document stylesheet's colour rules — a mark says what was asked for, and a decoration paints a colour no mark carries).
+142
View File
@@ -0,0 +1,142 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { cmToPx, type PageMargins } from '../storage/pageMargins';
import { commentRanges, commentIdAt } from '../editor/extensions/comment';
import { revisions, revisionIdAt, authorColorIndex, REVISION_AUTHOR_COLORS } from '../editor/extensions/trackChanges';
// The changed-lines bar both word processors draw in the margin: a stroke beside every
// line a recorded change or a comment covers, so a pane entry can be found in the text.
// The one the caret sits in is drawn heavier.
let { editor, tick, pageBoxes, pageMargins }: {
editor: Editor | null;
tick: number;
/** One box per page (Editor.svelte): a section on its own paper differs in size. */
pageBoxes: { top: number; left: number; height: number; width: number }[];
pageMargins: PageMargins;
} = $props();
// Between the text edge and the bar — inside the line numbers, which sit further out.
const GAP_CM = 0.2;
const COMMENT_COLOR = 'rgba(200, 140, 0, 0.7)';
type Bar = { top: number; height: number; left: number; color: string; active: boolean };
let bars = $state<Bar[]>([]);
let host = $state<HTMLElement | null>(null);
let paper: HTMLElement | null = null;
let scheduled = false;
const schedule = () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => { scheduled = false; measure(); });
};
$effect(() => {
// Re-measure on every edit, every selection move and each pagination settle.
void tick;
void pageBoxes;
void pageMargins;
schedule();
});
$effect(() => {
const el = host;
if (!el) return;
paper = el.closest('.paper') as HTMLElement | null;
paper?.addEventListener('pm-pagecount', schedule);
return () => paper?.removeEventListener('pm-pagecount', schedule);
});
// Every marked range with the colour its bar takes: a revision in its author's colour,
// a comment in the comment amber. A resolved comment is handled and gets none.
function marked(state: Editor['state']): { from: number; to: number; color: string; active: boolean }[] {
const revs = revisions(state.doc);
const order = authorColorIndex(revs);
const activeRev = revisionIdAt(state, revs);
const comments = commentRanges(state.doc);
const activeComment = commentIdAt(state, comments);
return [
...revs.map((r) => ({
from: r.from, to: r.to,
color: REVISION_AUTHOR_COLORS[order.get(r.author) ?? 0],
active: r.id === activeRev,
})),
...comments.filter((c) => !c.resolved).map((c) => ({
from: c.from, to: c.to, color: COMMENT_COLOR, active: c.id === activeComment,
})),
];
}
function measure(): void {
const view = editor?.view;
if (!view || !host?.isConnected) { bars = []; return; }
const ranges = marked(view.state);
if (!ranges.length) { bars = []; return; }
const dom = view.dom as HTMLElement;
const rect = dom.getBoundingClientRect();
// Client rects are in zoom-scaled screen space; page boxes and this layer's own
// coordinates are unscaled document px, so divide the scale back out.
const scale = dom.offsetWidth ? rect.width / dom.offsetWidth : 1;
const origin = rect.top;
const out: Bar[] = [];
for (const r of ranges) {
let top: number;
let bottom: number;
try {
const a = view.coordsAtPos(r.from);
const b = view.coordsAtPos(r.to, -1);
top = (Math.min(a.top, b.top) - origin) / scale;
bottom = (Math.max(a.bottom, b.bottom) - origin) / scale;
} catch { continue; }
// A range crossing a page break must not stroke through the gap or the margins:
// clipped to each page's text area, it draws one segment per page it covers.
for (const box of pageBoxes) {
const t = Math.max(top, box.top + cmToPx(pageMargins.top));
const b = Math.min(bottom, box.top + box.height - cmToPx(pageMargins.bottom));
if (b - t < 1) continue;
out.push({
top: t, height: b - t,
left: box.left + cmToPx(pageMargins.left) - cmToPx(GAP_CM),
color: r.color, active: r.active,
});
}
}
// Two changes on one line stack at the same spot; the active one draws last, or its
// heavier stroke would sit under a neighbour's.
out.sort((a, b) => Number(a.active) - Number(b.active));
bars = out;
}
</script>
<div class="change-bar-layer" bind:this={host} aria-hidden="true">
{#each bars as b}
<div
class="change-bar"
class:active={b.active}
style="top: {b.top}px; height: {b.height}px; left: {b.left}px; background: {b.color};"
></div>
{/each}
</div>
<style>
/* A sibling of .band-layer inside the scaled .paper, so a top measured against
.tiptap is the top it draws at. */
.change-bar-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
.change-bar {
position: absolute;
width: 2px;
border-radius: 1px;
opacity: 0.7;
}
.change-bar.active {
width: 3px;
opacity: 1;
}
</style>
+15 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { TextSelection } from '@tiptap/pm/state';
import { comments, type CommentRange } from '../editor/extensions/comment';
import { comments, commentIdAt, 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
@@ -19,6 +19,18 @@
return comments(editor.state.doc);
});
// The comment the caret sits in: its card is marked and pulled into view, the other
// half of the heavier tint the document draws on the annotated text.
let activeId = $derived.by<string | null>(() => {
if (tick < 0 || !editor) return null;
return commentIdAt(editor.state);
});
let cards: Record<string, HTMLElement | null> = {};
$effect(() => {
if (activeId) cards[activeId]?.scrollIntoView({ block: 'nearest' });
});
let editingId = $state<string | null>(null);
let draft = $state('');
@@ -62,7 +74,7 @@
<ul>
{#each list as c (c.id)}
<li class:resolved={c.resolved}>
<li bind:this={cards[c.id]} class:resolved={c.resolved} class:active={c.id === activeId}>
<button class="card" onclick={() => jumpTo(c)}>
<div class="meta">
<b>{c.author || author}</b>
@@ -141,6 +153,7 @@
border-bottom: 1px solid var(--color-border);
}
li.resolved { opacity: 0.55; }
li.active { background: var(--color-btn-hover); box-shadow: inset 3px 0 0 rgba(200, 140, 0, 0.9); }
.card {
display: block;
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { commentIdAt, commentRanges } from '../editor/extensions/comment';
import { revisionIdAt, revisions, authorColorIndex, REVISION_AUTHOR_COLORS } from '../editor/extensions/trackChanges';
// The dashed leader both word processors draw from an annotated range to its balloon,
// here from the text to the reviewing pane's card. Only for the one the caret sits in:
// a line per comment would cross the whole page, and one is what pairs card and text.
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
const COMMENT_COLOR = 'rgba(200, 140, 0, 0.9)';
let lines = $state<{ points: string; color: string }[]>([]);
let host = $state<HTMLElement | null>(null);
let scheduled = false;
const schedule = () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => { scheduled = false; measure(); });
};
// Deferred to the frame, so the pane's own card class and scroll are in the DOM.
$effect(() => {
void tick;
schedule();
});
$effect(() => {
const el = host;
if (!el) return;
// Every scroller moves an endpoint: the page's, the pane's own, the window's. A pane
// opening or closing only resizes the row, which is what the observer catches.
window.addEventListener('scroll', schedule, true);
window.addEventListener('resize', schedule);
const ro = new ResizeObserver(schedule);
ro.observe(el);
return () => {
window.removeEventListener('scroll', schedule, true);
window.removeEventListener('resize', schedule);
ro.disconnect();
};
});
function measure(): void {
const view = editor?.view;
const el = host;
if (!view || !el?.isConnected) { lines = []; return; }
const box = el.getBoundingClientRect();
const clip = (view.dom as HTMLElement).closest('.editor')?.getBoundingClientRect();
const out: { points: string; color: string }[] = [];
// A stub from the anchor out to the pane, then a diagonal onto the card's head —
// the shape Word gives a balloon leader.
const add = (pos: number, card: Element | null, color: string) => {
if (!card) return;
let c: { top: number; bottom: number; left: number };
try { c = view.coordsAtPos(pos, -1); } catch { return; }
// An anchor scrolled out of the page view has nothing left to point at.
if (clip && (c.bottom < clip.top || c.top > clip.bottom || c.left < clip.left || c.left > clip.right)) return;
const r = card.getBoundingClientRect();
const ax = c.left - box.left;
const ay = (c.top + c.bottom) / 2 - box.top;
const cx = r.left - box.left;
const cy = r.top - box.top + Math.min(14, r.height / 2);
if (cx <= ax) return;
// A leader may not run across another pane's cards. With a second pane docked in
// between, the card is still marked and the margin bar still names the line.
const own = card.closest('aside');
for (const pane of el.parentElement?.querySelectorAll('aside') ?? []) {
const left = pane.getBoundingClientRect().left - box.left;
if (pane !== own && left > ax && left < cx) return;
}
out.push({ points: `${ax},${ay} ${Math.max(ax + 8, cx - 24)},${ay} ${cx},${cy}`, color });
};
const state = view.state;
const ranges = commentRanges(state.doc);
const cid = commentIdAt(state, ranges);
if (cid) {
// The last range of the comment: its end is the anchor both products point from.
const range = ranges.filter((c) => c.id === cid).pop();
if (range) add(range.to, document.querySelector('.comments-pane li.active'), COMMENT_COLOR);
}
const list = revisions(state.doc);
const rid = revisionIdAt(state, list);
if (rid) {
const range = list.filter((r) => r.id === rid).pop();
if (range) {
add(range.to, document.querySelector('.revisions-pane li.active'),
REVISION_AUTHOR_COLORS[authorColorIndex(list).get(range.author) ?? 0]);
}
}
lines = out;
}
</script>
<div class="connector-layer" bind:this={host} aria-hidden="true">
{#if lines.length}
<svg>
{#each lines as l}
<polyline points={l.points} fill="none" stroke={l.color} stroke-width="1" stroke-dasharray="3 3" />
{/each}
</svg>
{/if}
</div>
<style>
/* Spans the scroller and the panes both, so one line reaches across; clipped at the
row, or it would draw over the toolbar. */
.connector-layer {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 5;
}
svg {
display: block;
width: 100%;
height: 100%;
}
</style>
+2
View File
@@ -27,6 +27,7 @@ import PageDecorLayer from './PageDecorLayer.svelte';
import PageSheetLayer from './PageSheetLayer.svelte';
import LineNumberLayer from './LineNumberLayer.svelte';
import FoldMarkLayer from './FoldMarkLayer.svelte';
import ChangeBarLayer from './ChangeBarLayer.svelte';
import { DEFAULT_LINE_NUMBERING, type LineNumbering } from '../storage/lineNumbering';
import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
import Ruler from './Ruler.svelte';
@@ -1477,6 +1478,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
{#if lineNumbering.on}
<LineNumberLayer {editor} {tick} {lineNumbering} {pageBoxes} {pageMargins} />
{/if}
<ChangeBarLayer {editor} {tick} {pageBoxes} {pageMargins} />
<HeaderFooterLayer
bind:headerDoc
bind:footerDoc
+16 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { TextSelection } from '@tiptap/pm/state';
import { revisions, authorColorIndex, REVISION_AUTHOR_COLORS, type Revision } from '../editor/extensions/trackChanges';
import { revisions, revisionIdAt, authorColorIndex, REVISION_AUTHOR_COLORS, type Revision } from '../editor/extensions/trackChanges';
import { t } from '../i18n/i18n.svelte';
// The reviewing pane both word processors list revisions in, beside the comments one:
@@ -22,6 +22,18 @@
let colors = $derived(authorColorIndex(list));
// The change the caret sits in: its card is marked and pulled into view, so the
// margin bar beside the text and the row here name each other.
let activeId = $derived.by<string | null>(() => {
if (tick < 0 || !editor) return null;
return revisionIdAt(editor.state);
});
let cards: Record<string, HTMLElement | null> = {};
$effect(() => {
if (activeId) cards[activeId]?.scrollIntoView({ block: 'nearest' });
});
function colorOf(r: Revision): string {
return REVISION_AUTHOR_COLORS[colors.get(r.author) ?? 0];
}
@@ -55,7 +67,7 @@
<ul>
{#each list as r (r.id)}
<li>
<li bind:this={cards[r.id]} class:active={r.id === activeId} style:box-shadow={r.id === activeId ? `inset 3px 0 0 ${colorOf(r)}` : null}>
<button class="card" onclick={() => jumpTo(r)}>
<div class="meta">
<b style:color={colorOf(r)}>{r.author || author || t().revisions.unknownAuthor}</b>
@@ -117,6 +129,8 @@
padding: 8px 10px;
border-bottom: 1px solid var(--color-border);
}
/* The accent is the author's colour, set inline — the same one the margin bar takes. */
li.active { background: var(--color-btn-hover); }
.card {
display: block;
+2 -2
View File
@@ -30,8 +30,8 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`bulletList.ts`** / **`orderedList.ts`** — the TipTap lists plus a marker-type attr: `setBulletChar` (innermost list, `null` = default cycle) and the ordered numbering cycle (`utils/orderedListTypes.ts`, multilevel targets the outermost list).
- **`listStyle.ts`** — the named list style (`listStyleName` on the outermost list; nested lists inherit) and the walk resolving every list's effective marker (`data-eff-list-style`, `data-bullet`+`--bullet`, indent, label alignment) from node attr > style level > depth cycle — the style level decides the depth's kind, so a number level numbers a `<ul>` (counter-offset via `counter-reset`, since `<ul>` ignores `start`); `setListStyle` assigns (clearing the subtree's direct marker attrs and retyping each list to its level's kind), `listStyleNameAt` feeds the dropdowns — see `src/lib/styles/CLAUDE.md`.
- **`link.ts`** — hyperlink mark; owns `Mod-k`, which fires the event `Toolbar.svelte` listens for to open the link dialog. A `#name` href targets a bookmark: `Editor.svelte`'s `handleClick` scrolls to it instead of opening a tab. The `plain` attr (`data-plain`, `editor.css`) suppresses the editor's own blue: Word paints a link through its **Hyperlink character style**, which arrives as an ordinary mark, so every DOCX link is plain and one carrying no style is drawn like the text around it — while LibreOffice paints every ODF `text:a` styled or not (probed), so those never are. The DOCX exporter paints `#0563C1` only on a non-plain link, which is how a round trip tells its own links from a file's.
- **`comment.ts`** — an annotation on a range of text, a mark like `bookmark`: the whole comment (author, date, body, resolved) rides its attrs, so two runs of one comment merge back together and no second store is needed. `commentRanges(doc)` walks every range (an id split by a paragraph boundary yields several, which is why the commands walk all of them), `comments(doc)` keeps one per id for `CommentsPane.svelte`. A point comment — LibreOffice's, made with nothing selected, written with no `office:name` and no end element — attaches on import to the run before it, else the one after: the editor's comment is a mark and needs a range.
- **`trackChanges.ts`** — the `insertion`/`deletion` marks plus the recording plugin. `appendTransaction` marks whatever a transaction added, and `adjacentInsertion` reuses the id of the run just before it when the author matches, so a typed sentence is one change and not one per keystroke. A delete never reaches the document: `filterTransaction` rejects it and dispatches a mark pass instead (the author's own pending insertion is removed for real, as both word processors do). A **replacement** — typing or pasting over a selection — goes the same way (`replacedRange`), the new content landing after the struck-out text it replaces; recorded as the insertion alone, its other half was simply dropped and a reject lost the original. Every transaction of our own carries the `RECORDING` meta — accept/reject included, or its `tr.delete` would be read as a fresh user deletion and marked up again. **Loading a document carries it too** (`App.svelte`'s `loadContent`, both for a new document and for an import): a `setContent` is one transaction that inserts the whole file, so without the meta an opened document arrives wholly marked as the reader's own insertion, underlined in the author colour. Both word processors record what is typed after an open, never the file. `revisions(doc)` merges adjacent runs of one id, so an export writes one change per id. The **author's colour** is a decoration, not the mark: a mark cannot know the document's other authors, so a plugin walks the revisions, hands out `REVISION_AUTHOR_COLORS` (LibreOffice's own author palette) in order of first appearance and paints `pm-rev-ins`/`-del` + `pm-rev-a<n>`. That span nests *inside* the mark's, so it carries the underline/strikethrough too — drawn by the ancestor it would take the fallback colour. Cached on the doc; the walk is the whole document and `decorations` runs on every view update. `acceptRevision(id)`/`rejectRevision(id)` apply every range of one change, which is what `RevisionsPane.svelte` lists as one row.
- **`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. `commentIdAt(state)` is the comment the selection lies within, which a plugin tints heavier (`pm-comment-active`, skipping a resolved one) and `CommentsPane.svelte`, `ChangeBarLayer.svelte` and `ConnectorLayer.svelte` each mark their own half of. It compares **ranges**, not the marks at the caret: the mark is non-inclusive, so `$pos.marks()` is empty at either end of it — and a click on the pane's card selects exactly that range, which would then match nothing.
- **`trackChanges.ts`** — the `insertion`/`deletion` marks plus the recording plugin. `appendTransaction` marks whatever a transaction added, and `adjacentInsertion` reuses the id of the run just before it when the author matches, so a typed sentence is one change and not one per keystroke. A delete never reaches the document: `filterTransaction` rejects it and dispatches a mark pass instead (the author's own pending insertion is removed for real, as both word processors do). A **replacement** — typing or pasting over a selection — goes the same way (`replacedRange`), the new content landing after the struck-out text it replaces; recorded as the insertion alone, its other half was simply dropped and a reject lost the original. Every transaction of our own carries the `RECORDING` meta — accept/reject included, or its `tr.delete` would be read as a fresh user deletion and marked up again. **Loading a document carries it too** (`App.svelte`'s `loadContent`, both for a new document and for an import): a `setContent` is one transaction that inserts the whole file, so without the meta an opened document arrives wholly marked as the reader's own insertion, underlined in the author colour. Both word processors record what is typed after an open, never the file. `revisions(doc)` merges adjacent runs of one id, so an export writes one change per id. The **author's colour** is a decoration, not the mark: a mark cannot know the document's other authors, so a plugin walks the revisions, hands out `REVISION_AUTHOR_COLORS` (LibreOffice's own author palette) in order of first appearance and paints `pm-rev-ins`/`-del` + `pm-rev-a<n>`. That span nests *inside* the mark's, so it carries the underline/strikethrough too — drawn by the ancestor it would take the fallback colour. Cached on the doc; the walk is the whole document and `decorations` runs on every view update. `acceptRevision(id)`/`rejectRevision(id)` apply every range of one change, which is what `RevisionsPane.svelte` lists as one row. `revisionIdAt(state)` is the change the selection lies within — the row the pane marks and the margin bar `ChangeBarLayer.svelte` draws heavier — compared by range for the same reason `commentIdAt` is.
- **`placeholderField.ts`** — a template placeholder, an inline atom drawn gray as ‹label›: a click selects the whole field (NodeSelection) and typing replaces it; Tab/Shift-Tab from a selected field jump to the next/previous one. Round-trips to ODF `<text:placeholder>` and a tagged DOCX `w:sdt` content control.
- **`caption.ts`** — a caption is an ordinary paragraph in the `Caption` style (LibreOffice's, probed: 10pt italic, 0.212cm above and below) holding one `sequenceField` atom, its running number. `sequenceFields(doc)` counts one counter per category in document order; the cached `number` attr is what an export writes, and an `appendTransaction` (off the undo stack) plus an `onCreate` pass keep it true — a file's own cached numbers are unreadable wherever the format is not arabic. The counter's name differs per format: ODF `Illustration`/`Table`, Word `Figure`/`Table`, and `ODF_SEQ_CATEGORY` reads both back. The caption paragraph is **boxed to the frame it captions** (`captionPlacement`), so it stands under the picture instead of at the margin: LibreOffice frames picture and caption together, and a loose paragraph carries that over as `indent`/`indentRight` — the frame's own span in the column, measured (`frameSpanCm`), since that span comes from the wrap, the offset and the column, not from one attr. The indent is also what lifts the caption clear of a **side-wrapped** frame: a line with no room beside a float drops below it. It is centred in that box only under a frame that is itself centred; a block with no frame at all has only its own alignment to pass on. `getFrameDebug` reports the same two spans for the debug dump. `CaptionDialog.svelte` (References tab) owns the insert.
- **`ruby.ts`** — a reading printed over its base (LibreOffice's Format ▸ Asian Phonetic Guide, Word's Phonetic Guide). An inline **atom** carrying both halves, because that is what both formats write: one element holding a base and a reading, edited as a unit (`RubyDialog.svelte`). Rendered as a real `<ruby>`, so the browser sets it. Alignment and position are both products' defaults and are not offered.
+29
View File
@@ -1,5 +1,7 @@
import { Mark, mergeAttributes } from '@tiptap/core';
import type { Node as PMNode, Mark as PMMark } from '@tiptap/pm/model';
import { Plugin, type EditorState } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
// 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
@@ -35,6 +37,14 @@ export function commentMarkAt(marks: readonly PMMark[]): PMMark | null {
return marks.find((m) => m.type.name === 'comment') ?? null;
}
/** The comment the selection lies within — what the pane highlights and the bar thickens. */
export function commentIdAt(state: EditorState, ranges?: CommentRange[]): string | null {
const { from, to } = state.selection;
// By range, not by the marks at the caret: the mark is non-inclusive, so a selection
// over the whole comment — what a click on the pane's card makes — carries none.
return (ranges ?? commentRanges(state.doc)).find((c) => c.from <= from && to <= c.to)?.id ?? 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.
@@ -150,4 +160,23 @@ export const Comment = Mark.create({
},
};
},
addProseMirrorPlugins() {
return [
new Plugin({
props: {
// The comment the selection lies in, tinted heavier — the document half of
// the pairing the pane draws on its card. A handled one stays grey.
decorations(state) {
const ranges = commentRanges(state.doc);
const id = commentIdAt(state, ranges);
const hit = id ? ranges.filter((c) => c.id === id) : [];
if (!hit.length || hit[0].resolved) return null;
return DecorationSet.create(state.doc,
hit.map((c) => Decoration.inline(c.from, c.to, { class: 'pm-comment-active' })));
},
},
}),
];
},
});
@@ -97,6 +97,14 @@ export const REVISION_AUTHOR_COLORS = [
'#c69200', '#0646a2', '#579d1c', '#692b9d', '#c5000b', '#008080', '#8c8400', '#35556b', '#d17600',
];
/** The change the selection lies within — what the pane highlights and the bar thickens. */
export function revisionIdAt(state: EditorState, list?: Revision[]): string | null {
const { from, to } = state.selection;
// By range, not by the marks at the caret: both marks are non-inclusive, so a selection
// over the whole change — what a click on the pane's card makes — carries none.
return (list ?? revisions(state.doc)).find((r) => r.from <= from && to <= r.to)?.id ?? null;
}
/** Author → palette index, in the order the document's revisions first name them. */
export function authorColorIndex(list: Revision[]): Map<string, number> {
const order = new Map<string, number>();
+6
View File
@@ -1022,6 +1022,12 @@
border-bottom: 1px solid rgba(200, 140, 0, 0.7);
}
/* The one the caret sits in (comment.ts): a heavier tint, so the pane's highlighted
card and the annotated text name each other. */
.tiptap .pm-comment-active {
background: rgba(255, 200, 60, 0.5);
}
.tiptap .pm-comment[data-comment-resolved='true'] {
background: none;
border-bottom-color: rgba(140, 140, 140, 0.6);