A polygon, a polyline, a curve or a connector's elbow used to be dropped with a warning; it now rides the box as an SVG path in the 0…100 box a preset's points use, and the editor draws it. Read from all four dialects the two products write (ODF points and svg:d, a non-primitive enhanced-path, DrawingML custGeom, VML's reversed-case path), written back as enhanced-path and custGeom — LibreOffice renders both exactly as the editor draws them. An open outline is stroked, never filled.
86 lines
14 KiB
Markdown
86 lines
14 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 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 frame style's own writing mode in ODF (in its *paragraph* properties — probed: in the graphic properties LibreOffice drops it) 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. **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.
|
||
## Freeform outlines (`shapePath`)
|
||
|
||
A drawing this editor offers no tool to author — a polygon, a polyline, a bezier curve,
|
||
a connector's elbow — still has to survive being opened and saved. The box keeps the
|
||
file's own outline in `shapePath`, an SVG `d` in the same **0…100 box** a preset's
|
||
points live in (`utils/shapes.ts`), and its presence is what makes the box draw itself.
|
||
An outline that never closes is **stroked only**, whatever fill its style declares,
|
||
which is how both products draw a polyline.
|
||
|
||
- **Reading** takes four path dialects, all through `parseSvgPath`, which resolves the
|
||
relative commands both products write into absolute `M`/`L`/`C`/`Z`: ODF's
|
||
`draw:points` (polygon closed, polyline open), its `svg:d` (`draw:path`, and a
|
||
`draw:connector`, which carries the resolved elbow beside its endpoints), a
|
||
`non-primitive` `draw:enhanced-path`, DrawingML's `a:custGeom` path list, and VML's
|
||
`path` — whose cases are SVG's the other way round (`parseVmlPath`).
|
||
- A geometry whose commands are **modifier formulas** (`?f0`) or an arc is not an
|
||
outline we can draw, so the shape stays unsupported and is dropped with the warning,
|
||
as before. So is a Word connector preset (`bentConnector3`), which is a geometry
|
||
Word resolves and does not write down.
|
||
- **Writing** goes back out as the shape's own geometry: ODF a `non-primitive`
|
||
`<draw:enhanced-geometry>` in its 21600 viewBox (probed: LibreOffice writes that
|
||
straight back out unchanged), DOCX an `<a:custGeom>` path list in the shape's EMU
|
||
extent. Both render in LibreOffice exactly as the editor draws them.
|