fix: a frame behind the text can be picked again

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
This commit is contained in:
Steffen Becker committed 2026-09-11 00:03:04 +02:00
1 parent 9f5ecd8071
commit 9dfaeb4032
4 files changed
+97 -1

No files matched your search

+52 -1
View File
@@ -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.
+3
View File
@@ -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 = '';
+1
View File
@@ -13,6 +13,7 @@ const ORDERED_DEFAULTS: Record<string, unknown> = {
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',
};
+41
View File
@@ -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 });
});
});