diff --git a/docs/architecture/frames.md b/docs/architecture/frames.md
index cea94c5..d38bc1d 100644
--- a/docs/architecture/frames.md
+++ b/docs/architecture/frames.md
@@ -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 `
` 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`), rendered as the float's near/far margin against the live column vars — an indented anchor paragraph can't skew it — and shared with `textBox.ts` through `frameMargins`. A `topBottom` float spans the column, so its own x moves the *rotor* inside the wrapper instead. `wrapOffsetY` (`positionV`, `svg:y`) is the frame's top below the anchor paragraph's, drawn for `topBottom` only — no text sits beside such a frame, where a side float's top margin would push away lines Word keeps. Chromium places **every** line after a full-width float below it, even the ones the float's top margin leaves room for, so a frame set below the paragraph top is sunk behind that paragraph's text at import (`sinkOffsetFrames`, both importers) and `ImageView.sinkToOffset` measures what the lines above it already cover, adding only the rest. Two `topBottom` frames set against **opposite ends** of nearby paragraphs (`wrapAlign`, from Word's `positionH` align / ODF `style:horizontal-pos`) share one band and float to their own sides instead: `pairAlignedFrames` (import/odt.ts, both importers) keeps the attr only for such a pair — a lone one reserves the whole band, which is what the wrap means — and scales a pair up to 15% wider than the column down to it, since Word lets the two overlap in the middle and two floats cannot. A frame Word puts *inside* running text still lands after it: browsers can't wrap text around a freely-positioned box (CSS Exclusions are unimplemented). Inline images stay as-char. Works inside table cells. Not available while editing a header/footer (the HF schema has no image node). Images live as data-URIs in the autosaved JSON; `autosave.ts` warns once if that exceeds the localStorage quota.
- **`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
diff --git a/src/App.svelte b/src/App.svelte
index 02ac451..e33e900 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -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';
diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md
index ff48e2b..7bfc904 100644
--- a/src/lib/components/CLAUDE.md
+++ b/src/lib/components/CLAUDE.md
@@ -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
diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md
index d7e8120..72ac360 100644
--- a/src/lib/editor/extensions/CLAUDE.md
+++ b/src/lib/editor/extensions/CLAUDE.md
@@ -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.
diff --git a/src/lib/editor/extensions/caption.ts b/src/lib/editor/extensions/caption.ts
index 04c2641..17b1b0b 100644
--- a/src/lib/editor/extensions/caption.ts
+++ b/src/lib/editor/extensions/caption.ts
@@ -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;
+ /** 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;
+ // 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;
+}
diff --git a/src/lib/editor/extensions/image.ts b/src/lib/editor/extensions/image.ts
index 5d51ca9..783b66d 100644
--- a/src/lib/editor/extensions/image.ts
+++ b/src/lib/editor/extensions/image.ts
@@ -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;
- /** 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;
- // 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;
-}
diff --git a/src/styles/editor.css b/src/styles/editor.css
index 6cfb423..f78c074 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -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%;
diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts
index 9bce129..3d67827 100644
--- a/tests/roundtrip.test.ts
+++ b/tests/roundtrip.test.ts
@@ -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 () => {
diff --git a/tests/unit/caption.test.ts b/tests/unit/caption.test.ts
index a3f7eae..5c70e6c 100644
--- a/tests/unit/caption.test.ts
+++ b/tests/unit/caption.test.ts
@@ -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) =>
- 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);