From 9dfaeb4032af0ee962549f7c8b72e1105aa601bf Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 11 Sep 2026 00:03:04 +0200 Subject: [PATCH] fix: a frame behind the text can be picked again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser hit-tests a behind-text frame last of all — the page and every paragraph over it come first — so a cover picture could not be clicked at all: the caret landed in the text as if it were not there. A mousedown over one now goes to the frame's own node view, which selects and drags it as any other frame; text painted over the point still wins, as the caret does in both products. A box's node view mirrors renderHTML's data-wrap for it, as an image's already did. Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G --- src/lib/editor/extensions/image.ts | 53 +++++++++++++++++++++++++- src/lib/editor/extensions/textBox.ts | 3 ++ tests/normalize.ts | 1 + tests/unit/frame-page-relative.test.ts | 41 ++++++++++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 tests/unit/frame-page-relative.test.ts diff --git a/src/lib/editor/extensions/image.ts b/src/lib/editor/extensions/image.ts index 1d37d40..43463cf 100644 --- a/src/lib/editor/extensions/image.ts +++ b/src/lib/editor/extensions/image.ts @@ -314,10 +314,61 @@ export const Image = Node.create({ // The drop cursor paints a caret where a dragged-in image *file* lands; moving an // existing image uses the node view's live re-anchor drag, not PM's native node move. addProseMirrorPlugins() { - return [dropCursor({ color: '#3b82f6', width: 2 }), imageLinePlugin()]; + return [dropCursor({ color: '#3b82f6', width: 2 }), imageLinePlugin(), behindTextPlugin()]; }, }); +// A frame behind the text paints under the page and the paragraphs over it, so the +// browser hit-tests those first and a click never reaches it — where both word +// processors let one be picked. The event goes to the frame's own node view, which +// selects and drags it as a click on any other frame does; text over it still wins, +// as the caret does there. +function behindTextPlugin(): Plugin { + return new Plugin({ + props: { + handleDOMEvents: { + mousedown(view, event) { + const at = event.target; + if (!view.editable || (at instanceof HTMLElement && at.closest('[data-wrap="through"]'))) return false; + const frame = frameBehindPoint(view, event.clientX, event.clientY); + if (!frame) return false; + event.preventDefault(); + (frame.querySelector('img') ?? frame).dispatchEvent(new MouseEvent('mousedown', { + clientX: event.clientX, clientY: event.clientY, button: 0, cancelable: true, + })); + return true; + }, + }, + }, + }); +} + +// The topmost behind-text frame under the point, or null where text painted over it +// covers the point — the elements above it are walked in paint order. +function frameBehindPoint(view: EditorView, x: number, y: number): HTMLElement | null { + const doc = view.dom.ownerDocument; + for (const el of doc.elementsFromPoint(x, y)) { + if (!(el instanceof HTMLElement)) continue; + if (el.dataset.wrap === 'through' && view.dom.contains(el)) return el; + if (textUnder(el, x, y)) return null; + } + return null; +} + +// Whether one of the element's own text runs covers the point: a hit on its box alone +// is the empty part of a line or a paragraph, which the frame under it may take. +function textUnder(el: HTMLElement, x: number, y: number): boolean { + const range = el.ownerDocument.createRange(); + for (const node of Array.from(el.childNodes)) { + if (node.nodeType !== 3 || !node.nodeValue?.trim()) continue; + range.selectNodeContents(node); + for (const r of Array.from(range.getClientRects())) { + if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return true; + } + } + return false; +} + // A line carrying nothing but an as-character image is as tall as the image or the // block's line height, whichever is more — no text descent hangs below it (probed // against LibreOffice). Whitespace beside the image adds none, which CSS cannot ask. diff --git a/src/lib/editor/extensions/textBox.ts b/src/lib/editor/extensions/textBox.ts index 8b4481b..645207b 100644 --- a/src/lib/editor/extensions/textBox.ts +++ b/src/lib/editor/extensions/textBox.ts @@ -694,6 +694,9 @@ class TextBoxView { private applyWrap(): void { const d = this.dom; const a = this.attrs(); + // Mirrors renderHTML's data-wrap, so one selector reaches a floating frame in the + // live view and in generated static HTML alike (as on an image). + if (a.wrap === 'inline') delete d.dataset.wrap; else d.dataset.wrap = a.wrap; d.style.float = ''; d.style.clear = ''; d.style.position = ''; diff --git a/tests/normalize.ts b/tests/normalize.ts index f43933e..f091e43 100644 --- a/tests/normalize.ts +++ b/tests/normalize.ts @@ -13,6 +13,7 @@ const ORDERED_DEFAULTS: Record = { joinPrev: false, // pageBreaks.ts's split marker; the export merges the halves marginLeft: 0, marginRight: 0, marginTop: 0, marginBottom: 0, // a table's, once a command has touched them alt: '', inFront: false, paddingCm: 0.15, flipV: false, textVertical: false, // the editor's own picture and box defaults + wrapFromPage: false, // index attrs the DOCX TOC field has no switch for come back at their defaults maxLevel: MAX_HEADING_LEVEL, leader: '.', citationStyle: 'key', }; diff --git a/tests/unit/frame-page-relative.test.ts b/tests/unit/frame-page-relative.test.ts new file mode 100644 index 0000000..2af1d77 --- /dev/null +++ b/tests/unit/frame-page-relative.test.ts @@ -0,0 +1,41 @@ +// A frame whose vertical offset counts from the page the anchor lands on (Word's +// positionV relativeFrom="page", ODF's style:vertical-rel="page") keeps that relation +// through both formats — read as paragraph-relative it would move on every re-open. +import { describe, it, expect } from 'vitest'; +import { buildOdt } from '../../src/lib/export/odt'; +import { importOdt } from '../../src/lib/import/odt'; +import { buildDocx } from '../../src/lib/export/docx'; +import { importDocx } from '../../src/lib/import/docx'; + +type N = any; + +const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAD0lEQVR4nGNgYPgPRmAKABf2A/1+6zfzAAAAAElFTkSuQmCC'; + +const doc: N = { type: 'doc', content: [ + { type: 'paragraph', content: [ + { type: 'image', attrs: { src: PNG, width: 200, height: 100, wrap: 'through', wrapOffset: 1, wrapOffsetY: 17.6, wrapFromPage: true } }, + { type: 'textBox', attrs: { width: 300, height: 120, wrap: 'through', wrapOffset: 0, wrapOffsetY: 5.5, wrapFromPage: true, fillColor: '#34ABA2' }, + content: [{ type: 'paragraph' }] }, + ] }, + { type: 'paragraph', content: [{ type: 'text', text: 'Body' }] }, +] }; + +const find = (n: N, type: string): N => { + if (n.type === type) return n; + for (const c of n.content ?? []) { const hit = find(c, type); if (hit) return hit; } + return null; +}; + +describe('a frame placed against the page', () => { + it('round-trips through ODF', async () => { + const back = importOdt(await buildOdt(doc)).content as N; + expect(find(back, 'image').attrs).toMatchObject({ wrapFromPage: true, wrapOffsetY: 17.6 }); + expect(find(back, 'textBox').attrs).toMatchObject({ wrapFromPage: true, wrapOffsetY: 5.5 }); + }); + + it('round-trips through DOCX', async () => { + const back = importDocx(await buildDocx(doc)).content as N; + expect(find(back, 'image').attrs).toMatchObject({ wrapFromPage: true, wrapOffsetY: 17.6 }); + expect(find(back, 'textBox').attrs).toMatchObject({ wrapFromPage: true, wrapOffsetY: 5.5 }); + }); +});