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:
1 parent
61ddf69d54
commit
46165b82f7
9 files changed
+162
-117
No files matched your search
+1
-1
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%;
|
||||
|
||||
Reference in new issue
Block a user