import { useCallback, useRef } from "react"; import { liveTime, usePlayerStore } from "../player"; import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers"; import { STUDIO_PREVIEW_SELECTION_ENABLED } from "../components/editor/manualEditingAvailability"; import { type DomEditSelection } from "../components/editor/domEditing"; // ── Types ── export interface UsePreviewInteractionParams { captionEditMode: boolean; compositionLoading: boolean; previewIframeRef: React.MutableRefObject; showToast: (message: string, tone?: "error" | "info") => void; // From useDomSelection applyDomSelection: ( selection: DomEditSelection | null, options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean }, ) => void; resolveDomSelectionFromPreviewPoint: ( clientX: number, clientY: number, options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean }, ) => Promise; resolveAllDomSelectionsFromPreviewPoint: ( clientX: number, clientY: number, ) => Promise; updateDomEditHoverSelection: (selection: DomEditSelection | null) => void; onClickToSource?: (selection: DomEditSelection) => void; } interface ClickCycleState { x: number; y: number; candidates: DomEditSelection[]; index: number; at: number; } const CYCLE_RADIUS_PX = 6; const CYCLE_WINDOW_MS = 600; // ── Hook ── export function usePreviewInteraction({ captionEditMode, compositionLoading, previewIframeRef, showToast, applyDomSelection, resolveDomSelectionFromPreviewPoint, resolveAllDomSelectionsFromPreviewPoint, updateDomEditHoverSelection, onClickToSource, }: UsePreviewInteractionParams) { const cycleRef = useRef(null); const handlePreviewCanvasMouseDown = useCallback( // fallow-ignore-next-line complexity async (e: React.MouseEvent, options?: { preferClipAncestor?: boolean }) => { if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return; const now = Date.now(); const prev = cycleRef.current; const dx = prev ? e.clientX - prev.x : Infinity; const dy = prev ? e.clientY - prev.y : Infinity; const sameSpot = prev !== null && Math.sqrt(dx * dx + dy * dy) < CYCLE_RADIUS_PX && now - prev.at < CYCLE_WINDOW_MS; if (e.shiftKey) { // Additive selection — no cycling cycleRef.current = null; const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, { preferClipAncestor: options?.preferClipAncestor ?? false, }); if (!nextSelection) return; e.preventDefault(); e.stopPropagation(); applyDomSelection(nextSelection, { additive: true }); return; } if (sameSpot && prev) { // Cycle to next candidate in z-stack const nextIndex = (prev.index + 1) % prev.candidates.length; const nextSel = prev.candidates[nextIndex]; cycleRef.current = { ...prev, index: nextIndex, at: now }; e.preventDefault(); e.stopPropagation(); applyDomSelection(nextSel); return; } // Fresh click — resolve topmost element const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, { preferClipAncestor: options?.preferClipAncestor ?? false, }); if (!nextSelection) { cycleRef.current = null; applyDomSelection(null, { revealPanel: false }); return; } e.preventDefault(); e.stopPropagation(); applyDomSelection(nextSelection); if (!e.shiftKey && e.altKey && onClickToSource) { onClickToSource(nextSelection); } // Resolve all stacked candidates so a subsequent click at the same // position can cycle to the next layer (issues #1124, #1125). const all = await resolveAllDomSelectionsFromPreviewPoint(e.clientX, e.clientY); cycleRef.current = all.length > 1 ? { x: e.clientX, y: e.clientY, candidates: all, index: 0, at: now } : null; }, [ applyDomSelection, captionEditMode, compositionLoading, onClickToSource, resolveAllDomSelectionsFromPreviewPoint, resolveDomSelectionFromPreviewPoint, ], ); const handlePreviewCanvasPointerMove = useCallback( // fallow-ignore-next-line complexity async (e: React.PointerEvent, options?: { preferClipAncestor?: boolean }) => { if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) { updateDomEditHoverSelection(null); return null; } const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, { preferClipAncestor: options?.preferClipAncestor ?? false, skipSourceProbe: true, }); updateDomEditHoverSelection(nextSelection); return nextSelection; }, [ captionEditMode, compositionLoading, resolveDomSelectionFromPreviewPoint, updateDomEditHoverSelection, ], ); const handlePreviewCanvasPointerLeave = useCallback(() => { updateDomEditHoverSelection(null); }, [updateDomEditHoverSelection]); const handleBlockedDomMove = useCallback( (selection: DomEditSelection) => { showToast( selection.capabilities.reasonIfDisabled ?? "This element can't be adjusted directly from the preview.", "info", ); }, [showToast], ); const handleDomManualDragStart = useCallback(() => { const pausedTime = pauseStudioPreviewPlayback(previewIframeRef.current); const playerStore = usePlayerStore.getState(); playerStore.setIsPlaying(false); if (pausedTime != null) { playerStore.setCurrentTime(pausedTime); liveTime.notify(pausedTime); } }, [previewIframeRef]); return { handlePreviewCanvasMouseDown, handlePreviewCanvasPointerMove, handlePreviewCanvasPointerLeave, handleBlockedDomMove, handleDomManualDragStart, }; }