LibreOffice sizes such a line max(frame, line-height) — nothing of the text sits under the frame (probed: an image-only paragraph is exactly as tall as its image, down to a small one, where the text line wins). Our baseline alignment left the strut's descent below it, ~1.1mm per formula. Whitespace beside the frame counts as nothing, which CSS cannot ask, so image.ts marks the block.
8.2 KiB
8.2 KiB
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, likerowHeight),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-nodeis given az-indexabove 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 mirrorstableRowResize.ts; width cap = the cell or page-text-column, height cap = the page text height).setImagecommand inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file viaFileReader→ data-URI and clamp the initial size to the page text box. Text wrap: thewrapattr ('inline'|'left'|'right'|'topBottom') makes the image a floating frame —ImageToolbar.svelte(a per-image floating toolbar wired inEditor.sveltelikeTableToolbar) sets the mode;left/rightfloat the wrapper at its anchor paragraph (text flows on the open side via CSSfloat),topBottomis a full-widthfloat(text only above/below). All modes usefloatdeliberately: an in-flowdisplay:blockon 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 (thedropCursorplugin 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 areaddToHistory:falseand 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: a float reserves 14px beside itself and nothing above or below — LibreOffice and Word both wrap flush against a frame unless the file declares a distance, and 6px of our own put every figure in a text box 3mm too tall. Vertical alignment (as-char):vAligncarries ODF'sstyle:vertical-pos/-relpair for an inline frame —null(LibreOffice'stop/baselineand Word's only mode) stands the frame on the text baseline,middle/belowcentre it on the baseline or hang it below, andtext-top/text-middle/text-bottommeasure against the character area instead (CSSvertical-align: text-top/text-bottom; nothing in CSS centres on that area, sotext-middleuses 0.36em for half of ascent − descent, which Liberation Serif/Sans, Times and Arial share).from-topbecomesvAlign: 'offset'and puts the frame's topwrapOffsetYcm below the baseline whatever the relation says — all probed against LibreOffice, whose formula images usemiddle/textandfrom-topalmost 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); the block carries animage-lineclass fromimage.tsfor it, since CSS cannot ask whether a line holds real text. A frame that shares its line with text keeps the baseline and the descent. Frame offsets:wrapOffsetis the frame's left edge in the text column (Word'spositionHposOffset, ODFsvg:x), rendered as the float's near/far margin against the live column vars — an indented anchor paragraph can't skew it — and shared withtextBox.tsthroughframeMargins. AtopBottomfloat 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 fortopBottomonly — 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) andImageView.sinkToOffsetmeasures what the lines above it already cover, adding only the rest. TwotopBottomframes set against opposite ends of nearby paragraphs (wrapAlign, from Word'spositionHalign / ODFstyle: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.tswarns 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'sWrapMode;inlinerenders in-flow, floats via CSSfloatlikeImageView.applyWrap),shapeKind('textbox'|'roundRect'|'ellipse'— ellipse renders viaborder-radius:50%),fillColor/strokeColor(null= transparent/no border),strokeWidthPt. Padding is the fixedTEXTBOX_PADDING_CM(0.15cm). The node has its own group (group: 'textBox') so only the document admits it (extensions.tswidens Document to'(block | textBox)+') — never in table cells, lists, header/footer, or other boxes; imports found there are flattened with a warning.TextBoxViewmirrorsImageView(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/isFrameHitclaim mouse events on a ~6px frame ring (rotation-/zoom-aware) → NodeSelection + handles (the sharedimage-*handle classes/CSS); clicks further inside pass to ProseMirror (caret). The box is browser-draggableonly 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; helperfindTextBox).TextBoxToolbar.svelte(wired inEditor.sveltelike the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke.trailingNode.tsappends a paragraph after a trailing box;pageBreaks.tspaginates.textbox-nodeatomically. Spacing: a box lifted out of an anchor paragraph that held nothing else stands in for it, so it takes that paragraph's resolvedfo:margin-top/-bottomasspaceBefore/spaceAfter(ParagraphSpacingcoverstextBoxfor that). Space above is padding — it adds to the block above rather than collapsing against it — which means the node iscontent-boxand the rotor, centred in the padding box, gets half of it back.