fix: a caption follows the frame's place, not the paragraph's
A picture with "above and below" wrap is a frame centred in the column while its paragraph carries no alignment at all, so the caption still went to the left margin. framePlacement reads the place off the frame: centred, the side it is set against, or its offset as the caption's indent. The debug dump gains a frames section — every other one measures the flow downwards, and this is a sideways question.
This commit is contained in:
1 parent
c4a74f92b1
commit
61ddf69d54
6 files changed
+129
-7
No files matched your search
@@ -18,6 +18,7 @@
|
||||
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 { getColorDebug } from './lib/utils/colorDebug';
|
||||
import { resetHistoryLog } from './lib/utils/historyLog.svelte';
|
||||
@@ -928,6 +929,9 @@
|
||||
pageBreaks: snapshot,
|
||||
columnsFlow: getColumnsFlowDebug(editor.view),
|
||||
textBoxes: getTextBoxDebug(editor.view),
|
||||
// Where each picture sits in the column beside its caption: the rest of the dump
|
||||
// measures the flow downwards, and a misplaced caption is a sideways question.
|
||||
frames: getFrameDebug(editor.view),
|
||||
// Cell alignment reads as broken whenever the content fills the box, so the
|
||||
// dump carries the spacing that decides that: the sheet and the model it uses.
|
||||
tableCells: getTableCellDebug(editor.view),
|
||||
|
||||
@@ -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), 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)` (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 **alignment of the block it captions**, so it stands under a centred or right-aligned picture instead of at the margin — LibreOffice frames picture and caption together, and a loose paragraph can only carry that over as its alignment. `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 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.
|
||||
- **`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.
|
||||
|
||||
@@ -61,6 +61,28 @@ 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;
|
||||
});
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
export const SequenceField = Node.create({
|
||||
name: 'sequenceField',
|
||||
group: 'inline',
|
||||
@@ -118,10 +140,15 @@ export const SequenceField = Node.create({
|
||||
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 the alignment is what carries that over to a loose paragraph.
|
||||
const textAlign = $from.node(1).attrs.textAlign ?? null;
|
||||
// two together, and a loose paragraph carries that over as its own placement.
|
||||
const block = $from.node(1);
|
||||
const place = framePlacement(block) ?? { textAlign: block.attrs.textAlign ?? null, indent: null };
|
||||
return chain()
|
||||
.insertContentAt(at, { type: 'paragraph', attrs: { styleName: CAPTION_STYLE, textAlign }, content })
|
||||
.insertContentAt(at, {
|
||||
type: 'paragraph',
|
||||
attrs: { styleName: CAPTION_STYLE, textAlign: place.textAlign, indent: place.indent },
|
||||
content,
|
||||
})
|
||||
.focus()
|
||||
.run();
|
||||
},
|
||||
|
||||
@@ -2,10 +2,11 @@ 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 } from '@tiptap/pm/view';
|
||||
import { Decoration, DecorationSet, type EditorView } 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
|
||||
@@ -730,3 +731,62 @@ 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;
|
||||
}
|
||||
@@ -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 } from '../../src/lib/editor/extensions/caption';
|
||||
import { sequenceFields, ODF_SEQ_CATEGORY, seqCategoryOf, SequenceField, framePlacement } 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,6 +19,11 @@ 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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,6 +52,32 @@ 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 });
|
||||
});
|
||||
|
||||
it('follows the side a frame is set against', () => {
|
||||
expect(framePlacement(picture({ wrap: 'topBottom', wrapAlign: 'right' })))
|
||||
.toEqual({ textAlign: 'right', indent: 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertCaption', () => {
|
||||
const makeEditor = (textAlign: string | null) => {
|
||||
const el = document.createElement('div');
|
||||
|
||||
Reference in new issue
Block a user