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}