From 060dcd73805cf96daca7881c50fcc730352b14a8 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sun, 30 Aug 2026 17:56:06 +0000 Subject: [PATCH] feat: a text box sits in the line of text textBox becomes an inline node carrying block content, like the frame both formats write, so "in line with text" really is in the line and a box reaches a table cell or a list item through their paragraphs. Both exports write it where it sits (an as-char draw:frame / a wp:inline drawing) and both imports leave it there, which drops the hoisting machinery on either side. Two guards the schema cannot express: no box inside a box, and a selection reaching into a box grows to the box's bounds rather than dissolving it. --- docs/architecture/frames.md | 15 +- src/App.svelte | 13 +- src/lib/components/Editor.svelte | 7 +- src/lib/components/LineNumberLayer.svelte | 18 +- src/lib/components/TextBoxToolbar.svelte | 14 +- src/lib/editor/extensions.ts | 8 +- src/lib/editor/extensions/caption.ts | 4 +- src/lib/editor/extensions/pageBreaks.ts | 35 +-- src/lib/editor/extensions/paragraphSpacing.ts | 4 +- src/lib/editor/extensions/textBox.ts | 157 +++++++++++--- src/lib/editor/extensions/trailingNode.ts | 2 +- src/lib/export/CLAUDE.md | 3 +- src/lib/export/docx.ts | 39 ++-- src/lib/export/odt.ts | 99 ++++----- src/lib/export/pdf.ts | 30 ++- src/lib/import/CLAUDE.md | 4 +- src/lib/import/docx.ts | 48 +---- src/lib/import/odt.ts | 92 +++----- src/styles/editor.css | 9 +- tests/docx-roundtrip.test.ts | 12 +- tests/fuzzDoc.ts | 3 +- tests/kitchenSink.ts | 12 +- tests/lo-roundtrip.test.ts | 13 +- tests/roundtrip.test.ts | 201 ++++++++++++++---- tests/unit/docx-export.test.ts | 2 +- tests/unit/freeform-shape.test.ts | 13 +- tests/unit/print-text-box.test.ts | 42 ++++ tests/unit/text-box-guards.test.ts | 134 ++++++++++++ tests/unit/vertical-text-box.test.ts | 12 +- 29 files changed, 700 insertions(+), 345 deletions(-) create mode 100644 tests/unit/print-text-box.test.ts create mode 100644 tests/unit/text-box-guards.test.ts diff --git a/docs/architecture/frames.md b/docs/architecture/frames.md index 5570dd3..eb3886f 100644 --- a/docs/architecture/frames.md +++ b/docs/architecture/frames.md @@ -4,7 +4,20 @@ 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`) — 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` — an in-flow box also `clear`s, since a block box is laid *over* a float rather than beside it, and both word processors put it on the next free line), `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, `wrapAlign` 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. `trailingNode.ts` appends a paragraph after a trailing box; `pageBreaks.ts` paginates `.textbox-node` atomically. **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. **Where it sits across the column:** the two modes that leave the box in flow (`inline`, `topBottom`) take `wrapAlign` — `null`/`center`/`right`, drawn as the wrapper's auto margins, and the toolbar drops any `wrapOffset` when one is picked. A side wrap names the side itself, so the buttons hide there. **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. +- **`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. + +## A box in static HTML + +A box is inline and its content is blocks, and no `

` can hold those: the browser's +parser breaks the paragraph open around it. That only bites where the document goes +through an **HTML string** — `generateHTML`, `editor.getHTML()`. Copy and paste inside +the editor is unaffected (ProseMirror pastes its own cached slice), and the raster PDF +path reads the live DOM. The **vector print path** (`export/pdf.ts`) handles it: +`hoistTextBoxes` gives each box a paragraph of its own before serializing, and +`buildBodyHtml` drops the two empty paragraphs the split then leaves around it — so the +box prints as a block between whole paragraphs, which is how it printed as one. +An **AutoText** entry holding a box keeps the box's text and loses the frame; so does a +paste from another window. ## Clicking a float diff --git a/src/App.svelte b/src/App.svelte index fee0285..6df34f2 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -535,7 +535,18 @@ // recorded, the RECORDING meta keeps the whole file from arriving as this author's // insertion. Both word processors record what is typed after an open, not the file. function loadContent(content: Content): void { - editor?.chain().setMeta(RECORDING, true).setContent(content).run(); + editor?.chain().setMeta(RECORDING, true).setContent(wrapLooseTextBoxes(content)).run(); + } + + // A text box used to be a block of its own; it is inline now, so a document written + // before that (an autosave, a recent file) would lose every box it holds to the + // schema. Give each one the paragraph it now needs. + function wrapLooseTextBoxes(content: Content): Content { + if (!content || typeof content !== 'object' || Array.isArray(content)) return content; + const doc = content as { content?: { type?: string }[] }; + if (!doc.content?.some(n => n?.type === 'textBox')) return content; + return { ...doc, content: doc.content.map(n => + n?.type === 'textBox' ? { type: 'paragraph', content: [n] } : n) } as Content; } // Reset every document side-car to its default; the $effects persist these. diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 1b368b8..39ff0c4 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; shapeKind: ShapeKind; fillColor: string | null; + wrap: WrapMode; wrapAlign: string | null; paraAlign: string | null; shapeKind: ShapeKind; fillColor: string | null; strokeColor: string | null; strokeWidthPt: number; textVertical: boolean; - }>({ visible: false, top: 0, left: 0, wrap: 'inline', wrapAlign: null, shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1, textVertical: false }); + }>({ visible: false, top: 0, left: 0, wrap: 'inline', wrapAlign: null, paraAlign: null, shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1, textVertical: false }); function recomputeTextBoxUi() { const ed = editor; @@ -702,6 +702,8 @@ 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, @@ -1563,6 +1565,7 @@ 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/LineNumberLayer.svelte b/src/lib/components/LineNumberLayer.svelte index 0b78e75..2e53179 100644 --- a/src/lib/components/LineNumberLayer.svelte +++ b/src/lib/components/LineNumberLayer.svelte @@ -62,8 +62,9 @@ const range = document.createRange(); range.selectNodeContents(block); let rects = Array.from(range.getClientRects()).filter((r) => r.height > 0); - // A frame floated out of the flow (wrapped image, text box) is no text line of its - // anchor paragraph — its band would otherwise count as an extra line. + // A frame is no text line of its paragraph: a floated one's band would count as an + // extra line, an in-line one's box as a second line beside the text it shares. + // A paragraph left with no rect at all falls back to its own box — one line. const frames = block.querySelectorAll('.image-node[data-wrap], .textbox-node'); if (frames.length) { const fr = Array.from(frames, (f) => f.getBoundingClientRect()); @@ -104,21 +105,16 @@ let count = 0; let page = 1; // Text blocks count — list items and column lines too, as in both word processors; - // a table, an image frame or an index is not a numbered line in either. A top-level - // text box counts as one empty line: in the file it hangs off an empty anchor - // paragraph, and both word processors count that line (probed in Word). + // a table, a frame or an index is not a numbered line in either. A text box rides a + // paragraph, and that paragraph's own line is what counts. const blocks = view.dom.querySelectorAll( ':scope > :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10, blockquote),' - + ' :scope > .textbox-node,' + ' :scope > :is(ul, ol) li > p,' + ' :scope > .columns-node :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10)'); for (const block of Array.from(blocks)) { if (block.closest('table')) continue; - const anchor = block.classList.contains('textbox-node'); - const empty = anchor || !block.textContent?.trim(); - const lines = anchor - ? [{ top: (block.getBoundingClientRect().top - origin) / scale, height: 18 }] - : lineTops(block, origin, scale); + const empty = !block.textContent?.trim(); + const lines = lineTops(block, origin, scale); for (const line of lines) { const linePage = pageAt(line.top); if (lineNumbering.restart === 'page' && linePage !== page) { page = linePage; count = 0; } diff --git a/src/lib/components/TextBoxToolbar.svelte b/src/lib/components/TextBoxToolbar.svelte index 488b3bf..80ca53b 100644 --- a/src/lib/components/TextBoxToolbar.svelte +++ b/src/lib/components/TextBoxToolbar.svelte @@ -12,6 +12,7 @@ left, wrap, wrapAlign, + paraAlign, shapeKind, fillColor, strokeColor, @@ -23,6 +24,7 @@ left: number; wrap: WrapMode; wrapAlign: string | null; + paraAlign: string | null; shapeKind: ShapeKind; fillColor: string | null; strokeColor: string | null; @@ -46,9 +48,12 @@ } // 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 the box in flow. + // names the side — so the buttons show for the two modes that leave it in flow. 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'); function alignTitle(a: (typeof alignModes)[number]): string { return a === 'left' ? t().textBox.alignLeft : a === 'center' ? t().textBox.alignCenter @@ -57,7 +62,8 @@ // An imported box placed by coordinate keeps that x over any alignment, so picking // one drops it. function setAlign(a: (typeof alignModes)[number]) { - set({ wrapAlign: a === 'left' ? null : a, wrapOffset: null }); + if (wrap === 'inline') editor?.chain().focus().setTextBoxAlign(a === 'left' ? null : a).run(); + else set({ wrapAlign: a === 'left' ? null : a, wrapOffset: null }); } const strokeWidths = [0.5, 1, 2.25]; @@ -123,10 +129,10 @@ {#each alignModes as a}