Two faults on one picture. A block after a float keeps its box over the float — only its lines move — so the caption on top swallowed every click meant for the picture; the frame is lifted above the in-flow boxes. And a caption can only sit under a side-wrapped frame if it is boxed to it, so its span in the column is measured and becomes the caption's indent, which also drops it clear of the float. Measured at 100% and 150%: caption under the picture, its width, in all four wrap modes.
65 lines
13 KiB
Markdown
65 lines
13 KiB
Markdown
# Frames: images and text boxes
|
||
|
||
Covers `src/lib/editor/extensions/image.ts` and `textBox.ts` — node views, resize/rotate
|
||
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 `<img>` 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'`) 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). 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. 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`), `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. `trailingNode.ts` appends a paragraph after a trailing box; `pageBreaks.ts` paginates `.textbox-node` atomically. **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.
|
||
|
||
## Clicking a float
|
||
|
||
A block after a float keeps its own box **over** the float — only its line boxes move out
|
||
of the way — so the block on top takes every click meant for the frame, and the frame
|
||
stops selecting once anything follows it (a caption is the usual first such block).
|
||
`editor.css` lifts a floating frame (`.image-node[data-wrap]`) above the in-flow boxes;
|
||
nothing is hidden, since text flows beside a frame and never across it. A page-anchored
|
||
frame is excluded — it carries its own place in the stack (`inFront`).
|
||
|
||
## Shapes (`utils/shapes.ts`)
|
||
|
||
Every kind past the three CSS can draw is **one polygon in a 0…100 box**, given once and
|
||
used three ways: the node view draws it as an SVG `path` (`preserveAspectRatio="none"` to
|
||
stretch it to the frame, `vector-effect="non-scaling-stroke"` so the line stays even under
|
||
that distortion), the ODF export scales the same points into the 21600 viewBox of
|
||
`draw:enhanced-path`, and Word gets the preset's `prst` name and draws its own geometry.
|
||
Adding a shape is one entry in `SHAPES` — no icon, no export branch, no importer case.
|
||
|
||
- The three rectangular kinds keep the geometry **LibreOffice itself writes** (the
|
||
round-rectangle path pre-evaluated for modifier 3600); a polygon derives its own.
|
||
- A ring (pentagon, star) is stretched onto the whole box per axis (`fit`): an
|
||
odd-cornered shape leaves gaps at its bounds, and the presets both word processors draw
|
||
fill their frame.
|
||
- `textArea` is where text goes inside the outline. It travels as ODF `draw:text-areas`
|
||
and pads `.textbox-content` in the editor — where a polygon's frame carries **no**
|
||
padding of its own, so the outline covers its whole box and the ring moves to the text.
|
||
LibreOffice ignores the text area of a preset it recognizes and sets the text at the
|
||
frame's top left instead, as it already does for our ellipse.
|
||
- An importer maps the file's `draw:type`/`prst` back through the same table;
|
||
`shapeFromOdfType`/`shapeFromPrst` return **null** for a preset we can't draw, which is
|
||
what makes the warning possible instead of flattening it to a rectangle.
|
||
|
||
## Lines and arrows
|
||
|
||
`line`, `lineArrow` and `lineDoubleArrow` are **two endpoints, not a box**: they hold no
|
||
text (the paragraph the schema requires is hidden and never exported), take no fill, and
|
||
run across the frame's diagonal — `flipV` picking which one, the single flag Word's
|
||
`flipV` and ODF's own endpoint order both come down to. They are drawn in the frame's
|
||
**real pixels** (`linePaths`), not the stretched 0…100 box a polygon uses: an arrow head
|
||
has to keep its shape however flat the frame is, and the head scales with the pen
|
||
(`arrowHeadPx`, floored so a hairline still shows one).
|
||
|
||
The three share their `odf`/`prst` names, because **the heads are what tell them apart**:
|
||
both importers read the heads a file declares and `lineKindFor` names the kind, so a
|
||
`straightConnector1` with no heads is a plain line and a `line` preset with both is a
|
||
double arrow.
|
||
|
||
- **ODF** gives a line its own element — `<draw:line svg:x1/y1/x2/y2>`, no width or
|
||
height — and its heads are a **named marker**, so `applyTextBoxes` also writes the one
|
||
`<draw:marker draw:name="Arrow">` definition into `styles.xml`, LibreOffice's own name
|
||
and path. It goes in only when a line asks for it, and needs `ensureDrawNamespaces`:
|
||
an undeclared `draw:` prefix there makes the whole file unreadable.
|
||
- **DOCX** keeps the frame and flips it (`<a:xfrm flipV="1">`), the heads riding the
|
||
shape's `<a:ln>` as `a:headEnd`/`a:tailEnd`, and writes no `wps:txbx` at all.
|
||
- **Not covered**: connectors that attach to other shapes, and freeform curves. Both are
|
||
still dropped with a warning — a connector is an anchoring model, not a shape.
|