fix: a caption stands under its picture, not at the margin
It was inserted as a plain paragraph, so a centred or right-aligned picture got its caption at the left edge of the text column. LibreOffice frames picture and caption together; a loose paragraph carries that over as the alignment of the block it captions. Measured in the live editor: a centred 240px picture at 520…760 now has its caption text at 580…700.
This commit is contained in:
1 parent
ca001e96d9
commit
c4a74f92b1
4 files changed
+47
-6
No files matched your search
@@ -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. `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 **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.
|
||||
- **`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.
|
||||
|
||||
@@ -117,8 +117,11 @@ 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 the alignment is what carries that over to a loose paragraph.
|
||||
const textAlign = $from.node(1).attrs.textAlign ?? null;
|
||||
return chain()
|
||||
.insertContentAt(at, { type: 'paragraph', attrs: { styleName: CAPTION_STYLE }, content })
|
||||
.insertContentAt(at, { type: 'paragraph', attrs: { styleName: CAPTION_STYLE, textAlign }, content })
|
||||
.focus()
|
||||
.run();
|
||||
},
|
||||
|
||||
@@ -1958,10 +1958,11 @@ 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): N =>
|
||||
P({ styleName: 'Caption' }, T(`${label} `), SEQ(category, n), T(`: ${text}`));
|
||||
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 doc: N = { type: 'doc', content: [
|
||||
CAP('figure', 'Figure', 1, 'first picture'),
|
||||
// A caption under a centred picture is centred with it, which is what has to travel.
|
||||
CAP('figure', 'Figure', 1, 'first picture', 'center'),
|
||||
CAP('table', 'Table', 1, 'first table'),
|
||||
CAP('figure', 'Figure', 2, 'second picture'),
|
||||
] };
|
||||
@@ -1980,6 +1981,7 @@ 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);
|
||||
});
|
||||
|
||||
it('DOCX: a SEQ field per category, and back', async () => {
|
||||
@@ -1993,6 +1995,7 @@ 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);
|
||||
});
|
||||
|
||||
it('a document without captions declares no counter', async () => {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Schema } from '@tiptap/pm/model';
|
||||
import { sequenceFields, ODF_SEQ_CATEGORY, seqCategoryOf } from '../../src/lib/editor/extensions/caption';
|
||||
import { Editor } from '@tiptap/core';
|
||||
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';
|
||||
|
||||
// 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.
|
||||
@@ -42,6 +47,36 @@ describe('sequenceFields', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertCaption', () => {
|
||||
const makeEditor = (textAlign: string | null) => {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
return new Editor({
|
||||
element: el,
|
||||
extensions: [Document, Paragraph, Text, SequenceField, TextAlign.configure({ types: ['paragraph'] })],
|
||||
content: { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign }, content: [{ type: 'text', text: 'picture' }] }] },
|
||||
});
|
||||
};
|
||||
const insert = (editor: Editor) =>
|
||||
editor.chain().focus().insertCaption({ category: 'figure', label: 'Figure', separator: ': ', text: 'x', above: false }).run();
|
||||
|
||||
it('takes the alignment of the block it captions, so it stands under the picture', () => {
|
||||
const editor = makeEditor('center');
|
||||
insert(editor);
|
||||
const [, caption] = editor.getJSON().content as any[];
|
||||
expect(caption.attrs.textAlign).toBe('center');
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it('leaves the caption of an unaligned block unaligned', () => {
|
||||
const editor = makeEditor(null);
|
||||
insert(editor);
|
||||
const [, caption] = editor.getJSON().content as any[];
|
||||
expect(caption.attrs.textAlign ?? null).toBe(null);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('category names', () => {
|
||||
it('reads both spellings of the picture counter', () => {
|
||||
expect(ODF_SEQ_CATEGORY.Illustration).toBe('figure'); // LibreOffice's
|
||||
|
||||
Reference in new issue
Block a user