fix: a caption is boxed to its frame, and the frame stays clickable

Two faults on one picture. A block after a float keeps its box over
the float — only its lines move — so the caption on top swallowed
every click meant for the picture; the frame is lifted above the
in-flow boxes. And a caption can only sit under a side-wrapped frame
if it is boxed to it, so its span in the column is measured and
becomes the caption's indent, which also drops it clear of the float.
Measured at 100% and 150%: caption under the picture, its width, in
all four wrap modes.
This commit is contained in:
Steffen Becker committed 2026-08-14 22:40:42 +02:00
1 parent 61ddf69d54
commit 46165b82f7
9 files changed
+162 -117

No files matched your search

+9
View File
@@ -6,6 +6,15 @@ handles, wrap modes, frame offsets — and their ODF/DOCX legs.
- **`image.ts`** (`Image`) — inline, as-character image atom (Word's "in line with text"). Attrs: `src` (data-URI), `alt`, `width`/`height` (**unscaled doc px @96dpi**, like `rowHeight`, and **fractional** — rounding a page-wide picture to whole px costs 0.3mm of height; `framePx` snaps back to a whole one within 0.05px, which is where our own export's 0.001cm quantization lands, so the round trip stays exact), `rotation` (CW degrees). The node view nests an `<img>` in a rotated "rotor" inside an axis-aligned wrapper that reserves the rotated bounding box (so text reflows around it). The rotor's transform makes it a stacking context, so a selected `.image-node` is given a `z-index` above the header/footer layer to keep handles that poke into a margin grabbable. It has eight zoom-aware resize handles — corners aspect-locked, edges single-axis (width- or height-only) — plus a rotation-arrow grip, and shows a live "Width … × Height …" (cm) badge while resizing; resize deltas are un-rotated onto the image's own axes so handles track the pointer at any angle (drag math mirrors `tableRowResize.ts`; width cap = the cell or page-text-column, height cap = the page text height). `setImage` command inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file via `FileReader` → data-URI and clamp the initial size to the page text box. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'`) makes the image a *floating* frame — `ImageToolbar.svelte` (a per-image floating toolbar wired in `Editor.svelte` like `TableToolbar`) sets the mode; `left`/`right` float the wrapper at its anchor paragraph (text flows on the open side via CSS `float`), `topBottom` is a **full-width `float`** (text only above/below). All modes use `float` deliberately: an in-flow `display:block` on an inline-atom node view splits the paragraph's inline content into anonymous block boxes, which desyncs ProseMirror's inline view descriptor and makes it drop the following page-break spacer widget; a float is out-of-flow and avoids that. Dragging an **inline** image runs ProseMirror's native node move (the `dropCursor` plugin shows the caret). Dragging a **floating** image runs a custom drag (`ImageView.startReposition`) that **live re-anchors** the node to the text position under the cursor, so the float and text reflow in real time as you drag; it's rAF-throttled and only moves when the cursor's line changes (a float can't move within a line), and stays a single undo step (first move records history, later moves are `addToHistory:false` and get rebased on undo). A paragraph that contains an image paginates **atomically** (`pageBreaks.ts` — pushed whole to the next page, spacer placed before it) so a page break never splits the paragraph next to the image's float (where ProseMirror drops the spacer widget). **Wrap distance:** `wrapDist` is the gap the file keeps beside a float, in cm (Word's `distL`/`distR`, ODF's `fo:margin-left`/`-right` on the graphic style) — only the side the text flows on, since the other one is the frame's own offset. A frame that declares none reserves **nothing**, beside it or above/below it: probed, LibreOffice wraps flush against it, and a gap of our own put every figure in a text box 3mm too tall and broke a line a word early. `setImageWrap` gives a frame the file never floated the 0.32cm a word processor's own wrap command writes. **Vertical alignment (as-char):** `vAlign` carries ODF's `style:vertical-pos`/`-rel` pair for an inline frame — `null` (LibreOffice's `top`/`baseline` and Word's only mode) stands the frame on the text baseline, `middle`/`below` centre it on the baseline or hang it below, and `text-top`/`text-middle`/`text-bottom` measure against the character area instead (CSS `vertical-align: text-top`/`text-bottom`; nothing in CSS centres on that area, so `text-middle` uses 0.36em for half of ascent − descent, which Liberation Serif/Sans, Times and Arial share). `from-top` becomes `vAlign: 'offset'` and puts the frame's top `wrapOffsetY` cm below the baseline whatever the relation says — all probed against LibreOffice, whose formula images use `middle`/`text` and `from-top` almost exclusively. A line an as-char frame has to itself — nothing beside it but whitespace — is as tall as the frame or the block's line height, whichever is more, with **no text descent below it** (probed); `image.ts` gives such a frame an `image-line` class, since CSS cannot ask whether a line holds real text. Hard breaks are the only line boundaries the model shows, so the run between two of them is what gets tested — which covers the frame-break-caption idiom LibreOffice's own guides are full of; a frame alone on a *soft-wrapped* line is invisible from there. A frame that shares its line with text keeps the baseline and the descent. **Frame offsets:** `wrapOffset` is the frame's left edge in the text column (Word's `positionH` posOffset, ODF `svg:x`Line truncated
- **`textBox.ts`** (`TextBox`) — block-level text box / basic shape with editable block content (`content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`). Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`; `inline` renders in-flow, floats via CSS `float` like `ImageView.applyWrap`), `shapeKind` (see the shape table below), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). The node has its **own group** (`group: 'textBox'`) so only the document admits it (`extensions.ts` widens Document to `'(block | textBox)+'`) — never in table cells, lists, header/footer, or other boxes; imports found there are flattened with a warning. `TextBoxView` mirrors `ImageView` (wrapper reserving the rotated bbox → rotor carrying fill/stroke/rotation → `contentDOM`); the rotor is out of flow and auto-grows, so a ResizeObserver refits the wrapper. **Click model (Word-like):** `stopEvent`/`isFrameHit` claim mouse events on a ~6px frame ring (rotation-/zoom-aware) → NodeSelection + handles (the shared `image-*` handle classes/CSS); clicks further inside pass to ProseMirror (caret). The box is browser-`draggable` only while node-selected, so PM's native block drag moves it without hijacking text selection. Commands: `insertTextBox()` (after the current top-level block, cursor inside), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`). `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke. `trailingNode.ts` appends a paragraph after a trailing box; `pageBreaks.ts` paginates `.textbox-node` atomically. **Spacing:** a box lifted out of an anchor paragraph that held nothing else stands in for it, so it takes that paragraph's resolved `fo:margin-top`/`-bottom` as `spaceBefore`/`spaceAfter` (`ParagraphSpacing` covers `textBox` for that). Space above is padding — it adds to the block above rather than collapsing against it — which means the node is `content-box` and the rotor, centred in the padding box, gets half of it back.
## Clicking a float
A block after a float keeps its own box **over** the float — only its line boxes move out
of the way — so the block on top takes every click meant for the frame, and the frame
stops selecting once anything follows it (a caption is the usual first such block).
`editor.css` lifts a floating frame (`.image-node[data-wrap]`) above the in-flow boxes;
nothing is hidden, since text flows beside a frame and never across it. A page-anchored
frame is excluded — it carries its own place in the stack (`inFront`).
## Shapes (`utils/shapes.ts`)
Every kind past the three CSS can draw is **one polygon in a 0…100 box**, given once and
+1 -1
View File
@@ -18,8 +18,8 @@
import { getPageBreakDebug } from './lib/editor/extensions/pageBreaks';
import { getColumnsFlowDebug } from './lib/editor/extensions/columnsFlow';
import { getTextBoxDebug } from './lib/editor/extensions/textBox';
import { getFrameDebug } from './lib/editor/extensions/image';
import { getTableCellDebug } from './lib/editor/extensions/tableCellAlign';
import { getFrameDebug } from './lib/editor/extensions/caption';
import { getColorDebug } from './lib/utils/colorDebug';
import { resetHistoryLog } from './lib/utils/historyLog.svelte';
import { countText, type TextStats } from './lib/utils/wordCount';
+1 -1
View File
@@ -100,7 +100,7 @@ ceiling noted in the file.
## 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)` (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).
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).
## Per-section header/footer
+1 -1
View File
@@ -31,7 +31,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`link.ts`** — hyperlink mark; owns `Mod-k`, which fires the event `Toolbar.svelte` listens for to open the link dialog. A `#name` href targets a bookmark: `Editor.svelte`'s `handleClick` scrolls to it instead of opening a tab. The `plain` attr (`data-plain`, `editor.css`) suppresses the editor's own blue: Word paints a link through its **Hyperlink character style**, which arrives as an ordinary mark, so every DOCX link is plain and one carrying no style is drawn like the text around it — while LibreOffice paints every ODF `text:a` styled or not (probed), so those never are. The DOCX exporter paints `#0563C1` only on a non-plain link, which is how a round trip tells its own links from a file's.
- **`comment.ts`** — an annotation on a range of text, a mark like `bookmark`: the whole comment (author, date, body, resolved) rides its attrs, so two runs of one comment merge back together and no second store is needed. `commentRanges(doc)` walks every range (an id split by a paragraph boundary yields several, which is why the commands walk all of them), `comments(doc)` keeps one per id for `CommentsPane.svelte`. A point comment — LibreOffice's, made with nothing selected, written with no `office:name` and no end element — attaches on import to the run before it, else the one after: the editor's comment is a mark and needs a range.
- **`trackChanges.ts`** — the `insertion`/`deletion` marks plus the recording plugin. `appendTransaction` marks whatever a transaction added, and `adjacentInsertion` reuses the id of the run just before it when the author matches, so a typed sentence is one change and not one per keystroke. A delete never reaches the document: `filterTransaction` rejects it and dispatches a mark pass instead (the author's own pending insertion is removed for real, as both word processors do). Every transaction of our own carries the `RECORDING` meta — accept/reject included, or its `tr.delete` would be read as a fresh user deletion and marked up again. `revisions(doc)` merges adjacent runs of one id, so an export writes one change per id.
- **`caption.ts`** — a caption is an ordinary paragraph in the `Caption` style (LibreOffice's, probed: 10pt italic, 0.212cm above and below) holding one `sequenceField` atom, its running number. `sequenceFields(doc)` counts one counter per category in document order; the cached `number` attr is what an export writes, and an `appendTransaction` (off the undo stack) plus an `onCreate` pass keep it true — a file's own cached numbers are unreadable wherever the format is not arabic. The counter's name differs per format: ODF `Illustration`/`Table`, Word `Figure`/`Table`, and `ODF_SEQ_CATEGORY` reads both back. The caption paragraph takes the **placement of what it captions**, so it stands under the picture instead of at the margin — LibreOffice frames picture and caption together, and a loose paragraph can only carry that over as its own alignment. A *frame* carries its own place in the column, which the anchor paragraph's alignment says nothing about, so `framePlacement` reads it off the frame: a `topBottom` one is centred unless it names a side (`wrapAlign`) or an offset (`wrapOffset` → the caption's `indent`, which no alignment can express). A side-wrapped frame gets nothing — its caption flows beside it, which is what the exported file renders too. `CaptionDialog.svelte` (References tab) owns the insert.
- **`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.
- **`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.
+118 -22
View File
@@ -1,7 +1,9 @@
import { Node, mergeAttributes } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state';
import type { EditorView } from '@tiptap/pm/view';
import { formatOrdinal } from '../../utils/orderedListTypes';
import { PX_PER_CM } from '../../storage/pageMargins';
import type { NoteNumFormat } from '../../storage/noteSettings';
// A caption is an ordinary paragraph in the `Caption` style carrying one inline atom:
@@ -61,26 +63,51 @@ declare module '@tiptap/core' {
}
}
// Where a caption has to stand to be under the frame it belongs to. A frame carries its
// own place in the column, so the anchor paragraph's alignment says nothing about it: a
// `topBottom` one is centred unless it names a side or an offset (image.ts/textBox.ts).
// A side-wrapped frame has the caption flowing beside it, which is what a reader of the
// exported file sees too, so nothing here can put it underneath.
export function framePlacement(block: PMNode): { textAlign: string | null; indent: number | null } | null {
let frame: PMNode | null = block.attrs.wrap ? block : null;
if (!frame) {
block.descendants((node) => {
if (frame) return false;
if (node.attrs.wrap) frame = node;
return !frame;
export type CaptionPlacement = { textAlign: string | null; indent: number | null; indentRight: number | null };
// LibreOffice frames picture and caption together, so the caption is exactly as wide as
// the picture and sits under it whatever the frame's wrap. A loose paragraph carries
// that over as its own box: indented to the frame's span, which also lifts it clear of a
// side-wrapped frame — a line with no room beside a float drops below it.
export function captionPlacement(leftCm: number, rightCm: number): CaptionPlacement {
const gap = (v: number) => (v > 0.05 ? Math.round(v * 100) / 100 : null);
return {
indent: gap(leftCm),
indentRight: gap(rightCm),
// Centred in its box under a frame that is itself centred, left-flush under one set
// against an edge — which is how LibreOffice's own frame reads.
textAlign: gap(leftCm) && Math.abs(leftCm - rightCm) < 0.05 ? 'center' : null,
};
}
// The frame's span in the text column, in cm from each edge — measured, since a frame's
// place comes from its wrap, its offset and the column it is in, not from one attr.
function frameSpanCm(view: EditorView, block: PMNode, blockPos: number): { left: number; right: number } | null {
const host = view.dom as HTMLElement;
if (typeof getComputedStyle !== 'function' || !host.offsetWidth) return null;
let framePos: number | null = null;
if (block.attrs.wrap) framePos = blockPos;
else if (block.inlineContent) {
block.descendants((node, pos) => {
if (framePos === null && node.attrs.wrap) framePos = blockPos + 1 + pos;
return framePos === null;
});
}
const attrs = (frame as PMNode | null)?.attrs;
if (!attrs || attrs.wrap !== 'topBottom') return null;
if (attrs.wrapAlign) return { textAlign: String(attrs.wrapAlign), indent: null };
return typeof attrs.wrapOffset === 'number'
? { textAlign: null, indent: attrs.wrapOffset }
: { textAlign: 'center', indent: null };
if (framePos === null) return null;
const dom = view.nodeDOM(framePos);
const frame = dom instanceof HTMLElement ? dom.querySelector('.image-rotor, .textbox-rotor') ?? dom : null;
if (!frame) return null;
const cs = getComputedStyle(host);
const padLeft = parseFloat(cs.paddingLeft) || 0;
const hostRect = host.getBoundingClientRect();
// The zoom transform scales the rects but not the layout, so take it back out.
const scale = hostRect.width / host.offsetWidth;
const columnLeft = hostRect.left + padLeft * scale;
const columnWidth = host.clientWidth - padLeft - (parseFloat(cs.paddingRight) || 0);
const rect = frame.getBoundingClientRect();
const left = (rect.left - columnLeft) / scale;
const right = columnWidth - (rect.right - columnLeft) / scale;
return { left: left / PX_PER_CM, right: right / PX_PER_CM };
}
export const SequenceField = Node.create({
@@ -139,14 +166,22 @@ export const SequenceField = Node.create({
content.push({ type: this.name, attrs: { category, format: '1', number: 1 } });
const tail = `${separator}${text}`;
if (tail) content.push({ type: 'text', text: tail });
// A caption stands under its picture, not at the margin: LibreOffice frames the
// two together, and a loose paragraph carries that over as its own placement.
// A caption stands under its picture, not at the margin. A frame's own span in
// the column is the caption's box; a block without one only has its alignment.
const block = $from.node(1);
const place = framePlacement(block) ?? { textAlign: block.attrs.textAlign ?? null, indent: null };
const span = frameSpanCm(this.editor.view, block, $from.before(1));
const place: CaptionPlacement = span
? captionPlacement(span.left, span.right)
: { textAlign: block.attrs.textAlign ?? null, indent: null, indentRight: null };
return chain()
.insertContentAt(at, {
type: 'paragraph',
attrs: { styleName: CAPTION_STYLE, textAlign: place.textAlign, indent: place.indent },
attrs: {
styleName: CAPTION_STYLE,
textAlign: place.textAlign,
indent: place.indent,
indentRight: place.indentRight,
},
content,
})
.focus()
@@ -188,3 +223,64 @@ function renumberTr(state: EditorState): Transaction | null {
export function sequenceFieldText(node: PMNode): string {
return formatOrdinal(Number(node.attrs.number) || 1, (node.attrs.format as NoteNumFormat) ?? '1');
}
export type FrameDebugEntry = {
pos: number;
/** Every attr but `src` — a data URI is the whole file. */
attrs: Record<string, unknown>;
/** Doc px from the text column's left edge, so the two spans compare directly. */
columnWidth: number;
frame: [number, number] | null;
caption: { text: string; span: [number, number]; textAlign: string; indent: unknown; indentRight: unknown } | null;
};
// A frame's place in the column beside the caption's — "the caption is not under the
// picture" is a horizontal question, and every other section of the dump is vertical.
export function getFrameDebug(view: EditorView): FrameDebugEntry[] {
const host = view.dom as HTMLElement;
if (typeof getComputedStyle !== 'function' || !host.offsetWidth) return [];
const cs = getComputedStyle(host);
const padLeft = parseFloat(cs.paddingLeft) || 0;
const scale = host.getBoundingClientRect().width / host.offsetWidth;
const originX = host.getBoundingClientRect().left + padLeft * scale;
const span = (r: DOMRect): [number, number] =>
[Math.round((r.left - originX) / scale), Math.round((r.right - originX) / scale)];
const textSpan = (el: HTMLElement): DOMRect => {
const range = document.createRange();
range.selectNodeContents(el);
return range.getBoundingClientRect();
};
const out: FrameDebugEntry[] = [];
view.state.doc.descendants((node, pos) => {
if (!node.attrs.wrap && node.type.name !== 'image') return true;
const dom = view.nodeDOM(pos);
const rotor = dom instanceof HTMLElement ? dom.querySelector('.image-rotor, .textbox-rotor') : null;
const { src: _src, ...attrs } = node.attrs as Record<string, unknown>;
// The block after the picture's own, which is where a caption below it lands.
const after = view.state.doc.resolve(pos).after(1);
const next = after <= view.state.doc.content.size ? view.state.doc.nodeAt(after) : null;
const nextDOM = next ? view.nodeDOM(after) : null;
out.push({
pos,
attrs,
columnWidth: Math.round(host.clientWidth - padLeft - (parseFloat(cs.paddingRight) || 0)),
frame: rotor ? span(rotor.getBoundingClientRect()) : null,
caption: next && nextDOM instanceof HTMLElement && next.attrs.styleName === CAPTION_STYLE
? {
// The running number is an atom, so plain textContent leaves a gap where
// the reader sees "Figure 1".
text: next.textBetween(0, next.content.size, '', (leaf) =>
leaf.type.name === 'sequenceField' ? sequenceFieldText(leaf) : '').slice(0, 80),
span: span(textSpan(nextDOM)),
textAlign: getComputedStyle(nextDOM).textAlign,
indent: next.attrs.indent,
indentRight: next.attrs.indentRight,
}
: null,
});
return false;
});
return out;
}
+1 -61
View File
@@ -2,11 +2,10 @@ import { Node, mergeAttributes } from '@tiptap/core';
import type { Editor } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import { NodeSelection, Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { dropCursor } from '@tiptap/pm/dropcursor';
import { cmToPx } from '../../storage/pageMargins';
import { readVerticalMargins } from './pageBreaks';
import { CAPTION_STYLE, sequenceFieldText } from './caption';
// Inline, as-character image, or a floating text-wrapped frame (wrap = flow mode);
// width/height are doc px @96dpi, rotation CW degrees. Export → cm + ODF
@@ -731,62 +730,3 @@ class ImageView {
return event.type.startsWith('mouse');
}
}
export type FrameDebugEntry = {
pos: number;
/** Every attr but `src` — a data URI is the whole file. */
attrs: Record<string, unknown>;
/** Doc px from the text column's left edge, so the two spans compare directly. */
columnWidth: number;
frame: [number, number] | null;
caption: { text: string; span: [number, number]; textAlign: string; indent: unknown } | null;
};
// A frame's place in the column beside the caption's — "the caption is not under the
// picture" is a horizontal question, and every other section of the dump is vertical.
export function getFrameDebug(view: EditorView): FrameDebugEntry[] {
const host = view.dom as HTMLElement;
if (typeof getComputedStyle !== 'function' || !host.offsetWidth) return [];
const cs = getComputedStyle(host);
const padLeft = parseFloat(cs.paddingLeft) || 0;
const scale = host.getBoundingClientRect().width / host.offsetWidth;
const originX = host.getBoundingClientRect().left + padLeft * scale;
const span = (r: DOMRect): [number, number] =>
[Math.round((r.left - originX) / scale), Math.round((r.right - originX) / scale)];
const textSpan = (el: HTMLElement): DOMRect => {
const range = document.createRange();
range.selectNodeContents(el);
return range.getBoundingClientRect();
};
const out: FrameDebugEntry[] = [];
view.state.doc.descendants((node, pos) => {
if (node.type.name !== 'image') return true;
const dom = view.nodeDOM(pos);
const rotor = dom instanceof HTMLElement ? dom.querySelector('.image-rotor') : null;
const { src: _src, ...attrs } = node.attrs as Record<string, unknown>;
// The block after the picture's own, which is where a caption below it lands.
const after = view.state.doc.resolve(pos).after(1);
const next = after <= view.state.doc.content.size ? view.state.doc.nodeAt(after) : null;
const nextDOM = next ? view.nodeDOM(after) : null;
out.push({
pos,
attrs,
columnWidth: Math.round(host.clientWidth - padLeft - (parseFloat(cs.paddingRight) || 0)),
frame: rotor ? span(rotor.getBoundingClientRect()) : null,
caption: next && nextDOM instanceof HTMLElement && next.attrs.styleName === CAPTION_STYLE
? {
// The running number is an atom, so plain textContent leaves a gap where
// the reader sees "Figure 1".
text: next.textBetween(0, next.content.size, '', (leaf) =>
leaf.type.name === 'sequenceField' ? sequenceFieldText(leaf) : '').slice(0, 80),
span: span(textSpan(nextDOM)),
textAlign: getComputedStyle(nextDOM).textAlign,
indent: next.attrs.indent,
}
: null,
});
return false;
});
return out;
}
+7
View File
@@ -347,6 +347,13 @@
.paper .tiptap .image-node.image-selected {
z-index: 25;
}
/* A block after a float keeps its own box over the float — only its lines move out of
the way — so the block on top swallows every click meant for the frame. Nothing is
hidden by lifting it: text flows beside a frame, never across it. A page-anchored
one is excluded, since it carries its own place in the stack (image.ts). */
.paper .tiptap .image-node[data-wrap]:not([data-anchor-page]) {
z-index: 1;
}
.paper .tiptap .image-rotor {
position: absolute;
left: 50%;
+12 -6
View File
@@ -1958,11 +1958,13 @@ describe('Leg 18: per-paragraph text direction', () => {
describe('Leg 19: captions (a sequence field per category)', () => {
const SEQ = (category: string, number: number): N =>
({ type: 'sequenceField', attrs: { category, format: '1', number } });
const CAP = (category: string, label: string, n: number, text: string, textAlign?: string): N =>
P({ styleName: 'Caption', ...(textAlign ? { textAlign } : {}) }, T(`${label} `), SEQ(category, n), T(`: ${text}`));
const CAP = (category: string, label: string, n: number, text: string, box?: N): N =>
P({ styleName: 'Caption', ...box }, T(`${label} `), SEQ(category, n), T(`: ${text}`));
// The box a caption under a centred picture gets (caption.ts): as wide as the frame,
// centred in it. All three have to travel, or the caption drifts back to the margin.
const BOX = { textAlign: 'center', indent: 4.2, indentRight: 4.2 };
const doc: N = { type: 'doc', content: [
// A caption under a centred picture is centred with it, which is what has to travel.
CAP('figure', 'Figure', 1, 'first picture', 'center'),
CAP('figure', 'Figure', 1, 'first picture', BOX),
CAP('table', 'Table', 1, 'first table'),
CAP('figure', 'Figure', 2, 'second picture'),
] };
@@ -1981,7 +1983,9 @@ describe('Leg 19: captions (a sequence field per category)', () => {
check('numbers round-trip', fields.map((f: N) => f?.attrs?.number).join() === '1,1,2',
fields.map((f: N) => f?.attrs?.number));
check('caption style kept', back.content[0].attrs?.styleName === 'Caption', back.content[0].attrs);
check('the picture’s alignment kept', back.content[0].attrs?.textAlign === 'center', back.content[0].attrs);
check('the picture’s box kept', back.content[0].attrs?.textAlign === 'center'
&& back.content[0].attrs?.indent === 4.2 && back.content[0].attrs?.indentRight === 4.2,
back.content[0].attrs);
});
it('DOCX: a SEQ field per category, and back', async () => {
@@ -1995,7 +1999,9 @@ describe('Leg 19: captions (a sequence field per category)', () => {
fields.map((f: N) => f?.attrs));
check('numbers round-trip', fields.map((f: N) => f?.attrs?.number).join() === '1,1,2',
fields.map((f: N) => f?.attrs?.number));
check('the picture’s alignment kept', back.content[0].attrs?.textAlign === 'center', back.content[0].attrs);
check('the picture’s box kept', back.content[0].attrs?.textAlign === 'center'
&& back.content[0].attrs?.indent === 4.2 && back.content[0].attrs?.indentRight === 4.2,
back.content[0].attrs);
});
it('a document without captions declares no counter', async () => {
+12 -25
View File
@@ -5,7 +5,7 @@ import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import TextAlign from '@tiptap/extension-text-align';
import { sequenceFields, ODF_SEQ_CATEGORY, seqCategoryOf, SequenceField, framePlacement } from '../../src/lib/editor/extensions/caption';
import { sequenceFields, ODF_SEQ_CATEGORY, seqCategoryOf, SequenceField, captionPlacement } from '../../src/lib/editor/extensions/caption';
// The numbering rule both word processors apply: one counter per category, in document
// order. A schema of its own keeps the check off the editor's full extension list.
@@ -19,11 +19,6 @@ const schema = new Schema({
attrs: { category: { default: 'figure' }, number: { default: 1 } },
toDOM: () => ['span'],
},
image: {
group: 'inline', inline: true, atom: true,
attrs: { wrap: { default: 'inline' }, wrapAlign: { default: null }, wrapOffset: { default: null } },
toDOM: () => ['img'],
},
},
});
@@ -52,29 +47,20 @@ describe('sequenceFields', () => {
});
});
describe('framePlacement', () => {
const picture = (attrs: Record<string, unknown>) =>
schema.nodes.paragraph.create(null, schema.nodes.image.create(attrs));
it('centres the caption under a frame that names neither side nor offset', () => {
expect(framePlacement(picture({ wrap: 'topBottom' }))).toEqual({ textAlign: 'center', indent: null });
describe('captionPlacement', () => {
it('boxes the caption to a centred frame, and centres it in that box', () => {
expect(captionPlacement(4.2, 4.2)).toEqual({ indent: 4.2, indentRight: 4.2, textAlign: 'center' });
});
it('follows the side a frame is set against', () => {
expect(framePlacement(picture({ wrap: 'topBottom', wrapAlign: 'right' })))
.toEqual({ textAlign: 'right', indent: null });
it('leaves a frame against the far edge left-flush in its box', () => {
// The indent is what lifts the caption clear of a side-wrapped frame: its lines
// have no room beside the float and drop below it.
expect(captionPlacement(9.45, 0)).toEqual({ indent: 9.45, indentRight: null, textAlign: null });
});
it('indents to a frame’s own offset, which no alignment can express', () => {
expect(framePlacement(picture({ wrap: 'topBottom', wrapOffset: 3.4 })))
.toEqual({ textAlign: null, indent: 3.4 });
});
it('leaves an inline or side-wrapped picture to the flow', () => {
// A side wrap has the caption beside the frame — where LibreOffice puts it too.
expect(framePlacement(picture({ wrap: 'inline' }))).toBeNull();
expect(framePlacement(picture({ wrap: 'right' }))).toBeNull();
expect(framePlacement(schema.nodes.paragraph.create(null, schema.text('no picture')))).toBeNull();
it('indents nothing for a frame that fills the column', () => {
expect(captionPlacement(0, 0)).toEqual({ indent: null, indentRight: null, textAlign: null });
expect(captionPlacement(0.02, 0.01)).toEqual({ indent: null, indentRight: null, textAlign: null });
});
});
@@ -91,6 +77,7 @@ describe('insertCaption', () => {
const insert = (editor: Editor) =>
editor.chain().focus().insertCaption({ category: 'figure', label: 'Figure', separator: ': ', text: 'x', above: false }).run();
// No frame here, which is the fallback leg: a block without one has only its alignment.
it('takes the alignment of the block it captions, so it stands under the picture', () => {
const editor = makeEditor('center');
insert(editor);