Operand

thee, sea, us.

gram: docs

> ./packages/vue/src/components/ImageSelectionOverlay.vue

<template>
<div
ref="overlayRootRef"
v-if="imageInfo"
class="image-overlay"
:style="overlayStyle"
@mousedown.stop
>
<!-- Selection border -->
<div class="image-overlay__border"></div>
<!-- Draggable body — press + drag past the threshold moves the image -->
<div
class="image-overlay__body"
:style="{ cursor: isDragging ? 'grabbing' : 'grab' }"
@mousedown.prevent.stop="startDragMove($event)"
@contextmenu.prevent.stop="$emit('context-menu', $event)"
></div>
<!-- Resize handles: 4 corners + 4 edge midpoints -->
<div
v-for="h in handles"
:key="h.pos"
class="image-overlay__handle"
:style="h.style"
:data-handle="h.pos"
@mousedown.prevent.stop="startResize($event, h.pos)"
></div>
<!-- Rotation handle (above the top edge, with a connector line) -->
<div class="image-overlay__rotate-line"></div>
<div
class="image-overlay__rotate-handle"
:style="{ left: `${currentRectWidth / 2 - 7}px` }"
:title="t('imageOverlay.rotate')"
@mousedown.prevent.stop="startRotate($event)"
></div>
<!-- Dimension label during resize -->
<div v-if="isResizing" class="image-overlay__dim">
{{ Math.round(currentWidth) }} &times; {{ Math.round(currentHeight) }}
</div>
<!-- Rotation label while rotating -->
<div v-if="isRotating" class="image-overlay__dim image-overlay__dim--rotate">
{{ Math.round(currentRotation) }}&deg;
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue';
import type { EditorView } from 'prosemirror-view';
import { NodeSelection } from 'prosemirror-state';
import { pixelsToEmu } from '@eigenpal/docx-editor-core/utils';
import { clickToPositionDom } from '@eigenpal/docx-editor-core/layout-bridge/clickToPositionDom';
import {
isFloatingImage,
commitImageResize,
commitImageFloatMove,
commitImageInlineMove,
calculateResizedImageDimensions,
type ImageResizeHandle,
} from '@eigenpal/docx-editor-core/prosemirror/imageCommit';
import { findBodyPmAnchor } from '@eigenpal/docx-editor-core/layout-bridge';
import { findImageElement } from '@eigenpal/docx-editor-core/layout-painter';
import { Z_INDEX } from '../styles/zIndex';
import { computeImageOverlayRect } from '../composables/imageOverlayRect';
import { useTranslation } from '../i18n';
const { t } = useTranslation();
import type { ImageSelectionInfo } from './imageSelectionTypes';
export type { ImageSelectionInfo };
/** Resize handle position; the resize math lives in core (shared with React). */
type ResizeHandle = ImageResizeHandle;
const props = defineProps<{
imageInfo: ImageSelectionInfo | null;
zoom: number;
view: EditorView | null;
}>();
const emit = defineEmits<{
(e: 'deselect'): void;
/** Fired when a resize / move / rotate gesture begins (parity with React's onResizeStart/onDragStart). */
(e: 'interact-start'): void;
/** Fired when a resize / move / rotate gesture ends. */
(e: 'interact-end'): void;
/** Fired when the selected image overlay receives a context-menu gesture. */
(e: 'context-menu', event: MouseEvent): void;
}>();
const overlayRootRef = ref<HTMLElement | null>(null);
const isResizing = ref(false);
const isDragging = ref(false);
const isRotating = ref(false);
const currentWidth = ref(0);
const currentHeight = ref(0);
const currentRotation = ref(0);
// Tracked overlay rect (updated from element position). During a resize this is
// mutated live so the painted border/handles follow the pointer.
const overlayRect = ref<{ left: number; top: number; width: number; height: number } | null>(null);
const DRAG_THRESHOLD = 4; // px before a press becomes a move drag — matches React.
const ROTATION_SNAP_DEG = 15; // snap increments (released by holding Shift) — matches Word.
let resizeHandle: ResizeHandle = 'se';
let startX = 0;
let startY = 0;
let startWidth = 0;
let startHeight = 0;
// Immutable snapshot of the overlay rect when a resize starts — west/north
// handles compute their new left/top from this, never accumulating.
let resizeStartRect: { left: number; top: number; width: number; height: number } | null = null;
let rafId: number | null = null;
// drag-to-move's listeners are closures, so hold refs to them so onBeforeUnmount
// can detach them if the component unmounts mid-drag (e.g. the image is deleted
// via keyboard during a drag).
let dragMoveListener: ((e: MouseEvent) => void) | null = null;
let dragUpListener: ((e: MouseEvent) => void) | null = null;
/** The image PM node at `pos`, or null if it's gone / not an image. */
function getImageNode(v: EditorView | null, pos: number) {
if (!v) return null;
try {
const node = v.state.doc.nodeAt(pos);
return node && node.type.name === 'image' ? node : null;
} catch {
return null;
}
}
// ---- Position calculation (matches React's approach) ----
/** The scaled `.docx-editor-vue__pages` element this overlay is anchored to. */
function getPagesEl(): HTMLElement | null {
return (
overlayRootRef.value
?.closest('.docx-editor-vue__pages-viewport')
?.querySelector<HTMLElement>('.docx-editor-vue__pages') ?? null
);
}
/**
* The painted element to anchor the overlay to. After a resize / move / rotate
* commits — or after the image is pushed onto another page — the layout-painter
* re-builds the visible pages, so the originally tracked element is detached.
* Re-find the fresh one by the image's current PM position (covering inline,
* floating, and block images) through the body-scoped `findBodyPmAnchor`, which
* restricts the lookup to `.layout-page-content` so a header/footer run (a
* separate PM doc whose positions overlap the body's) can never match. Mirrors
* React's single-source resolution. Returns null if it's gone.
*/
function resolveImageEl(): HTMLElement | null {
const info = props.imageInfo;
if (!info) return null;
if (info.element.isConnected) return info.element;
const pages = getPagesEl();
if (!pages) return null;
const anchor = findBodyPmAnchor(pages, info.pmPos);
return anchor ? findImageElement(anchor) : null;
}
function updatePosition() {
const imgEl = resolveImageEl();
if (!imgEl || !overlayRootRef.value) {
overlayRect.value = null;
return;
}
const parent = overlayRootRef.value.offsetParent as HTMLElement | null;
if (!parent) {
overlayRect.value = null;
return;
}
const parentRect = parent.getBoundingClientRect();
const imageRect = imgEl.getBoundingClientRect();
// The overlay is `position: absolute` inside its offsetParent, the scroll
// container (`.docx-editor-vue__pages-viewport`). Absolutely-positioned
// children are placed relative to the *content* origin, so the scroll offset
// is added back, and the inline-start scrollbar gutter (from
// `scrollbar-gutter: stable both-edges`) is subtracted — otherwise the frame
// lands `scrollTop` px too high after scrolling, or shifted right by the
// gutter width on platforms with classic scrollbars (issue #764). See
// `computeImageOverlayRect` for the geometry.
overlayRect.value = computeImageOverlayRect({
imageRect,
parentRect,
scrollLeft: parent.scrollLeft,
scrollTop: parent.scrollTop,
parentOffsetWidth: parent.offsetWidth,
parentClientWidth: parent.clientWidth,
zoom: props.zoom,
});
}
// The image's painted position can keep moving for a few frames after a
// trigger, and this overlay — unlike React's, which lives *inside* the scaled
// container and tracks for free — sits in the unscaled viewport and must
// re-anchor itself across that window. Two triggers need it:
// - Selecting an image right after load: `.docx-editor-vue__pages` re-centers
// horizontally as the layout settles (a `translateX` change, so no
// ResizeObserver would catch it), shifting the image out from under a
// frame measured one frame too early (issue #764).
// - Zoom: the pages `transform` animates over ~0.2s (DocxEditor.vue
// `pagesContainerStyle`); `transitionend` is unreliable (never fires under
// `prefers-reduced-motion`).
//
// So recompute each frame until the rect holds steady — but only honor
// steadiness AFTER a minimum window: in the first frame or two the rect is
// momentarily steady at its pre-transition position, and latching there strands
// the frame. Track elapsed time from the rAF timestamp (not a per-frame guess)
// so high-refresh displays can't race through the window, and cap it so a
// perpetually-moving rect can't spin.
const SETTLE_MIN_MS = 250; // covers the 0.2s pages transform transition + buffer
const SETTLE_CAP_MS = 700; // hard stop so a perpetually-moving rect can't spin
// Cancel for the in-flight re-anchor loop. Only one runs at a time — a fresh
// trigger cancels and restarts it so overlapping triggers don't pile up rAFs.
let cancelReanchor: (() => void) | null = null;
// Re-measure the frame against the image every frame until the layout holds
// steady. The image's painted position keeps moving for a few frames after a
// trigger (zoom and the comments-sidebar shift both animate the pages
// `transform` over ~0.2s — see DocxEditor.vue `pagesContainerStyle`), and this
// overlay — unlike React's, which lives *inside* the scaled+translated container
// and tracks for free — sits in the unscaled viewport and must re-anchor across
// that window. Only honor steadiness AFTER a minimum window: in the first frame
// or two the rect is momentarily steady at its pre-transition position, and
// latching there strands the frame. Track elapsed from the rAF timestamp so
// high-refresh displays can't race through the window; cap it so a
// perpetually-moving rect can't spin.
function scheduleReanchor() {
cancelReanchor?.();
let raf = 0;
let prevKey = '';
let stableFrames = 0;
let startTs = 0;
const step = (ts: number) => {
if (startTs === 0) startTs = ts;
const elapsed = ts - startTs;
updatePosition();
const r = overlayRect.value;
const key = r ? `${r.left}|${r.top}|${r.width}|${r.height}` : '';
stableFrames = key === prevKey ? stableFrames + 1 : 0;
prevKey = key;
const settled = elapsed >= SETTLE_MIN_MS && stableFrames >= 2;
if (!settled && elapsed < SETTLE_CAP_MS) {
raf = requestAnimationFrame(step);
}
};
raf = requestAnimationFrame(step);
// Safety net: rAF is paused while the tab is backgrounded, so a timer also
// re-measures once the layout has settled, guaranteeing a correct final anchor.
const settleTimer = setTimeout(updatePosition, SETTLE_MIN_MS);
cancelReanchor = () => {
cancelAnimationFrame(raf);
clearTimeout(settleTimer);
};
}
// Re-anchor when the selected image changes. The first rAF step runs after Vue
// has mounted the `v-if` overlay, so `overlayRootRef` is available by then.
watch(
() => props.imageInfo,
(_info, _prev, onCleanup) => {
if (!props.imageInfo) {
overlayRect.value = null;
return;
}
scheduleReanchor();
onCleanup(() => cancelReanchor?.());
},
{ immediate: true }
);
// Re-anchor when zoom changes.
watch(
() => props.zoom,
() => {
if (!props.imageInfo) return;
scheduleReanchor();
}
);
// While an image is selected, keep the frame on it across the layout shifts
// that fire no zoom/imageInfo event: page scroll/resize, and the comments
// sidebar sliding `.docx-editor-vue__pages` sideways (a `translateX`) to make
// room for the panel. The sidebar shift moves the image with no scroll/resize
// event, so observe the pages container's style and re-anchor through the
// ~0.2s transition — React's overlay lives inside that container and tracks for
// free. The transform observer is deferred to the next tick because the overlay
// mounts via `v-if`, so `getPagesEl` isn't resolvable until then.
watch(
() => props.imageInfo,
(_newVal, _oldVal, onCleanup) => {
if (!props.imageInfo) return;
const handleScrollOrResize = () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(updatePosition);
};
// The viewport itself is the scroll container (`overflow-y: auto`); its
// parent `.docx-editor-vue__editor-area` is `overflow: hidden` and never
// scrolls. `scroll` events don't bubble, so the listener must sit on the
// viewport or it never fires and the overlay drifts off the image on scroll.
const viewport = overlayRootRef.value?.closest('.docx-editor-vue__pages-viewport');
viewport?.addEventListener('scroll', handleScrollOrResize, { passive: true });
window.addEventListener('resize', handleScrollOrResize, { passive: true });
let transformObserver: MutationObserver | null = null;
let cancelled = false;
nextTick(() => {
if (cancelled || !props.imageInfo) return;
const pages = getPagesEl();
if (!pages) return;
transformObserver = new MutationObserver(() => scheduleReanchor());
transformObserver.observe(pages, { attributes: true, attributeFilter: ['style'] });
});
onCleanup(() => {
cancelled = true;
viewport?.removeEventListener('scroll', handleScrollOrResize);
window.removeEventListener('resize', handleScrollOrResize);
transformObserver?.disconnect();
if (rafId) cancelAnimationFrame(rafId);
});
},
{ immediate: true }
);
// ---- Computed styles ----
const currentRectWidth = computed(() =>
isResizing.value ? currentWidth.value : overlayRect.value?.width || 0
);
const currentRectHeight = computed(() =>
isResizing.value ? currentHeight.value : overlayRect.value?.height || 0
);
const overlayStyle = computed(() => {
const r = overlayRect.value;
if (!r) {
// Use visibility:hidden instead of display:none so offsetParent remains
// available for position calculation on the next tick.
return {
position: 'absolute' as const,
top: '0px',
left: '0px',
visibility: 'hidden' as const,
pointerEvents: 'none' as const,
};
}
const w = isResizing.value ? currentWidth.value : r.width;
const h = isResizing.value ? currentHeight.value : r.height;
// `overlayRect` is kept in layout (unscaled) px — the same space the image's
// `width`/`height` attrs and the resize/drag math live in. But the overlay's
// offsetParent is `.docx-editor-vue__pages-viewport`, which is NOT scaled
// (only the inner `.docx-editor-vue__pages` carries `transform: scale(zoom)`).
// So position the box at real (post-scale) px and scale its contents — border,
// handles and rotate handle all grow with zoom, mirroring React's overlay,
// which sits inside its scaled container and scales for free.
const z = props.zoom || 1;
return {
position: 'absolute' as const,
left: `${r.left * z}px`,
top: `${r.top * z}px`,
width: `${w}px`,
height: `${h}px`,
transform: z === 1 ? undefined : `scale(${z})`,
transformOrigin: 'top left' as const,
zIndex: Z_INDEX.imageOverlay,
pointerEvents: 'auto' as const,
};
});
/**
* Handle geometry. Corners sit on the box corners; edge handles sit on the
* midpoint of each side. Cursors match the React overlay
* (nwse / nesw for corners, ns / ew for edges).
*/
const handles = computed<Array<{ pos: ResizeHandle; style: Record<string, string> }>>(() => {
const half = 5; // half the 10px handle
const w = currentRectWidth.value;
const h = currentRectHeight.value;
const midX = `${w / 2 - half}px`;
const midY = `${h / 2 - half}px`;
const right = `${w - half}px`;
const bottom = `${h - half}px`;
const neg = `-${half}px`;
return [
{ pos: 'nw', style: { left: neg, top: neg, cursor: 'nwse-resize' } },
{ pos: 'n', style: { left: midX, top: neg, cursor: 'ns-resize' } },
{ pos: 'ne', style: { left: right, top: neg, cursor: 'nesw-resize' } },
{ pos: 'e', style: { left: right, top: midY, cursor: 'ew-resize' } },
{ pos: 'se', style: { left: right, top: bottom, cursor: 'nwse-resize' } },
{ pos: 's', style: { left: midX, top: bottom, cursor: 'ns-resize' } },
{ pos: 'sw', style: { left: neg, top: bottom, cursor: 'nesw-resize' } },
{ pos: 'w', style: { left: neg, top: midY, cursor: 'ew-resize' } },
];
});
// ---- Resize logic ----
// The dimension math lives in core (`calculateResizedImageDimensions`), shared
// with the React overlay.
function startResize(e: MouseEvent, handle: string) {
if (!props.imageInfo || !overlayRect.value) return;
resizeHandle = handle as ResizeHandle;
startX = e.clientX;
startY = e.clientY;
startWidth = overlayRect.value.width;
startHeight = overlayRect.value.height;
resizeStartRect = { ...overlayRect.value };
currentWidth.value = Math.round(startWidth);
currentHeight.value = Math.round(startHeight);
isResizing.value = true;
emit('interact-start');
document.addEventListener('mousemove', onResizeMove);
document.addEventListener('mouseup', onResizeEnd);
}
function onResizeMove(e: MouseEvent) {
const z = props.zoom;
const deltaX = (e.clientX - startX) / z;
const deltaY = (e.clientY - startY) / z;
const lockAspect = !e.shiftKey;
const dims = calculateResizedImageDimensions(
resizeHandle,
deltaX,
deltaY,
startWidth,
startHeight,
lockAspect
);
currentWidth.value = Math.round(dims.width);
currentHeight.value = Math.round(dims.height);
// Keep the painted box pinned at the opposite edge when dragging a
// west/north handle, so the resize grows toward the pointer. Computed from
// the immutable start-rect so repeated moves never drift.
const base = resizeStartRect;
if (base) {
const nextLeft = resizeHandle.includes('w') ? base.left + (base.width - dims.width) : base.left;
const nextTop = resizeHandle.includes('n') ? base.top + (base.height - dims.height) : base.top;
overlayRect.value = { left: nextLeft, top: nextTop, width: base.width, height: base.height };
}
}
function onResizeEnd() {
document.removeEventListener('mousemove', onResizeMove);
document.removeEventListener('mouseup', onResizeEnd);
isResizing.value = false;
resizeStartRect = null;
emit('interact-end');
const v = props.view;
const info = props.imageInfo;
if (!v || !info) {
updatePosition();
return;
}
// `setNodeMarkup` doesn't reliably keep the NodeSelection on the node, so
// re-assert it (mirrors React's `setNodeSelection` after resize). Without
// this the selection collapses to a text caret and the overlay clears.
const sel = commitImageResize(v, info.pmPos, currentWidth.value, currentHeight.value);
if (sel !== null) reselectImage(sel);
// Re-derive the rect from the freshly painted element.
nextTick(() => updatePosition());
}
// ---- Rotation logic ----
// Snapshotted in startRotate — none of these change during a rotate, so the
// per-mousemove handler never has to re-query the DOM or the PM doc.
let rotateCenterX = 0;
let rotateCenterY = 0;
let rotateStartAngle = 0; // pointer angle at gesture start (degrees)
let rotateBaseRotation = 0; // image rotation at gesture start (degrees)
let rotateBaseTransform: string | null = null; // the image's transform attr at start (flip terms preserved)
let rotateImgEl: HTMLElement | null = null; // the painted <img> the live preview is applied to
/** Pull the `rotate(Ndeg)` term out of an existing CSS transform string. */
function readRotation(transform: string | null | undefined): number {
if (!transform) return 0;
const m = transform.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
return m ? parseFloat(m[1]) : 0;
}
/** Re-assemble a transform string keeping any flip terms intact (mirrors DocxEditor's logic). */
function writeRotation(transform: string | null | undefined, rotation: number): string | null {
const hasFlipH = /scaleX\(-1\)/.test(transform || '');
const hasFlipV = /scaleY\(-1\)/.test(transform || '');
const parts: string[] = [];
const norm = ((rotation % 360) + 360) % 360;
if (norm !== 0) parts.push(`rotate(${norm}deg)`);
if (hasFlipH) parts.push('scaleX(-1)');
if (hasFlipV) parts.push('scaleY(-1)');
return parts.length > 0 ? parts.join(' ') : null;
}
function angleFromCenter(clientX: number, clientY: number): number {
// Angle of the pointer relative to the box centre, with 0° pointing up
// (the rotation handle's resting position).
return (Math.atan2(clientX - rotateCenterX, -(clientY - rotateCenterY)) * 180) / Math.PI;
}
function startRotate(e: MouseEvent) {
if (!props.imageInfo) return;
const rect = props.imageInfo.element.getBoundingClientRect();
rotateCenterX = rect.left + rect.width / 2;
rotateCenterY = rect.top + rect.height / 2;
const node = getImageNode(props.view, props.imageInfo.pmPos);
rotateBaseTransform = node ? (node.attrs.transform as string) || null : null;
rotateBaseRotation = readRotation(rotateBaseTransform);
rotateImgEl = props.imageInfo.element.querySelector('img');
rotateStartAngle = angleFromCenter(e.clientX, e.clientY);
currentRotation.value = rotateBaseRotation;
isRotating.value = true;
emit('interact-start');
document.addEventListener('mousemove', onRotateMove);
document.addEventListener('mouseup', onRotateEnd);
}
function onRotateMove(e: MouseEvent) {
const delta = angleFromCenter(e.clientX, e.clientY) - rotateStartAngle;
let next = rotateBaseRotation + delta;
if (!e.shiftKey) {
next = Math.round(next / ROTATION_SNAP_DEG) * ROTATION_SNAP_DEG;
}
currentRotation.value = ((next % 360) + 360) % 360;
// Live preview on the painted <img> (the painter applies `transform` there,
// not on the container — which is `overflow:hidden`, so rotating the
// container would just clip the still-upright image). Element + base transform
// were snapshotted in startRotate; this only writes a style.
if (rotateImgEl) {
rotateImgEl.style.transform = writeRotation(rotateBaseTransform, currentRotation.value) || '';
}
}
function onRotateEnd() {
document.removeEventListener('mousemove', onRotateMove);
document.removeEventListener('mouseup', onRotateEnd);
isRotating.value = false;
emit('interact-end');
const v = props.view;
const info = props.imageInfo;
try {
const node = v && info ? getImageNode(v, info.pmPos) : null;
if (v && info && node) {
v.dispatch(
v.state.tr.setNodeMarkup(info.pmPos, undefined, {
...node.attrs,
transform: writeRotation(node.attrs.transform as string, currentRotation.value),
})
);
// Keep the image node-selected after the markup change (see onResizeEnd).
reselectImage(info.pmPos);
}
} catch {
/* position invalid after a concurrent edit */
} finally {
// Drop the live-preview inline transform. On the happy path the dispatch
// above re-paints the pages and replaces this <img> anyway; this matters
// for the catch path, where no re-paint follows and the stale transform
// would otherwise stick.
if (rotateImgEl) rotateImgEl.style.transform = '';
rotateImgEl = null;
rotateBaseTransform = null;
}
nextTick(() => updatePosition());
}
// ---- Drag-to-move logic ----
let moveGhostEl: HTMLElement | null = null;
function startDragMove(e: MouseEvent) {
if (!props.imageInfo || !overlayRect.value) return;
const sx = e.clientX;
const sy = e.clientY;
const rect = overlayRect.value;
let dragStarted = false;
const onMove = (moveEvent: MouseEvent) => {
const dx = moveEvent.clientX - sx;
const dy = moveEvent.clientY - sy;
if (!dragStarted && Math.sqrt(dx * dx + dy * dy) < DRAG_THRESHOLD) return;
if (!dragStarted) {
dragStarted = true;
isDragging.value = true;
emit('interact-start');
moveGhostEl = document.createElement('div');
moveGhostEl.style.cssText =
'position: fixed; pointer-events: none; z-index: 10000; opacity: 0.5; ' +
'border: 2px dashed #2563eb; border-radius: 4px; background: rgba(37, 99, 235, 0.1);';
const z = props.zoom;
moveGhostEl.style.width = `${rect.width * z}px`;
moveGhostEl.style.height = `${rect.height * z}px`;
document.body.appendChild(moveGhostEl);
}
if (moveGhostEl) {
const z = props.zoom;
moveGhostEl.style.left = `${moveEvent.clientX - (rect.width * z) / 2}px`;
moveGhostEl.style.top = `${moveEvent.clientY - (rect.height * z) / 2}px`;
}
};
const onUp = (upEvent: MouseEvent) => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
dragMoveListener = null;
dragUpListener = null;
if (moveGhostEl) {
moveGhostEl.remove();
moveGhostEl = null;
}
isDragging.value = false;
if (dragStarted) {
emit('interact-end');
commitDragMove(upEvent.clientX, upEvent.clientY);
}
};
dragMoveListener = onMove;
dragUpListener = onUp;
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
/**
* Commit a drag-to-move. Mirrors React's `handleImageDragMove`:
* - floating images (square / tight / through wrap, or displayMode === 'float'):
* rewrite the anchor `position` offsets so the image moves to the drop point
* - inline images: re-insert the image node at the text position under the drop
*/
function commitDragMove(clientX: number, clientY: number) {
const v = props.view;
const info = props.imageInfo;
if (!v || !info) return;
const node = getImageNode(v, info.pmPos);
if (!node) return;
if (isFloatingImage(node)) {
const viewport = overlayRootRef.value?.closest('.docx-editor-vue__pages-viewport');
const pages = viewport?.querySelectorAll<HTMLElement>('.layout-page');
if (!pages || pages.length === 0) return;
let contentEl: HTMLElement | null = null;
for (const page of pages) {
const r = page.getBoundingClientRect();
if (clientY >= r.top && clientY <= r.bottom) {
contentEl = page.querySelector<HTMLElement>('.layout-page-content');
break;
}
}
if (!contentEl) {
contentEl = pages[pages.length - 1].querySelector<HTMLElement>('.layout-page-content');
}
if (!contentEl) return;
const contentRect = contentEl.getBoundingClientRect();
const z = props.zoom;
const hEmu = pixelsToEmu((clientX - contentRect.left) / z);
const vEmu = pixelsToEmu((clientY - contentRect.top) / z);
const sel = commitImageFloatMove(v, info.pmPos, hEmu, vEmu);
if (sel !== null) reselectImage(sel);
} else {
// Map the drop point to a PM position via the *visible pages* hit-test —
// the hidden ProseMirror lives off-screen, so `view.posAtCoords` would
// never intersect it. Mirrors how the editor places the caret on click.
const pagesEl = getPagesEl();
if (!pagesEl) return;
const dropPos = clickToPositionDom(pagesEl, clientX, clientY, 1);
if (dropPos == null || dropPos < 0) return;
const sel = commitImageInlineMove(v, info.pmPos, dropPos);
if (sel !== null) reselectImage(sel);
}
nextTick(() => updatePosition());
}
function reselectImage(pos: number) {
const v = props.view;
if (!v) return;
try {
const sel = NodeSelection.create(v.state.doc, pos);
v.dispatch(v.state.tr.setSelection(sel));
} catch {
/* ignore */
}
}
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onResizeMove);
document.removeEventListener('mouseup', onResizeEnd);
document.removeEventListener('mousemove', onRotateMove);
document.removeEventListener('mouseup', onRotateEnd);
if (dragMoveListener) document.removeEventListener('mousemove', dragMoveListener);
if (dragUpListener) document.removeEventListener('mouseup', dragUpListener);
if (moveGhostEl) {
moveGhostEl.remove();
moveGhostEl = null;
}
if (rafId) cancelAnimationFrame(rafId);
cancelReanchor?.();
});
</script>
<style scoped>
.image-overlay {
overflow: visible;
}
.image-overlay__border {
position: absolute;
inset: -2px;
border: 2px solid var(--doc-primary);
border-radius: 2px;
pointer-events: none;
}
.image-overlay__body {
position: absolute;
inset: 0;
pointer-events: auto;
}
/* White circular dots with a thin accent ring — matches the resize handles in
Word / PowerPoint. Mirrors the React overlay's handleBaseStyles. */
.image-overlay__handle {
position: absolute;
width: 10px;
height: 10px;
background: var(--doc-surface);
border: 1.5px solid var(--doc-primary);
box-shadow: 0 1px 2.5px var(--doc-shadow-strong);
border-radius: 50%;
z-index: 16;
box-sizing: border-box;
pointer-events: auto;
}
.image-overlay__rotate-line {
position: absolute;
left: 50%;
top: -22px;
width: 0;
height: 22px;
border-left: 1px solid var(--doc-primary);
pointer-events: none;
}
.image-overlay__rotate-handle {
position: absolute;
top: -32px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--doc-surface);
border: 2px solid var(--doc-primary);
box-shadow: 0 1px 3px var(--doc-shadow-strong);
cursor: grab;
z-index: 16;
box-sizing: border-box;
pointer-events: auto;
}
.image-overlay__rotate-handle:active {
cursor: grabbing;
}
.image-overlay__dim {
position: absolute;
bottom: -24px;
left: 50%;
transform: translateX(-50%);
background: var(--doc-overlay);
color: #fff;
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
}
.image-overlay__dim--rotate {
bottom: auto;
top: -52px;
}
</style>