diff --git a/docs/architecture/frames.md b/docs/architecture/frames.md
index eb3886f..a6bce35 100644
--- a/docs/architecture/frames.md
+++ b/docs/architecture/frames.md
@@ -4,7 +4,7 @@ Covers `src/lib/editor/extensions/image.ts` and `textBox.ts` — node views, res
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'|'through'`) 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), and `through` — Word's in-front-of / behind-text (`wp:wrapNone` + `behindDoc`), ODF's `style:wrap="run-through"` — leaves the flow entirely: `position: absolute` with no offsets keeps the static position it was anchored at, so it reserves neither width nor height, and `inFront` picks the side of the text it lands on. Read as a side float instead it reserved a near-column-wide picture's whole height, which cost the image fixture a page and ~50 reports. A text box in that mode always sits behind the text — the mode has no toolbar entry yet, so only a file can carry it in. 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. The **block after the anchor paragraph clears the band** (an `editor.css` `:has` rule): a float only pushes lines that intersect it, so a short block (a heading) would slot into the clearance gap a prior side float opens above the band — both word processors start it below; a page-break spacer and the block holding a band-sharing partner frame are exempt. 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`) — an **inline** text box / basic shape carrying editable block content (`group: 'inline'`, `inline: true`, `content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`) — the frame both formats write, a character in a run that holds paragraphs. It rides a paragraph, so `inline` really is in the line, and a box reaches a table cell or a list item through their paragraphs, as one does in Word. Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`), `shapeKind` (see the shape table below), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). **Wrap** follows `ImageView.applyWrap` exactly: `inline` is an `inline-block` in the line, `left`/`right` are floats, `topBottom` a **full-width float** whose wrapper spans the column while `placeInBand` moves the rotor across it, `through` leaves the flow. Never `display:block` — that splits the paragraph's inline content into anonymous block boxes and ProseMirror drops the following page-break spacer. A paragraph holding a box paginates **atomically** (`pageBreaks.ts`), whichever way the box wraps. `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 drag moves it without hijacking text selection. Commands: `insertTextBox()` (at the caret, cursor inside; refused inside a box), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`), `setTextBoxAlign()`. **Two guards** the schema cannot express live in the extension's plugin: a `filterTransaction` refuses a box inside a box — both word processors refuse it and neither format's writer has a place for one — and an `appendTransaction` grows a selection ending inside a box it did not start in out to the box's own bounds, since deleting such a range dissolves the frame and spills its text into the body. `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke, the alignment and `textVertical` — the box's text running **top-to-bottom, right-to-left** (`writing-mode: vertical-rl` on the content area, which is all the browser needs). It round-trips as the style's own writing mode in ODF — a plain box is a Writer text frame (its `TbxFr` style chains to `Frame`; see `src/lib/export/CLAUDE.md`) and takes it in the graphic properties, a drawing *shape* only in its style's paragraph properties (both probed) — and Word's `w:bodyPr vert`; ODF's bottom-to-top and Word's `vert270` read as the same one direction the browser lays out. **DOCX autofit:** the shape body writes ``, which is what LibreOffice writes for the same frame — read as `spAutoFit` it gives the frame `fo:min-height="0"` and draws its text detached from the shape, over whatever follows (probed). The cost is the ODF leg's auto-grow: content taller than the declared height is clipped there, DOCX having no `fo:min-height`. **LibreOffice puts a `wrapTopAndBottom` anchor's band above its anchor paragraph** whatever the anchor says — probed against `posOffset` 0/635, `align top`, `relativeFrom` line/column, `allowOverlap`, and against a file LibreOffice exported itself; `wp:inline` is the one form it places right, and a band-wrapped box keeps `wp:anchor` anyway (Word is the reference for `.docx`). **Where it sits across the column:** a band-wrapped box takes `wrapAlign` (`null`/`center`/`right`, drawn by `placeInBand`), and the toolbar drops any `wrapOffset` when one is picked. A box **in the line** is a character and has none of its own: its paragraph aligns it, which is what both formats write, so the same buttons set the paragraph's `textAlign` through `setTextBoxAlign`. A side wrap names the side itself, so the buttons hide there. An as-char frame must name the **baseline pair** explicitly in ODF (`style:vertical-pos="top" style:vertical-rel="baseline"`): the `Frame` parent hangs a frame that names none below the line, where a style-less as-char frame (an image) stands on it — probed, and the measured difference between the two formats until it was written.
+- **`textBox.ts`** (`TextBox`) — an **inline** text box / basic shape carrying editable block content (`group: 'inline'`, `inline: true`, `content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`) — the frame both formats write, a character in a run that holds paragraphs. It rides a paragraph, so `inline` really is in the line, and a box reaches a table cell or a list item through their paragraphs, as one does in Word. Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`), `shapeKind` (see the shape table below), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). **Wrap** follows `ImageView.applyWrap` exactly: `inline` is an `inline-block` in the line, `left`/`right` are floats, `topBottom` a **full-width float** whose wrapper spans the column while `placeInBand` moves the rotor across it, `through` leaves the flow. Never `display:block` — that splits the paragraph's inline content into anonymous block boxes and ProseMirror drops the following page-break spacer. A paragraph holding a box paginates **atomically** (`pageBreaks.ts`), whichever way the box wraps. `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 drag moves it without hijacking text selection. Commands: `insertTextBox()` (at the caret, cursor inside; refused inside a box), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`), `setTextBoxAlign()`. **Two guards** the schema cannot express live in the extension's plugin: a `filterTransaction` refuses a box inside a box — both word processors refuse it and neither format's writer has a place for one — and an `appendTransaction` grows a selection ending inside a box it did not start in out to the box's own bounds, since deleting such a range dissolves the frame and spills its text into the body. `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke, the alignment and `textVertical` — the box's text running **top-to-bottom, right-to-left** (`writing-mode: vertical-rl` on the content area, which is all the browser needs). It round-trips as the style's own writing mode in ODF — a plain box is a Writer text frame (its `TbxFr` style chains to `Frame`; see `src/lib/export/CLAUDE.md`) and takes it in the graphic properties, a drawing *shape* only in its style's paragraph properties (both probed) — and Word's `w:bodyPr vert`; ODF's bottom-to-top and Word's `vert270` read as the same one direction the browser lays out. **DOCX autofit:** the shape body writes ``, which is what LibreOffice writes for the same frame — read as `spAutoFit` it gives the frame `fo:min-height="0"` and draws its text detached from the shape, over whatever follows (probed). The cost is the ODF leg's auto-grow: content taller than the declared height is clipped there, DOCX having no `fo:min-height`. **LibreOffice puts a `wrapTopAndBottom` anchor's band above its anchor paragraph** whatever the anchor says — probed against `posOffset` 0/635, `align top`, `relativeFrom` line/column, `allowOverlap`, and against a file LibreOffice exported itself; `wp:inline` is the one form it places right, and a band-wrapped box keeps `wp:anchor` anyway (Word is the reference for `.docx`). **Where it sits across the column:** only a **band-wrapped** box has a place of its own — `wrapAlign` (`null`/`center`/`right`, drawn by `placeInBand`), and the toolbar's three buttons show for that mode alone, dropping any `wrapOffset` when one is picked. A side wrap names the side itself; a box **in the line** is a character its paragraph places, exactly as a picture is, so the ordinary paragraph alignment does it and a second control on the frame toolbar would only be the same thing under a frame icon. That ordinary command needs one correction, though (`TextAlignInFrames`): a **selected** box is one node whose range covers its own paragraphs, so it would align the box's contents along with the block holding it — with the frame selected only that block is meant (`setTextBoxAlign`), while the caret inside the box still aligns that text. An as-char frame must name the **baseline pair** explicitly in ODF (`style:vertical-pos="top" style:vertical-rel="baseline"`): the `Frame` parent hangs a frame that names none below the line, where a style-less as-char frame (an image) stands on it — probed, and the measured difference between the two formats until it was written.
## A box in static HTML
diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte
index 39ff0c4..1b368b8 100644
--- a/src/lib/components/Editor.svelte
+++ b/src/lib/components/Editor.svelte
@@ -674,9 +674,9 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// just above it. Hidden while the image toolbar shows (an image inside a box).
let textBoxUi = $state<{
visible: boolean; top: number; left: number;
- wrap: WrapMode; wrapAlign: string | null; paraAlign: string | null; shapeKind: ShapeKind; fillColor: string | null;
+ wrap: WrapMode; wrapAlign: string | null; shapeKind: ShapeKind; fillColor: string | null;
strokeColor: string | null; strokeWidthPt: number; textVertical: boolean;
- }>({ visible: false, top: 0, left: 0, wrap: 'inline', wrapAlign: null, paraAlign: null, shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1, textVertical: false });
+ }>({ visible: false, top: 0, left: 0, wrap: 'inline', wrapAlign: null, shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1, textVertical: false });
function recomputeTextBoxUi() {
const ed = editor;
@@ -702,8 +702,6 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
left: r.left - cRect.left + container!.scrollLeft,
wrap: (a.wrap as WrapMode) || 'inline',
wrapAlign: (a.wrapAlign as string | null) ?? null,
- // The box's own paragraph places an in-line box, so the toolbar reads it there.
- paraAlign: (view!.state.doc.resolve(found.pos).parent.attrs?.textAlign as string | null) ?? null,
shapeKind: (a.shapeKind as ShapeKind) || 'textbox',
fillColor: (a.fillColor as string | null) ?? null,
strokeColor: (a.strokeColor as string | null) ?? null,
@@ -1565,7 +1563,6 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
left={textBoxUi.left}
wrap={textBoxUi.wrap}
wrapAlign={textBoxUi.wrapAlign}
- paraAlign={textBoxUi.paraAlign}
shapeKind={textBoxUi.shapeKind}
fillColor={textBoxUi.fillColor}
strokeColor={textBoxUi.strokeColor}
diff --git a/src/lib/components/TextBoxToolbar.svelte b/src/lib/components/TextBoxToolbar.svelte
index 80ca53b..ca14d9f 100644
--- a/src/lib/components/TextBoxToolbar.svelte
+++ b/src/lib/components/TextBoxToolbar.svelte
@@ -12,7 +12,6 @@
left,
wrap,
wrapAlign,
- paraAlign,
shapeKind,
fillColor,
strokeColor,
@@ -24,7 +23,6 @@
left: number;
wrap: WrapMode;
wrapAlign: string | null;
- paraAlign: string | null;
shapeKind: ShapeKind;
fillColor: string | null;
strokeColor: string | null;
@@ -47,13 +45,13 @@
: t().textBox.wrapTopBottom;
}
- // Where the box sits across the column. A side wrap has no such choice — the wrap
- // names the side — so the buttons show for the two modes that leave it in flow.
+ // Where the box sits across the band it spans. Only a band-wrapped box has such a
+ // choice of its own: a side wrap names the side, and a box in the line is a character
+ // its paragraph places — there the ordinary paragraph alignment does it, as on a
+ // picture, so a second control here would only be the same thing under a frame icon.
const alignModes = ['left', 'center', 'right'] as const;
- const alignable = $derived(wrap === 'inline' || wrap === 'topBottom');
- // A box in the line is a character: its paragraph places it, and that is what both
- // formats write. A band-wrapped box has a place of its own across the column.
- const current = $derived(wrap === 'inline' ? paraAlign ?? 'left' : wrapAlign ?? 'left');
+ const alignable = $derived(wrap === 'topBottom');
+ const current = $derived(wrapAlign ?? 'left');
function alignTitle(a: (typeof alignModes)[number]): string {
return a === 'left' ? t().textBox.alignLeft
: a === 'center' ? t().textBox.alignCenter
@@ -62,8 +60,7 @@
// An imported box placed by coordinate keeps that x over any alignment, so picking
// one drops it.
function setAlign(a: (typeof alignModes)[number]) {
- if (wrap === 'inline') editor?.chain().focus().setTextBoxAlign(a === 'left' ? null : a).run();
- else set({ wrapAlign: a === 'left' ? null : a, wrapOffset: null });
+ set({ wrapAlign: a === 'left' ? null : a, wrapOffset: null });
}
const strokeWidths = [0.5, 1, 2.25];
diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts
index 799edae..0b732c2 100644
--- a/src/lib/editor/extensions.ts
+++ b/src/lib/editor/extensions.ts
@@ -24,7 +24,7 @@ import { Table, TableHeader, TableCell } from '@tiptap/extension-table';
import { ResizableTableRow } from './extensions/tableRow';
import History from '@tiptap/extension-history';
import Placeholder from '@tiptap/extension-placeholder';
-import TextAlign from '@tiptap/extension-text-align';
+
import { PageBreaks } from './extensions/pageBreaks';
import { LineHeight } from './extensions/lineHeight';
import { ParagraphSpacing } from './extensions/paragraphSpacing';
@@ -61,7 +61,7 @@ import { BibliographyEntry } from './extensions/bibliographyEntry';
import { CrossReference } from './extensions/crossReference';
import { Ruby } from './extensions/ruby';
import { Formula } from './extensions/formula';
-import { TextBox } from './extensions/textBox';
+import { TextBox, TextAlignInFrames } from './extensions/textBox';
import { Columns } from './extensions/columns';
import { ColumnsFlow } from './extensions/columnsFlow';
import { TableOfContents } from './extensions/tableOfContents';
@@ -227,7 +227,8 @@ export const extensions = [
// No defaultAlignment: it would put textAlign:'left' on every block, and that inline
// style beats the paragraph style's alignment. Unset = follow the style; an explicit
// 'left' stays direct formatting that overrides it, as in Word/LibreOffice.
- TextAlign.configure({
+ // Taught that a selected box means the block it sits in (textBox.ts).
+ TextAlignInFrames.configure({
types: ['paragraph', 'heading'],
alignments: ['left', 'center', 'right', 'justify'],
}),
diff --git a/src/lib/editor/extensions/textBox.ts b/src/lib/editor/extensions/textBox.ts
index 599c49a..544d7f7 100644
--- a/src/lib/editor/extensions/textBox.ts
+++ b/src/lib/editor/extensions/textBox.ts
@@ -1,4 +1,6 @@
import { Node, mergeAttributes } from '@tiptap/core';
+import type { CommandProps } from '@tiptap/core';
+import TextAlign from '@tiptap/extension-text-align';
import type { Editor } from '@tiptap/core';
import type { Node as PMNode, Slice } from '@tiptap/pm/model';
import { NodeSelection, TextSelection, Plugin } from '@tiptap/pm/state';
@@ -881,3 +883,24 @@ export function getTextBoxDebug(view: EditorView): TextBoxDebugEntry[] {
});
return out;
}
+
+// A selected text box is one node whose range covers its own paragraphs, so the command
+// would align the box's contents along with the block holding it — moving the frame and
+// re-setting its text in one keystroke. With the frame selected only the block it sits
+// in is meant; the caret inside the box still aligns that text.
+export const TextAlignInFrames = TextAlign.extend({
+ addCommands() {
+ const base = this.parent?.() ?? {};
+ const onBox = (align: string | null) => (props: CommandProps) =>
+ props.state.selection instanceof NodeSelection
+ && props.state.selection.node.type.name === 'textBox'
+ && props.chain().setTextBoxAlign(align).run();
+ return {
+ ...base,
+ setTextAlign: (align: string) => (props: CommandProps) =>
+ onBox(align)(props) || base.setTextAlign!(align)(props),
+ unsetTextAlign: () => (props: CommandProps) =>
+ onBox(null)(props) || base.unsetTextAlign!()(props),
+ };
+ },
+});
diff --git a/tests/unit/text-box-guards.test.ts b/tests/unit/text-box-guards.test.ts
index 0a730a0..1dee597 100644
--- a/tests/unit/text-box-guards.test.ts
+++ b/tests/unit/text-box-guards.test.ts
@@ -11,7 +11,7 @@ import Heading from '@tiptap/extension-heading';
import { BulletList } from '../../src/lib/editor/extensions/bulletList';
import { OrderedList } from '../../src/lib/editor/extensions/orderedList';
import ListItem from '@tiptap/extension-list-item';
-import { TextBox } from '../../src/lib/editor/extensions/textBox';
+import { TextBox, TextAlignInFrames } from '../../src/lib/editor/extensions/textBox';
type N = any;
@@ -33,7 +33,8 @@ function makeEditor(...content: N[]) {
document.body.appendChild(el);
return new Editor({
element: el,
- extensions: [Document, Paragraph, Text, Heading, BulletList, OrderedList, ListItem, TextBox],
+ extensions: [Document, Paragraph, Text, Heading, BulletList, OrderedList, ListItem, TextBox,
+ TextAlignInFrames.configure({ types: ['paragraph', 'heading'] })],
content: { type: 'doc', content },
});
}
@@ -132,3 +133,26 @@ describe('inserting a text box', () => {
ed.destroy();
});
});
+
+describe('aligning a selected box', () => {
+ it('moves the block it sits in, not the text inside it', () => {
+ const ed = makeEditor(...doc());
+ ed.commands.setNodeSelection(boxPos(ed));
+ ed.commands.setTextAlign('right');
+ const outer = ed.state.doc.child(0);
+ const inner = outer.content.child(1).content.child(0);
+ expect(outer.attrs.textAlign).toBe('right');
+ expect(inner.attrs.textAlign).toBeNull();
+ ed.destroy();
+ });
+
+ it('aligns the text inside when the caret is in the box', () => {
+ const ed = makeEditor(...doc());
+ ed.commands.setTextSelection(boxPos(ed) + 2);
+ ed.commands.setTextAlign('center');
+ const outer = ed.state.doc.child(0);
+ expect(outer.attrs.textAlign).toBeNull();
+ expect(outer.content.child(1).content.child(0).attrs.textAlign).toBe('center');
+ ed.destroy();
+ });
+});