feat: behind and in front of the text, pickable and draggable
Both frame toolbars and the ribbon's Arrange group now offer the two run-through entries; they are one wrap mode, told apart by inFront. Picking any mode by hand drops the offsets and the coordinate system they were measured in (droppedFrameAttrs), so a page-placed frame set to a side wrap no longer exports page-relative coordinates for a paragraph-relative offset. A frame out of the flow has no text position to re-anchor to, so the drag adds the pointer delta to its own offsets (startFreeMove).
This commit is contained in:
1 parent
a260969138
commit
5b287ef57b
13 files changed
+272
-33
No files matched your search
@@ -3,8 +3,8 @@
|
||||
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. A picture that arrives by **URL** (an `<img>` pasted or dropped from a web page) is fetched into a data-URI right after the paste (`inlineRemoteImages`); one the site withholds is dropped with a message, since only a data-URI ever reaches a file. **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. A frame the file places against the **page** takes both offsets from the page's corner instead (see `wrapFromPage`). 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 sits behind the text unless the file puts it in front (`inFront`, z-index 1 against its −2) — 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 SeLine truncated
|
||||
- **`textBox.ts`** (`TextBox`) — an **inline** text box / basic shape carrying editable block content (`group: 'inline'`, `inline: true`, `content: '(paragraph|heading|bulletList|orderedList)+ | table'`, `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. **Reaching the position beside the box:** a frame nobody is editing is an atom to the browser — a `contenteditable="false"` node decoration, dropped while the caret is in its text — and `ArrowLeft`/`ArrowRight` at the box's first/last text position step out to `before`/`after` the node. Without both, a box that starts its paragraph is a trap: there is no text position beside it, so the browser pulls the caret meant for the box's own place in the line into the frame and everything typed there lands inside it. The frame then has to place the caret itself on the click that enters it (`onFrameMouseDown`, `stopEvent`), since a non-editable one gets none from the browser. A nested `contentEditable` island is not the way — ProseMirror's `hasFocus` is `activeElement == view.dom`, and an island holding focus makes it read no selection at all. 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 `<a:noAutofit/>`, 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:** only a **band-wrapped** box has a place of its own — `wrapAlign` (`null`/`center`/`right`, drawn by `placeInBand`), and the toolbar's three buttons show for that mode alone, dropping any `wrapOffset`Line truncated
|
||||
- **`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. A picture that arrives by **URL** (an `<img>` pasted or dropped from a web page) is fetched into a data-URI right after the paste (`inlineRemoteImages`); one the site withholds is dropped with a message, since only a data-URI ever reaches a file. **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. A frame the file places against the **page** takes both offsets from the page's corner instead (see `wrapFromPage`). 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 sits behind the text unless the file puts it in front (`inFront`, z-index 1 against its −2). Both frame toolbars and the ribbon's Arrange group offer the mode as the two entries Word's layout options have — behind the text and in front of it, one mode told apart by `inFront`. Picking **any** mode by hand drops the offsets and the frame of reference they were measured in (`droppedFrameAttrs`: `wrapFromPage`, `anchorPage`, `inFront`), or a page-placed frame set to a side wrap would export as page-relative with an offset that now counts from a paragraph. 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 frame that is **out of the flow** (`through`, or page-anchored) moves it by its own offsets instead (`startFreeMove`, shared with `textBox.ts`): nothing wraps around it, so there is no text position to re-anchor to — and a page-placed frame is re-placed from its offsets on every pagination pass, so re-anchoring moved it nowhere at all. Those offsets count from a point the drag cannot move (the anchor's static position, the page's corner), so the pointer delta is simply added to them; the preview rides the node view's own `applyWrap`, and the commit is one undo step. 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'sLine truncated
|
||||
- **`textBox.ts`** (`TextBox`) — an **inline** text box / basic shape carrying editable block content (`group: 'inline'`, `inline: true`, `content: '(paragraph|heading|bulletList|orderedList)+ | table'`, `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 — except out of the flow, where the ring drag runs `startFreeMove` (PM's node move would re-anchor it). **Reaching the position beside the box:** a frame nobody is editing is an atom to the browser — a `contenteditable="false"` node decoration, dropped while the caret is in its text — and `ArrowLeft`/`ArrowRight` at the box's first/last text position step out to `before`/`after` the node. Without both, a box that starts its paragraph is a trap: there is no text position beside it, so the browser pulls the caret meant for the box's own place in the line into the frame and everything typed there lands inside it. The frame then has to place the caret itself on the click that enters it (`onFrameMouseDown`, `stopEvent`), since a non-editable one gets none from the browser. A nested `contentEditable` island is not the way — ProseMirror's `hasFocus` is `activeElement == view.dom`, and an island holding focus makes it read no selection at all. 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 `<a:noAutofit/>`, 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:** only a **band-wrapped** box has a place of its own — `wrapAlign` (`null`/`center`/`right`, draLine truncated
|
||||
|
||||
## A floating table
|
||||
|
||||
|
||||
@@ -736,7 +736,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
|
||||
|
||||
// --- Floating image wrap toolbar ---
|
||||
// Shown when a single image node is selected; positioned just above it.
|
||||
let imageUi = $state<{ visible: boolean; top: number; left: number; wrap: WrapMode }>({ visible: false, top: 0, left: 0, wrap: 'inline' });
|
||||
let imageUi = $state<{ visible: boolean; top: number; left: number; wrap: WrapMode; inFront: boolean }>({ visible: false, top: 0, left: 0, wrap: 'inline', inFront: false });
|
||||
|
||||
function recomputeImageUi() {
|
||||
const ed = editor;
|
||||
@@ -759,6 +759,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
|
||||
top: r.top - cRect.top + container.scrollTop,
|
||||
left: r.left - cRect.left + container.scrollLeft,
|
||||
wrap: ((sel as NodeSelection).node.attrs.wrap as WrapMode) || 'inline',
|
||||
inFront: (sel as NodeSelection).node.attrs.inFront === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -767,9 +768,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; inFront: boolean; wrapAlign: 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', inFront: false, wrapAlign: null, shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1, textVertical: false });
|
||||
|
||||
function recomputeTextBoxUi() {
|
||||
const ed = editor;
|
||||
@@ -794,6 +795,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
|
||||
top: anchorTop - cRect.top + container!.scrollTop,
|
||||
left: r.left - cRect.left + container!.scrollLeft,
|
||||
wrap: (a.wrap as WrapMode) || 'inline',
|
||||
inFront: a.inFront === true,
|
||||
wrapAlign: (a.wrapAlign as string | null) ?? null,
|
||||
shapeKind: (a.shapeKind as ShapeKind) || 'textbox',
|
||||
fillColor: (a.fillColor as string | null) ?? null,
|
||||
@@ -1735,7 +1737,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
|
||||
</div>
|
||||
{/if}
|
||||
{#if imageUi.visible}
|
||||
<ImageToolbar {editor} top={imageUi.top} left={imageUi.left} wrap={imageUi.wrap} />
|
||||
<ImageToolbar {editor} top={imageUi.top} left={imageUi.left} wrap={imageUi.wrap} inFront={imageUi.inFront} />
|
||||
{/if}
|
||||
{#if textBoxUi.visible}
|
||||
<TextBoxToolbar
|
||||
@@ -1743,6 +1745,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
|
||||
top={textBoxUi.top}
|
||||
left={textBoxUi.left}
|
||||
wrap={textBoxUi.wrap}
|
||||
inFront={textBoxUi.inFront}
|
||||
wrapAlign={textBoxUi.wrapAlign}
|
||||
shapeKind={textBoxUi.shapeKind}
|
||||
fillColor={textBoxUi.fillColor}
|
||||
|
||||
@@ -8,24 +8,33 @@
|
||||
top,
|
||||
left,
|
||||
wrap,
|
||||
inFront,
|
||||
}: {
|
||||
editor: Editor | null;
|
||||
top: number;
|
||||
left: number;
|
||||
wrap: WrapMode;
|
||||
inFront: boolean;
|
||||
} = $props();
|
||||
|
||||
// preventDefault on mousedown keeps the image node-selected; .focus() re-anchors
|
||||
// to it so setImageWrap targets the right node.
|
||||
function set(mode: WrapMode) {
|
||||
editor?.chain().focus().setImageWrap(mode).run();
|
||||
function set(m: WrapChoice) {
|
||||
editor?.chain().focus().setImageWrap(m === 'behind' || m === 'front' ? 'through' : m, m === 'front').run();
|
||||
}
|
||||
|
||||
const modes: WrapMode[] = ['inline', 'left', 'right', 'topBottom'];
|
||||
function wrapTitle(m: WrapMode): string {
|
||||
// The list Word's layout options offer, without "tight" (no browser wraps text along
|
||||
// a contour). The two run-through entries are one mode, told apart by which side of
|
||||
// the text the frame lands on.
|
||||
type WrapChoice = WrapMode | 'behind' | 'front';
|
||||
const modes: WrapChoice[] = ['inline', 'left', 'right', 'topBottom', 'behind', 'front'];
|
||||
const active = $derived<WrapChoice>(wrap === 'through' ? (inFront ? 'front' : 'behind') : wrap);
|
||||
function wrapTitle(m: WrapChoice): string {
|
||||
return m === 'inline' ? t().image.wrapInline
|
||||
: m === 'left' ? t().image.wrapLeft
|
||||
: m === 'right' ? t().image.wrapRight
|
||||
: m === 'behind' ? t().image.wrapBehind
|
||||
: m === 'front' ? t().image.wrapFront
|
||||
: t().image.wrapTopBottom;
|
||||
}
|
||||
</script>
|
||||
@@ -41,10 +50,10 @@
|
||||
{#each modes as m}
|
||||
<button
|
||||
class="it-btn"
|
||||
class:active={wrap === m}
|
||||
class:active={active === m}
|
||||
title={wrapTitle(m)}
|
||||
aria-label={wrapTitle(m)}
|
||||
aria-pressed={wrap === m}
|
||||
aria-pressed={active === m}
|
||||
onclick={() => set(m)}
|
||||
>
|
||||
{#if m === 'inline'}
|
||||
@@ -71,6 +80,20 @@
|
||||
<line x1="2.5" y1="10" x2="8" y2="10" stroke="currentColor" stroke-width="1.2" />
|
||||
<line x1="2.5" y1="15" x2="15.5" y2="15" stroke="currentColor" stroke-width="1.2" />
|
||||
</svg>
|
||||
{:else if m === 'behind'}
|
||||
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<rect x="5" y="4.5" width="8" height="9" rx="1" stroke="currentColor" stroke-width="1.2" opacity="0.45" />
|
||||
<line x1="2.5" y1="6" x2="15.5" y2="6" stroke="currentColor" stroke-width="1.2" />
|
||||
<line x1="2.5" y1="9" x2="15.5" y2="9" stroke="currentColor" stroke-width="1.2" />
|
||||
<line x1="2.5" y1="12" x2="15.5" y2="12" stroke="currentColor" stroke-width="1.2" />
|
||||
</svg>
|
||||
{:else if m === 'front'}
|
||||
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<line x1="2.5" y1="6" x2="15.5" y2="6" stroke="currentColor" stroke-width="1.2" opacity="0.45" />
|
||||
<line x1="2.5" y1="9" x2="15.5" y2="9" stroke="currentColor" stroke-width="1.2" opacity="0.45" />
|
||||
<line x1="2.5" y1="12" x2="15.5" y2="12" stroke="currentColor" stroke-width="1.2" opacity="0.45" />
|
||||
<rect x="5" y="4.5" width="8" height="9" rx="1" fill="currentColor" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<line x1="2.5" y1="3" x2="15.5" y2="3" stroke="currentColor" stroke-width="1.2" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { WrapMode } from '../editor/extensions/image';
|
||||
import { droppedFrameAttrs, type WrapMode } from '../editor/extensions/image';
|
||||
import type { ShapeKind, TextBoxAttrs } from '../editor/extensions/textBox';
|
||||
import ColorPicker from './ColorPicker.svelte';
|
||||
import ShapePicker from './ShapePicker.svelte';
|
||||
@@ -11,6 +11,7 @@
|
||||
top,
|
||||
left,
|
||||
wrap,
|
||||
inFront,
|
||||
wrapAlign,
|
||||
shapeKind,
|
||||
fillColor,
|
||||
@@ -22,6 +23,7 @@
|
||||
top: number;
|
||||
left: number;
|
||||
wrap: WrapMode;
|
||||
inFront: boolean;
|
||||
wrapAlign: string | null;
|
||||
shapeKind: ShapeKind;
|
||||
fillColor: string | null;
|
||||
@@ -37,11 +39,21 @@
|
||||
editor?.chain().focus().setTextBoxAttrs(attrs).run();
|
||||
}
|
||||
|
||||
const wrapModes: WrapMode[] = ['inline', 'left', 'right', 'topBottom'];
|
||||
function wrapTitle(m: WrapMode): string {
|
||||
// As on an image: the two run-through entries are one mode told apart by inFront,
|
||||
// and picking any mode by hand drops the offsets of the one it replaces.
|
||||
type WrapChoice = WrapMode | 'behind' | 'front';
|
||||
const wrapModes: WrapChoice[] = ['inline', 'left', 'right', 'topBottom', 'behind', 'front'];
|
||||
const activeWrap = $derived<WrapChoice>(wrap === 'through' ? (inFront ? 'front' : 'behind') : wrap);
|
||||
function setWrap(m: WrapChoice) {
|
||||
const mode: WrapMode = m === 'behind' || m === 'front' ? 'through' : m;
|
||||
set({ wrap: mode, ...droppedFrameAttrs(mode, m === 'front') });
|
||||
}
|
||||
function wrapTitle(m: WrapChoice): string {
|
||||
return m === 'inline' ? t().textBox.wrapInline
|
||||
: m === 'left' ? t().textBox.wrapLeft
|
||||
: m === 'right' ? t().textBox.wrapRight
|
||||
: m === 'behind' ? t().textBox.wrapBehind
|
||||
: m === 'front' ? t().textBox.wrapFront
|
||||
: t().textBox.wrapTopBottom;
|
||||
}
|
||||
|
||||
@@ -80,11 +92,11 @@
|
||||
{#each wrapModes as m}
|
||||
<button
|
||||
class="tb-btn"
|
||||
class:active={wrap === m}
|
||||
class:active={activeWrap === m}
|
||||
title={wrapTitle(m)}
|
||||
aria-label={wrapTitle(m)}
|
||||
aria-pressed={wrap === m}
|
||||
onclick={() => set({ wrap: m })}
|
||||
aria-pressed={activeWrap === m}
|
||||
onclick={() => setWrap(m)}
|
||||
>
|
||||
{#if m === 'inline'}
|
||||
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
@@ -110,6 +122,20 @@
|
||||
<line x1="2.5" y1="10" x2="8" y2="10" stroke="currentColor" stroke-width="1.2" />
|
||||
<line x1="2.5" y1="15" x2="15.5" y2="15" stroke="currentColor" stroke-width="1.2" />
|
||||
</svg>
|
||||
{:else if m === 'behind'}
|
||||
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<rect x="5" y="4.5" width="8" height="9" rx="1" stroke="currentColor" stroke-width="1.2" opacity="0.45" />
|
||||
<line x1="2.5" y1="6" x2="15.5" y2="6" stroke="currentColor" stroke-width="1.2" />
|
||||
<line x1="2.5" y1="9" x2="15.5" y2="9" stroke="currentColor" stroke-width="1.2" />
|
||||
<line x1="2.5" y1="12" x2="15.5" y2="12" stroke="currentColor" stroke-width="1.2" />
|
||||
</svg>
|
||||
{:else if m === 'front'}
|
||||
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<line x1="2.5" y1="6" x2="15.5" y2="6" stroke="currentColor" stroke-width="1.2" opacity="0.45" />
|
||||
<line x1="2.5" y1="9" x2="15.5" y2="9" stroke="currentColor" stroke-width="1.2" opacity="0.45" />
|
||||
<line x1="2.5" y1="12" x2="15.5" y2="12" stroke="currentColor" stroke-width="1.2" opacity="0.45" />
|
||||
<rect x="5" y="4.5" width="8" height="9" rx="1" fill="currentColor" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="22" height="22" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<line x1="2.5" y1="3" x2="15.5" y2="3" stroke="currentColor" stroke-width="1.2" />
|
||||
|
||||
@@ -381,6 +381,7 @@
|
||||
{editor}
|
||||
which={tab === 'pictureFormat' ? 'picture' : 'shape'}
|
||||
wrap={(frameAttrs?.wrap ?? 'inline') as never}
|
||||
inFront={frameAttrs?.inFront === true}
|
||||
alt={(frameAttrs?.alt ?? '') as string}
|
||||
shapeKind={frameAttrs?.shapeKind as never}
|
||||
fillColor={frameAttrs?.fillColor as string | null}
|
||||
|
||||
@@ -77,6 +77,9 @@ export const STROKED = {
|
||||
wrapLeft: ['M2 3.5h6v9H2z', 'M9.5 4.5h4.5M9.5 7h4.5M9.5 9.5h4.5M9.5 12h4.5'],
|
||||
wrapRight: ['M8 3.5h6v9H8z', 'M2 4.5h4.5M2 7h4.5M2 9.5h4.5M2 12h4.5'],
|
||||
wrapTopBottom: ['M4 6.5h8v3H4z', 'M2 3h12M2 13h12'],
|
||||
// The text runs over the frame's outline; in front, the solid frame covers it (FILLED).
|
||||
wrapBehind: ['M5 4.5h6v7H5z', 'M2 5.5h12M2 8h12M2 10.5h12'],
|
||||
wrapFront: ['M2 5.5h12M2 8h12M2 10.5h12'],
|
||||
// A capital T beside an arrow running down: the text sets vertically.
|
||||
textDirection: ['M3 3.75h6M6 3.75v8.5', 'M12.5 4v7.5M10.9 10l1.6 1.6 1.6-1.6'],
|
||||
toc: ['M2 3h6M2 6.5h5M2 10h6M2 13.5h4', 'M11 3h3M10 6.5h4M11 10h3M10 13.5h4'],
|
||||
@@ -157,6 +160,7 @@ export const FILLED = {
|
||||
infoDot: ['M8 4.4a.8.8 0 1 0 0 1.6.8.8 0 0 0 0-1.6z'],
|
||||
header: ['M5.4 3.8h5.2v1.7H5.4z'],
|
||||
footer: ['M5.4 10.5h5.2v1.7H5.4z'],
|
||||
wrapFront: ['M5 4.5h6v7H5z'],
|
||||
} as const;
|
||||
|
||||
// Constant painted stroke rather than proportional scaling: a 1-unit stroke would
|
||||
|
||||
@@ -6,16 +6,17 @@
|
||||
import ColorPicker from '../../ColorPicker.svelte';
|
||||
import ShapePicker from '../../ShapePicker.svelte';
|
||||
import CaptionDialog from '../../CaptionDialog.svelte';
|
||||
import type { WrapMode } from '../../../editor/extensions/image';
|
||||
import { droppedFrameAttrs, type WrapMode } from '../../../editor/extensions/image';
|
||||
import type { ShapeKind } from '../../../editor/extensions/textBox';
|
||||
import { t } from '../../../i18n/i18n.svelte';
|
||||
|
||||
// Word's Picture Format and Shape Format: the same wrap modes, plus a shape's
|
||||
// own fill, outline and kind.
|
||||
let { editor, which, wrap, alt = '', shapeKind, fillColor, strokeColor, strokeWidthPt, textVertical = false }: {
|
||||
let { editor, which, wrap, inFront = false, alt = '', shapeKind, fillColor, strokeColor, strokeWidthPt, textVertical = false }: {
|
||||
editor: Editor | null;
|
||||
which: 'picture' | 'shape';
|
||||
wrap: WrapMode;
|
||||
inFront?: boolean;
|
||||
alt?: string;
|
||||
shapeKind?: ShapeKind;
|
||||
fillColor?: string | null;
|
||||
@@ -24,24 +25,31 @@
|
||||
textVertical?: boolean;
|
||||
} = $props();
|
||||
|
||||
const WRAPS: { key: WrapMode; icon: 'wrapInline' | 'wrapLeft' | 'wrapRight' | 'wrapTopBottom'; label: () => string }[] = [
|
||||
// The two run-through entries are one mode, told apart by which side of the text the
|
||||
// frame lands on — as in Word's layout options, whose "tight" no browser can draw.
|
||||
type WrapChoice = WrapMode | 'behind' | 'front';
|
||||
const WRAPS: { key: WrapChoice; icon: 'wrapInline' | 'wrapLeft' | 'wrapRight' | 'wrapTopBottom' | 'wrapBehind' | 'wrapFront'; label: () => string }[] = [
|
||||
{ key: 'inline', icon: 'wrapInline', label: () => t().image.wrapInline },
|
||||
{ key: 'left', icon: 'wrapLeft', label: () => t().image.wrapLeft },
|
||||
{ key: 'right', icon: 'wrapRight', label: () => t().image.wrapRight },
|
||||
{ key: 'topBottom', icon: 'wrapTopBottom', label: () => t().image.wrapTopBottom },
|
||||
{ key: 'behind', icon: 'wrapBehind', label: () => t().image.wrapBehind },
|
||||
{ key: 'front', icon: 'wrapFront', label: () => t().image.wrapFront },
|
||||
];
|
||||
const active = $derived<WrapChoice>(wrap === 'through' ? (inFront ? 'front' : 'behind') : wrap);
|
||||
|
||||
let captionOpen = $state(false);
|
||||
|
||||
function setWrap(w: WrapMode) {
|
||||
if (which === 'picture') editor?.chain().focus().setImageWrap(w).run();
|
||||
else editor?.chain().focus().setTextBoxAttrs({ wrap: w }).run();
|
||||
function setWrap(w: WrapChoice) {
|
||||
const mode: WrapMode = w === 'behind' || w === 'front' ? 'through' : w;
|
||||
if (which === 'picture') editor?.chain().focus().setImageWrap(mode, w === 'front').run();
|
||||
else editor?.chain().focus().setTextBoxAttrs({ wrap: mode, ...droppedFrameAttrs(mode, w === 'front') }).run();
|
||||
}
|
||||
</script>
|
||||
|
||||
<RibbonGroup label={t().ribbon.groups.arrange}>
|
||||
{#each WRAPS as w}
|
||||
<RibbonButton variant="big" icon={w.icon} label={w.label()} title={w.label()} active={wrap === w.key} onclick={() => setWrap(w.key)} />
|
||||
<RibbonButton variant="big" icon={w.icon} label={w.label()} title={w.label()} active={active === w.key} onclick={() => setWrap(w.key)} />
|
||||
{/each}
|
||||
</RibbonGroup>
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
image: {
|
||||
setImage: (attrs: { src: string; alt?: string; width?: number | null; height?: number | null; rotation?: number; wrap?: WrapMode }) => ReturnType;
|
||||
setImageWrap: (wrap: WrapMode) => ReturnType;
|
||||
setImageWrap: (wrap: WrapMode, inFront?: boolean) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,19 @@ export function frameMargins(wrap: WrapMode, offsetCm: unknown, boxWidthPx: numb
|
||||
return `0 ${far} 0 ${gap}`;
|
||||
}
|
||||
|
||||
// What picking a wrap mode by hand drops: the offsets belong to the mode that was set,
|
||||
// and so do the coordinate systems they were measured in (the page corner, a fixed
|
||||
// page). `inFront` means something for run-through alone. Shared with textBox.ts.
|
||||
export function droppedFrameAttrs(wrap: WrapMode, inFront: boolean): Record<string, unknown> {
|
||||
return {
|
||||
wrapOffset: null,
|
||||
wrapOffsetY: null,
|
||||
wrapFromPage: false,
|
||||
anchorPage: null,
|
||||
inFront: wrap === 'through' && inFront,
|
||||
};
|
||||
}
|
||||
|
||||
// Word's behind-text / in-front-of-text, ODF run-through: the text runs over or under
|
||||
// the frame, so it reserves nothing. Absolute with no offsets keeps the static position
|
||||
// it was anchored at; the file's own offsets ride as margins from there.
|
||||
@@ -114,6 +127,46 @@ export function applyRunThrough(el: HTMLElement, offsetCm: unknown, offsetYCm: u
|
||||
else { delete el.dataset.pageX; delete el.dataset.pageY; }
|
||||
}
|
||||
|
||||
// Drag a frame that is out of the flow. Its offsets count from a point the drag cannot
|
||||
// move — the anchor's static position, the page's corner — so the pointer delta is
|
||||
// simply added to them. `done(null)` reports a click that never moved.
|
||||
export function startFreeMove(
|
||||
event: MouseEvent,
|
||||
dom: HTMLElement,
|
||||
attrs: Record<string, unknown>,
|
||||
preview: (by: { x: number; y: number } | null) => void,
|
||||
done: (offsets: { wrapOffset: number; wrapOffsetY: number } | null) => void,
|
||||
): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// The wrapper is axis-aligned, so its scaled/unscaled width ratio is the zoom.
|
||||
const zoom = dom.getBoundingClientRect().width / dom.offsetWidth || 1;
|
||||
const win = dom.ownerDocument.defaultView ?? window;
|
||||
const cm = (px: number) => Math.round((px * 2.54 * 1000) / 96) / 1000;
|
||||
const base = (v: unknown) => (typeof v === 'number' ? v : 0);
|
||||
const sx = event.clientX;
|
||||
const sy = event.clientY;
|
||||
let by = { x: 0, y: 0 };
|
||||
let moved = false;
|
||||
|
||||
const move = (e: MouseEvent): void => {
|
||||
if (!e.buttons) { finish(); return; }
|
||||
by = { x: cm((e.clientX - sx) / zoom), y: cm((e.clientY - sy) / zoom) };
|
||||
moved = true;
|
||||
preview(by);
|
||||
};
|
||||
const finish = (): void => {
|
||||
win.removeEventListener('mousemove', move);
|
||||
win.removeEventListener('mouseup', finish);
|
||||
preview(null);
|
||||
done(moved
|
||||
? { wrapOffset: base(attrs.wrapOffset) + by.x, wrapOffsetY: base(attrs.wrapOffsetY) + by.y }
|
||||
: null);
|
||||
};
|
||||
win.addEventListener('mousemove', move);
|
||||
win.addEventListener('mouseup', finish);
|
||||
}
|
||||
|
||||
// Where an as-char frame sits against the line (ODF style:vertical-pos/-rel, probed
|
||||
// against LibreOffice): its bottom on the baseline by default, `text-*` against the
|
||||
// character area, `offset` its top wrapOffsetY cm below the baseline.
|
||||
@@ -286,7 +339,7 @@ export const Image = Node.create({
|
||||
|
||||
// Set the wrap mode on the selected image.
|
||||
setImageWrap:
|
||||
(wrap: WrapMode) =>
|
||||
(wrap: WrapMode, inFront = false) =>
|
||||
({ state, dispatch }) => {
|
||||
const sel = state.selection;
|
||||
if (!(sel instanceof NodeSelection) || sel.node.type.name !== this.name) return false;
|
||||
@@ -294,7 +347,10 @@ export const Image = Node.create({
|
||||
// A frame the file never floated has no distance either; give it the one a
|
||||
// word processor's own wrap command writes (0.32cm).
|
||||
const wrapDist = wrap === 'inline' ? sel.node.attrs.wrapDist : sel.node.attrs.wrapDist ?? 0.32;
|
||||
if (dispatch) dispatch(state.tr.setNodeMarkup(sel.from, undefined, { ...sel.node.attrs, wrap, wrapDist, wrapOffset: null, wrapOffsetY: null }));
|
||||
if (dispatch) {
|
||||
dispatch(state.tr.setNodeMarkup(sel.from, undefined,
|
||||
{ ...sel.node.attrs, wrap, wrapDist, ...droppedFrameAttrs(wrap, inFront) }));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -428,6 +484,8 @@ class ImageView {
|
||||
// Handed in with the node: editor.view is not there yet while a saved document builds.
|
||||
private view: EditorView;
|
||||
private getPos: () => number;
|
||||
// Live offsets while a free drag runs (cm), added to the node's own by offX/offY.
|
||||
private dragBy: { x: number; y: number } | null = null;
|
||||
|
||||
constructor(node: PMNode, editor: Editor, getPos: () => number, view: EditorView) {
|
||||
this.node = node;
|
||||
@@ -489,6 +547,12 @@ class ImageView {
|
||||
private attrWrap(): WrapMode { const w = this.node.attrs.wrap; return w === 'left' || w === 'right' || w === 'topBottom' || w === 'through' ? w : 'inline'; }
|
||||
// The wrapper's reserved (rotated) width, which applyLayout has just written.
|
||||
private boxWidth(): number { return parseFloat(this.dom.style.width) || this.attrW() || 0; }
|
||||
// The frame's offsets, carrying a running drag. Without one the attr passes through
|
||||
// as it stands: null means "no offset stated", which places the frame flush.
|
||||
private offX(): unknown { const v = this.node.attrs.wrapOffset; return this.dragBy ? (typeof v === 'number' ? v : 0) + this.dragBy.x : v; }
|
||||
private offY(): unknown { const v = this.node.attrs.wrapOffsetY; return this.dragBy ? (typeof v === 'number' ? v : 0) + this.dragBy.y : v; }
|
||||
// A frame out of the flow is placed by those offsets alone, so it is dragged by them.
|
||||
private isFree(): boolean { return this.attrWrap() === 'through' || typeof this.node.attrs.anchorPage === 'number'; }
|
||||
|
||||
// Size the rotor to w×h, rotate it about its centre, and grow the axis-aligned
|
||||
// wrapper to the rotated bounding box so the line reserves the right space.
|
||||
@@ -541,7 +605,7 @@ class ImageView {
|
||||
}
|
||||
delete d.dataset.anchorPage;
|
||||
if (wrap === 'through') {
|
||||
applyRunThrough(d, a.wrapOffset, a.wrapOffsetY, a.inFront === true, a.wrapFromPage === true);
|
||||
applyRunThrough(d, this.offX(), this.offY(), a.inFront === true, a.wrapFromPage === true);
|
||||
// Deferred like sinkToOffset: the frame has to be laid out before its own page
|
||||
// can be read off the grid.
|
||||
if (a.wrapFromPage) requestAnimationFrame(() => placeFromPage(this.view, d));
|
||||
@@ -584,9 +648,9 @@ class ImageView {
|
||||
const px = (cm: unknown) => Math.round(cmToPx(typeof cm === 'number' ? cm : 0));
|
||||
d.style.position = 'absolute';
|
||||
d.style.zIndex = this.node.attrs.inFront ? '1' : '-1';
|
||||
d.style.left = `${px(this.node.attrs.wrapOffset)}px`;
|
||||
d.style.left = `${px(this.offX())}px`;
|
||||
const grid = readVerticalMargins(this.view.dom as HTMLElement).grid;
|
||||
d.style.top = `${grid.topOf(page) + px(this.node.attrs.wrapOffsetY)}px`;
|
||||
d.style.top = `${grid.topOf(page) + px(this.offY())}px`;
|
||||
}
|
||||
|
||||
// The offset counts from the anchor paragraph's top. Where text precedes the frame
|
||||
@@ -609,6 +673,7 @@ class ImageView {
|
||||
// image to the exact character. One undo step (later moves are addToHistory:false).
|
||||
private startReposition(event: MouseEvent): void {
|
||||
if (!this.editor.isEditable) return;
|
||||
if (this.isFree()) { this.startFreeDrag(event); return; }
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const view = this.view;
|
||||
@@ -681,6 +746,19 @@ class ImageView {
|
||||
win.addEventListener('mouseup', finish);
|
||||
}
|
||||
|
||||
// A frame out of the flow moves by its own offsets instead of re-anchoring: nothing
|
||||
// wraps around it, so there is no text position to follow. A click that never moved
|
||||
// selects it, as it does everywhere else.
|
||||
private startFreeDrag(event: MouseEvent): void {
|
||||
startFreeMove(event, this.dom, this.node.attrs, by => { this.dragBy = by; this.applyWrap(); }, offsets => {
|
||||
if (offsets) { this.commit(offsets); return; }
|
||||
const pos = this.getPos();
|
||||
if (typeof pos === 'number') {
|
||||
this.view.dispatch(this.view.state.tr.setSelection(NodeSelection.create(this.view.state.doc, pos)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private adoptNaturalSize(): void {
|
||||
if (this.attrW() != null && this.attrH() != null) return;
|
||||
const nw = this.img.naturalWidth;
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { EditorState } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import type { EditorView } from '@tiptap/pm/view';
|
||||
import { placeFromPage } from './pageBreaks';
|
||||
import { HANDLES, MIN_SIZE_PX, clamp, parsePx, frameMargins, pageContentHeightPx, applyRunThrough, type WrapMode } from './image';
|
||||
import { HANDLES, MIN_SIZE_PX, clamp, parsePx, frameMargins, pageContentHeightPx, applyRunThrough, startFreeMove, droppedFrameAttrs, type WrapMode } from './image';
|
||||
import { SHAPES, shapePath, linePaths, arrowHeadPx, isShapeKind, isLineKind, type ShapeKind } from '../../utils/shapes';
|
||||
import { cmToPx } from '../../storage/pageMargins';
|
||||
|
||||
@@ -503,6 +503,8 @@ class TextBoxView {
|
||||
private editor: Editor;
|
||||
private getPos: () => number;
|
||||
private resizing = false;
|
||||
// Live offsets while a free drag runs (cm), added to the node's own by offX/offY.
|
||||
private dragBy: { x: number; y: number } | null = null;
|
||||
// The polygon outline, for a shape CSS cannot draw; null for the three it can.
|
||||
private outline: SVGPathElement | null = null;
|
||||
// The line and its arrow heads, for the kinds that are two endpoints, not a box.
|
||||
@@ -561,6 +563,10 @@ class TextBoxView {
|
||||
return this.node.attrs as TextBoxAttrs;
|
||||
}
|
||||
|
||||
// The frame's offsets, carrying a running free drag (see ImageView).
|
||||
private offX(): unknown { const v = this.attrs().wrapOffset; return this.dragBy ? (typeof v === 'number' ? v : 0) + this.dragBy.x : v; }
|
||||
private offY(): unknown { const v = this.attrs().wrapOffsetY; return this.dragBy ? (typeof v === 'number' ? v : 0) + this.dragBy.y : v; }
|
||||
|
||||
private applyAll(): void {
|
||||
const a = this.attrs();
|
||||
this.rotor.style.width = a.width ? `${a.width}px` : `${DEFAULT_WIDTH_PX}px`;
|
||||
@@ -729,7 +735,7 @@ class TextBoxView {
|
||||
// Behind the text, which is what a shape with no run-through of its own exports as
|
||||
// — and under a picture behind the text too (-1), which is the order LibreOffice
|
||||
// paints a cover page in; a box the file puts in front of the text sits above both.
|
||||
applyRunThrough(d, a.wrapOffset, a.wrapOffsetY, a.inFront === true, a.wrapFromPage === true);
|
||||
applyRunThrough(d, this.offX(), this.offY(), a.inFront === true, a.wrapFromPage === true);
|
||||
if (a.inFront !== true) d.style.zIndex = '-2';
|
||||
// Deferred: the frame has to be laid out before its own page can be read.
|
||||
if (a.wrapFromPage) requestAnimationFrame(() => placeFromPage(this.editor.view, d));
|
||||
@@ -813,6 +819,12 @@ class TextBoxView {
|
||||
// draggable, so a later ring drag still moves it natively.
|
||||
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)));
|
||||
if (!already) { e.preventDefault(); view.focus(); }
|
||||
// Out of the flow there is no text position to re-anchor to, so the ring drag
|
||||
// moves the box by its own offsets instead of PM's native node move.
|
||||
if (this.attrs().wrap === 'through') {
|
||||
startFreeMove(e, this.dom, this.node.attrs, by => { this.dragBy = by; this.applyWrap(); },
|
||||
offsets => { if (offsets) this.commit(offsets); });
|
||||
}
|
||||
} else if (!this.editing(view.state)) {
|
||||
// Body click on a box nobody is editing: the frame is not editable yet, so the
|
||||
// browser places no caret in it. Do it ourselves at the click point.
|
||||
|
||||
@@ -316,6 +316,8 @@ const de: Messages = {
|
||||
wrapLeft: 'Textumbruch — Bild links',
|
||||
wrapRight: 'Textumbruch — Bild rechts',
|
||||
wrapTopBottom: 'Oben und unten',
|
||||
wrapBehind: 'Hinter den Text',
|
||||
wrapFront: 'Vor den Text',
|
||||
},
|
||||
textBox: {
|
||||
toolbar: 'Textfeld-Optionen',
|
||||
@@ -323,6 +325,8 @@ const de: Messages = {
|
||||
wrapLeft: 'Textumbruch — Feld links',
|
||||
wrapRight: 'Textumbruch — Feld rechts',
|
||||
wrapTopBottom: 'Oben und unten',
|
||||
wrapBehind: 'Hinter den Text',
|
||||
wrapFront: 'Vor den Text',
|
||||
alignLeft: 'Linksbündig in der Spalte',
|
||||
alignCenter: 'Zentriert in der Spalte',
|
||||
alignRight: 'Rechtsbündig in der Spalte',
|
||||
|
||||
@@ -322,6 +322,8 @@ const en = {
|
||||
wrapLeft: 'Wrap text — image left',
|
||||
wrapRight: 'Wrap text — image right',
|
||||
wrapTopBottom: 'Top and bottom',
|
||||
wrapBehind: 'Behind text',
|
||||
wrapFront: 'In front of text',
|
||||
},
|
||||
textBox: {
|
||||
toolbar: 'Text box options',
|
||||
@@ -329,6 +331,8 @@ const en = {
|
||||
wrapLeft: 'Wrap text — box left',
|
||||
wrapRight: 'Wrap text — box right',
|
||||
wrapTopBottom: 'Top and bottom',
|
||||
wrapBehind: 'Behind text',
|
||||
wrapFront: 'In front of text',
|
||||
alignLeft: 'Align left in the column',
|
||||
alignCenter: 'Centre in the column',
|
||||
alignRight: 'Align right in the column',
|
||||
|
||||
@@ -313,6 +313,65 @@ try {
|
||||
const flow = await page.evaluate(() => ({ passes: window.__passes, fragments: document.querySelectorAll('.tiptap > .columns-node').length }));
|
||||
check(flow.fragments >= 3 && flow.passes <= 8,
|
||||
`a two-column section over ${flow.fragments} pages settles in ${flow.passes} passes`);
|
||||
// Behind the text: the frame leaves the flow (so the paragraph loses its height again)
|
||||
// and is then moved by its own offsets, since there is no text position to re-anchor to.
|
||||
await page.evaluate((d) => localStorage.setItem('edentext-doc', JSON.stringify(d)), { type: 'doc', content: [
|
||||
block(words('before '), frame({}), words(' after the picture')),
|
||||
] });
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForSelector('.tiptap img', { timeout: 15_000 });
|
||||
await settle(page, true);
|
||||
const paraHeight = () => page.evaluate(() => document.querySelector('.tiptap > p').getBoundingClientRect().height);
|
||||
const frameBox = () => page.evaluate(() => {
|
||||
const el = document.querySelector('.image-node');
|
||||
const r = el.getBoundingClientRect();
|
||||
return { wrap: el.dataset.wrap ?? '', z: getComputedStyle(el).zIndex, x: r.left, y: r.top };
|
||||
});
|
||||
const inlineHeight = await paraHeight();
|
||||
await page.click('.tiptap img');
|
||||
await page.waitForSelector('.image-toolbar', { timeout: 10_000 });
|
||||
await page.locator('.image-toolbar .it-btn').nth(4).click();
|
||||
await settle(page, true);
|
||||
const behindBox = await frameBox();
|
||||
check(behindBox.wrap === 'through' && behindBox.z === '-1' && await paraHeight() < inlineHeight,
|
||||
`the behind-text button takes the frame out of the flow (${behindBox.wrap}, z ${behindBox.z}, ${inlineHeight}px → ${await paraHeight()}px)`);
|
||||
|
||||
await page.mouse.move(behindBox.x + 60, behindBox.y + 30);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(behindBox.x + 120, behindBox.y + 70, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
await settle(page, true);
|
||||
const movedBox = await frameBox();
|
||||
const dx = Math.round(movedBox.x - behindBox.x);
|
||||
const dy = Math.round(movedBox.y - behindBox.y);
|
||||
check(Math.abs(dx - 60) <= 2 && Math.abs(dy - 40) <= 2,
|
||||
`a frame out of the flow is dragged by its own offsets (moved ${dx}/${dy}, wanted 60/40)`);
|
||||
|
||||
// A text box in that mode moves the same way, but by its frame ring — its own drag
|
||||
// is ProseMirror's node move, which would re-anchor it instead.
|
||||
await page.evaluate((d) => localStorage.setItem('edentext-doc', JSON.stringify(d)), { type: 'doc', content: [
|
||||
block(words('before the box '), { type: 'textBox', attrs: { width: 200, height: 80, wrap: 'through' },
|
||||
content: [block(words('in the box'))] }, words(' after it')),
|
||||
] });
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForSelector('.tiptap .image-node[data-wrap="through"]', { timeout: 15_000 });
|
||||
await settle(page, true);
|
||||
const boxAt = () => page.evaluate(() => {
|
||||
const r = document.querySelector('.tiptap .image-node[data-wrap="through"]').getBoundingClientRect();
|
||||
return { x: r.left, y: r.top };
|
||||
});
|
||||
const boxBefore = await boxAt();
|
||||
await page.mouse.move(boxBefore.x + 2, boxBefore.y + 40);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(boxBefore.x + 52, boxBefore.y + 65, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
await settle(page, true);
|
||||
const boxAfter = await boxAt();
|
||||
const bdx = Math.round(boxAfter.x - boxBefore.x);
|
||||
const bdy = Math.round(boxAfter.y - boxBefore.y);
|
||||
check(Math.abs(bdx - 50) <= 2 && Math.abs(bdy - 25) <= 2,
|
||||
`a text box out of the flow is dragged by its ring (moved ${bdx}/${bdy}, wanted 50/25)`);
|
||||
|
||||
} catch (err) {
|
||||
check(false, `dom run threw: ${err.message ?? err}`);
|
||||
} finally {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { buildOdt } from '../../src/lib/export/odt';
|
||||
import { buildDocx } from '../../src/lib/export/docx';
|
||||
import { importOdt } from '../../src/lib/import/odt';
|
||||
import { importDocx } from '../../src/lib/import/docx';
|
||||
import { droppedFrameAttrs } from '../../src/lib/editor/extensions/image';
|
||||
|
||||
type N = any;
|
||||
|
||||
@@ -50,3 +51,19 @@ describe('a run-through frame', () => {
|
||||
expect(img.attrs.wrap).toBe('topBottom');
|
||||
});
|
||||
});
|
||||
|
||||
// Picking a mode by hand is "put it there": the offsets go, and so do the coordinate
|
||||
// systems they were measured in — a page-placed frame set to a side wrap would
|
||||
// otherwise export as vertical-rel="page" with an offset that now counts from a paragraph.
|
||||
describe('picking a wrap mode by hand', () => {
|
||||
it('drops the offsets and the frame of reference they belonged to', () => {
|
||||
const dropped = droppedFrameAttrs('left', false);
|
||||
expect(dropped).toMatchObject({ wrapOffset: null, wrapOffsetY: null, wrapFromPage: false, anchorPage: null });
|
||||
});
|
||||
|
||||
it('keeps inFront for run-through alone', () => {
|
||||
expect(droppedFrameAttrs('through', true).inFront).toBe(true);
|
||||
expect(droppedFrameAttrs('through', false).inFront).toBe(false);
|
||||
expect(droppedFrameAttrs('topBottom', true).inFront).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user