/** * Gesture handling for DomEditOverlay. * Owns: onPointerMove, onPointerUp, clearPointerState. * startGesture and startGroupDrag live in domEditOverlayStartGesture.ts. */ import type { RefObject } from "react"; import { type DomEditSelection } from "./domEditing"; import { applyManualOffsetDragCommit, applyManualOffsetDragDraft, endManualOffsetDragMembers, restoreManualOffsetDragMembers, } from "./manualOffsetDrag"; import { applyStudioBoxSize, applyStudioBoxSizeDraft, applyStudioRotation, applyStudioRotationDraft, endStudioManualEditGesture, isStudioManualEditGestureCurrent, readStudioBoxSize, restoreStudioBoxSize, restoreStudioPathOffset, restoreStudioRotation, } from "./manualEdits"; import { type GroupOverlayItem, type OverlayRect, toOverlayRect } from "./domEditOverlayGeometry"; import { BLOCKED_MOVE_THRESHOLD_PX, type BlockedMoveState, type GestureKind, type GestureState, type GroupGestureState, hasDomEditRotationChanged, resolveDomEditResizeGesture, resolveDomEditRotationGesture, } from "./domEditOverlayGestures"; import type { DomEditGroupPathOffsetCommit } from "./DomEditOverlay"; import { startGesture as _startGesture, startGroupDrag as _startGroupDrag, } from "./domEditOverlayStartGesture"; // Refs are stable across renders; values are read via .current. export type UseDomEditOverlayGesturesOptions = { overlayRef: RefObject; iframeRef: RefObject; boxRef: RefObject; selectionRef: RefObject; overlayRectRef: RefObject; groupOverlayItemsRef: RefObject; gestureRef: RefObject; groupGestureRef: RefObject; blockedMoveRef: RefObject; rafPausedRef: RefObject; suppressNextBoxClickRef: RefObject; setOverlayRect: (next: OverlayRect | null) => void; setGroupOverlayItems: (next: GroupOverlayItem[]) => void; onBlockedMoveRef: RefObject<(selection: DomEditSelection) => void>; onManualDragStartRef: RefObject<(() => void) | undefined>; onPathOffsetCommitRef: RefObject< (s: DomEditSelection, n: { x: number; y: number }) => Promise | void >; onGroupPathOffsetCommitRef: RefObject< (updates: DomEditGroupPathOffsetCommit[]) => Promise | void >; onBoxSizeCommitRef: RefObject< (s: DomEditSelection, n: { width: number; height: number }) => Promise | void >; onRotationCommitRef: RefObject< (s: DomEditSelection, n: { angle: number }) => Promise | void >; onCanvasPointerMoveRef: RefObject< ( e: React.PointerEvent, o?: { preferClipAncestor?: boolean }, ) => DomEditSelection | null >; onCanvasMouseDown: ( e: React.MouseEvent, o?: { preferClipAncestor?: boolean }, ) => void; }; export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) { const setDraftOverlayRect = (next: OverlayRect) => { opts.setOverlayRect(next); }; const restoreGestureOverlayRect = (g: GestureState) => { setDraftOverlayRect({ left: g.originLeft, top: g.originTop, width: g.originWidth, height: g.originHeight, editScaleX: g.editScaleX, editScaleY: g.editScaleY, }); }; const setDraftGroupOverlayItems = (next: GroupOverlayItem[]) => { opts.setGroupOverlayItems(next); }; const restoreGroupPathOffsets = (g: GroupGestureState) => { restoreManualOffsetDragMembers(g.members); setDraftGroupOverlayItems(g.originItems); }; const startGroupDrag = (e: React.PointerEvent) => _startGroupDrag(e, opts); const startGesture = ( kind: GestureKind, e: React.PointerEvent, options?: { selection?: DomEditSelection; rect?: OverlayRect | null }, ) => _startGesture(kind, e, opts, options); const onPointerMove = (e: React.PointerEvent) => { const g = opts.gestureRef.current; const groupG = opts.groupGestureRef.current; const sel = g?.selection ?? opts.selectionRef.current; const box = opts.boxRef.current; const blockedMove = opts.blockedMoveRef.current; if (!blockedMove && !g && !groupG) { opts.onCanvasPointerMoveRef.current(e, { preferClipAncestor: false }); } if (blockedMove && sel) { const dx = e.clientX - blockedMove.startX; const dy = e.clientY - blockedMove.startY; if (!blockedMove.notified && Math.hypot(dx, dy) >= BLOCKED_MOVE_THRESHOLD_PX) { blockedMove.notified = true; opts.suppressNextBoxClickRef.current = true; opts.onBlockedMoveRef.current(sel); } return; } if (groupG) { const dx = e.clientX - groupG.startX; const dy = e.clientY - groupG.startY; setDraftGroupOverlayItems( groupG.originItems.map((item) => ({ ...item, rect: { ...item.rect, left: item.rect.left + dx, top: item.rect.top + dy }, })), ); for (const member of groupG.members) applyManualOffsetDragDraft(member, dx, dy); return; } if (!g || !sel) return; const dx = e.clientX - g.startX; const dy = e.clientY - g.startY; if (g.kind === "rotate") { applyStudioRotationDraft( sel.element, resolveDomEditRotationGesture({ centerX: g.centerX, centerY: g.centerY, startX: g.startX, startY: g.startY, currentX: e.clientX, currentY: e.clientY, actualAngle: g.actualRotation, snap: e.shiftKey, }), ); return; } if (g.kind === "drag") { const nextBoxLeft = g.originLeft + dx; const nextBoxTop = g.originTop + dy; setDraftOverlayRect({ left: nextBoxLeft, top: nextBoxTop, width: g.originWidth, height: g.originHeight, editScaleX: g.editScaleX, editScaleY: g.editScaleY, }); if (box) { box.style.left = `${nextBoxLeft}px`; box.style.top = `${nextBoxTop}px`; } if (g.pathOffsetMember) applyManualOffsetDragDraft(g.pathOffsetMember, dx, dy); } else { if (!box) return; const nextSize = resolveDomEditResizeGesture({ originWidth: g.originWidth, originHeight: g.originHeight, actualWidth: g.actualWidth, actualHeight: g.actualHeight, scaleX: g.editScaleX, scaleY: g.editScaleY, dx, dy, uniform: e.shiftKey, }); applyStudioBoxSizeDraft(sel.element, nextSize); // Re-read BCR after applying dimensions. For elements with a GSAP // scale transform and centered transform-origin the visual top-left // drifts and the visual size diverges from the raw CSS size, so BCR // is the only accurate source for both. const overlayEl = opts.overlayRef.current; const iframe = opts.iframeRef.current; const refreshed = overlayEl && iframe ? toOverlayRect(overlayEl, iframe, sel.element) : null; const overlayLeft = refreshed ? refreshed.left : g.originLeft; const overlayTop = refreshed ? refreshed.top : g.originTop; const overlayWidth = refreshed ? refreshed.width : nextSize.overlayWidth; const overlayHeight = refreshed ? refreshed.height : nextSize.overlayHeight; box.style.left = `${overlayLeft}px`; box.style.top = `${overlayTop}px`; box.style.width = `${overlayWidth}px`; box.style.height = `${overlayHeight}px`; setDraftOverlayRect({ left: overlayLeft, top: overlayTop, width: overlayWidth, height: overlayHeight, editScaleX: g.editScaleX, editScaleY: g.editScaleY, }); } }; const onPointerUp = (e: React.PointerEvent) => { const g = opts.gestureRef.current; const groupG = opts.groupGestureRef.current; const sel = g?.selection ?? opts.selectionRef.current; const box = opts.boxRef.current; opts.blockedMoveRef.current = null; if (groupG) { opts.groupGestureRef.current = null; opts.rafPausedRef.current = false; const dx = e.clientX - groupG.startX; const dy = e.clientY - groupG.startY; if (Math.hypot(dx, dy) < BLOCKED_MOVE_THRESHOLD_PX) { restoreGroupPathOffsets(groupG); opts.suppressNextBoxClickRef.current = true; return; } setDraftGroupOverlayItems( groupG.originItems.map((item) => ({ ...item, rect: { ...item.rect, left: item.rect.left + dx, top: item.rect.top + dy }, })), ); const updates = groupG.members.map((member) => ({ selection: member.selection, next: applyManualOffsetDragCommit(member, dx, dy), })); void Promise.resolve(opts.onGroupPathOffsetCommitRef.current(updates)) .catch(() => { for (const member of groupG.members) { if ( member.gestureToken && isStudioManualEditGestureCurrent(member.element, member.gestureToken) ) restoreStudioPathOffset(member.element, member.initialPathOffset); } }) .finally(() => endManualOffsetDragMembers(groupG.members)); return; } if (!g || !sel) { opts.gestureRef.current = null; opts.rafPausedRef.current = false; return; } opts.gestureRef.current = null; opts.rafPausedRef.current = false; const movedDistance = Math.hypot(e.clientX - g.startX, e.clientY - g.startY); if (g.kind === "drag" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) { restoreStudioPathOffset(sel.element, g.initialPathOffset); endStudioManualEditGesture(sel.element, g.manualEditDragToken); if (box) { box.style.left = `${g.originLeft}px`; box.style.top = `${g.originTop}px`; } restoreGestureOverlayRect(g); opts.suppressNextBoxClickRef.current = true; opts.onCanvasMouseDown(e as unknown as React.MouseEvent, { preferClipAncestor: false, }); return; } if (g.kind === "resize" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) { restoreStudioBoxSize(sel.element, g.initialBoxSize); endStudioManualEditGesture(sel.element, g.manualEditDragToken); if (box) { box.style.width = `${g.originWidth}px`; box.style.height = `${g.originHeight}px`; } restoreGestureOverlayRect(g); opts.suppressNextBoxClickRef.current = true; return; } if (g.kind === "rotate") { const finalRotation = resolveDomEditRotationGesture({ centerX: g.centerX, centerY: g.centerY, startX: g.startX, startY: g.startY, currentX: e.clientX, currentY: e.clientY, actualAngle: g.actualRotation, snap: e.shiftKey, }); if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) { restoreStudioRotation(sel.element, g.initialRotation); endStudioManualEditGesture(sel.element, g.manualEditDragToken); return; } applyStudioRotation(sel.element, finalRotation); void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation)) .catch(() => { if ( g.manualEditDragToken && isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken) ) restoreStudioRotation(sel.element, g.initialRotation); }) .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken)); } else if (g.kind === "drag") { const dx = e.clientX - g.startX; const dy = e.clientY - g.startY; if (!g.pathOffsetMember) return; const finalOffset = applyManualOffsetDragCommit(g.pathOffsetMember, dx, dy); const nextBoxLeft = g.originLeft + dx; const nextBoxTop = g.originTop + dy; setDraftOverlayRect({ left: nextBoxLeft, top: nextBoxTop, width: g.originWidth, height: g.originHeight, editScaleX: g.editScaleX, editScaleY: g.editScaleY, }); if (box) { box.style.left = `${nextBoxLeft}px`; box.style.top = `${nextBoxTop}px`; } void Promise.resolve(opts.onPathOffsetCommitRef.current(sel, finalOffset)) .catch(() => { if ( g.pathOffsetMember?.gestureToken && isStudioManualEditGestureCurrent(sel.element, g.pathOffsetMember.gestureToken) ) restoreStudioPathOffset(sel.element, g.initialPathOffset); }) .finally(() => { if (g.pathOffsetMember) endManualOffsetDragMembers([g.pathOffsetMember]); }); } else { opts.suppressNextBoxClickRef.current = true; const finalSize = readStudioBoxSize(sel.element); applyStudioBoxSize(sel.element, finalSize); void Promise.resolve(opts.onBoxSizeCommitRef.current(sel, finalSize)) .catch(() => { if ( g.manualEditDragToken && isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken) ) restoreStudioBoxSize(sel.element, g.initialBoxSize); }) .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken)); } }; const clearPointerState = (selectionRef: RefObject) => { const groupG = opts.groupGestureRef.current; if (groupG) restoreGroupPathOffsets(groupG); const g = opts.gestureRef.current; const sel = g?.selection ?? selectionRef.current; if (g?.mode === "path-offset" && sel) { restoreStudioPathOffset(sel.element, g.initialPathOffset); endStudioManualEditGesture(sel.element, g.manualEditDragToken); restoreGestureOverlayRect(g); } if (g?.mode === "box-size" && sel) { restoreStudioBoxSize(sel.element, g.initialBoxSize); endStudioManualEditGesture(sel.element, g.manualEditDragToken); restoreGestureOverlayRect(g); } if (g?.mode === "rotation" && sel) { restoreStudioRotation(sel.element, g.initialRotation); endStudioManualEditGesture(sel.element, g.manualEditDragToken); } opts.blockedMoveRef.current = null; opts.groupGestureRef.current = null; opts.gestureRef.current = null; opts.rafPausedRef.current = false; }; return { startGesture, startGroupDrag, onPointerMove, onPointerUp, clearPointerState }; }