feat: move image with real time text wrapping
This commit is contained in:
1 parent
20706c80cb
commit
2b0bb74632
2 files changed
+88
-5
No files matched your search
+81
-3
@@ -171,6 +171,9 @@ class ImageView {
|
||||
// Pasted/HTML images may arrive without dimensions; adopt their natural size
|
||||
// (capped to the text column) so every image is explicitly sized.
|
||||
this.img.onload = () => this.adoptNaturalSize();
|
||||
// Dragging a floating image live re-anchors it to the paragraph under the cursor
|
||||
// (text reflows in real time). Inline images keep ProseMirror's native node move.
|
||||
this.img.addEventListener('mousedown', e => this.startReposition(e as MouseEvent));
|
||||
this.rotor.appendChild(this.img);
|
||||
|
||||
for (const cfg of HANDLES) {
|
||||
@@ -254,6 +257,79 @@ class ImageView {
|
||||
}
|
||||
}
|
||||
|
||||
// Drag a floating image to re-anchor it to the paragraph under the cursor, live (the
|
||||
// node moves so text reflows in real time; throttled, only on paragraph change). Undo
|
||||
// is one step: first move records history, later moves are addToHistory:false (rebased).
|
||||
private startReposition(event: MouseEvent): void {
|
||||
if (this.attrWrap() === 'inline' || !this.editor.isEditable) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const view = this.editor.view;
|
||||
const origPos = this.getPos();
|
||||
if (typeof origPos !== 'number') return;
|
||||
const win = this.dom.ownerDocument.defaultView ?? window;
|
||||
|
||||
let curPos = origPos;
|
||||
let firstMove = true;
|
||||
let moved = false;
|
||||
let raf = 0;
|
||||
let lastX = event.clientX;
|
||||
let lastY = event.clientY;
|
||||
|
||||
const lineTop = (pos: number): number | null => {
|
||||
try { return view.coordsAtPos(pos).top; } catch { return null; }
|
||||
};
|
||||
|
||||
// Re-anchor the image at the exact text position under the cursor (so it follows
|
||||
// line by line, not paragraph by paragraph), but only when the cursor's line differs
|
||||
// from the image's current line — a float won't move within a line, so skip churn.
|
||||
const step = (): void => {
|
||||
raf = 0;
|
||||
const found = view.posAtCoords({ left: lastX, top: lastY });
|
||||
if (!found) return;
|
||||
const target = found.pos;
|
||||
if (target === curPos || target === curPos + 1) return;
|
||||
const $t = view.state.doc.resolve(target);
|
||||
if (!$t.parent.isTextblock) return;
|
||||
const curY = lineTop(curPos);
|
||||
const tgtY = lineTop(target);
|
||||
if (curY != null && tgtY != null && Math.abs(curY - tgtY) < 6) return; // same line
|
||||
const node = view.state.doc.nodeAt(curPos);
|
||||
if (!node || node.type.name !== 'image') return;
|
||||
try {
|
||||
const tr = view.state.tr;
|
||||
tr.delete(curPos, curPos + node.nodeSize);
|
||||
const ip = tr.mapping.map(target);
|
||||
tr.insert(ip, node);
|
||||
tr.setSelection(NodeSelection.create(tr.doc, ip));
|
||||
if (!firstMove) tr.setMeta('addToHistory', false);
|
||||
view.dispatch(tr);
|
||||
curPos = ip;
|
||||
firstMove = false;
|
||||
moved = true;
|
||||
} catch { /* target can't hold an inline image — ignore */ }
|
||||
};
|
||||
|
||||
const move = (e: MouseEvent): void => {
|
||||
if (!e.buttons) { finish(); return; }
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
if (!raf) raf = win.requestAnimationFrame(step);
|
||||
};
|
||||
const finish = (): void => {
|
||||
if (raf) win.cancelAnimationFrame(raf);
|
||||
win.removeEventListener('mousemove', move);
|
||||
win.removeEventListener('mouseup', finish);
|
||||
// A plain click (no move) just selects the image so its toolbar/handles show.
|
||||
if (!moved && typeof this.getPos() === 'number') {
|
||||
const pos = this.getPos();
|
||||
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)));
|
||||
}
|
||||
};
|
||||
win.addEventListener('mousemove', move);
|
||||
win.addEventListener('mouseup', finish);
|
||||
}
|
||||
|
||||
private adoptNaturalSize(): void {
|
||||
if (this.attrW() != null && this.attrH() != null) return;
|
||||
const nw = this.img.naturalWidth;
|
||||
@@ -394,11 +470,13 @@ class ImageView {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Keep ProseMirror out of handle interactions only; dragging the image itself runs
|
||||
// PM's native node move (which re-anchors a floating image to the drop paragraph).
|
||||
// Keep ProseMirror out of handle interactions, and out of all mouse handling for
|
||||
// floating images (startReposition owns their drag). Inline images keep PM's native
|
||||
// node move + drop cursor.
|
||||
stopEvent(event: Event): boolean {
|
||||
if (!event.type.startsWith('mouse')) return false;
|
||||
const t = event.target as HTMLElement | null;
|
||||
return !!t?.classList?.contains('image-resize-handle') || !!t?.classList?.contains('image-rotate-handle');
|
||||
if (t?.classList?.contains('image-resize-handle') || t?.classList?.contains('image-rotate-handle')) return true;
|
||||
return this.attrWrap() !== 'inline';
|
||||
}
|
||||
}
|
||||
@@ -623,8 +623,13 @@ export const PageBreaks = Extension.create({
|
||||
walkTableRows(child, inTableCell);
|
||||
continue;
|
||||
}
|
||||
const isAtomic = ATOMIC_TAGS.has(tag);
|
||||
const isSplittable = SPLITTABLE_TAGS.has(tag);
|
||||
// A paragraph holding a floated/block image paginates atomically (pushed
|
||||
// whole, spacer placed before it): a line-split spacer inside it would sit
|
||||
// next to the float, where ProseMirror drops the widget.
|
||||
const splittableTag = SPLITTABLE_TAGS.has(tag);
|
||||
const hasImage = splittableTag && !!child.querySelector('.image-node');
|
||||
const isAtomic = ATOMIC_TAGS.has(tag) || hasImage;
|
||||
const isSplittable = splittableTag && !hasImage;
|
||||
if (isAtomic || isSplittable) {
|
||||
let intraSpacerHeight = 0;
|
||||
for (const sp of Array.from(
|
||||
|
||||
Reference in new issue
Block a user