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:
1 parent
9f5ecd8071
commit
9dfaeb4032
4 files changed
+97
-1
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user