import { memo, type Ref, type RefObject, useCallback, useEffect, useId, useState } from 'react'; import { Animated as NativeAnimated, type View, type ViewStyle } from 'react-native'; import { GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedReaction, useAnimatedRef, useSharedValue, withDelay, withSequence, withTiming, } from 'react-native-reanimated'; import { mergeRefs } from '../../../moti/interactions/pressable/merge-refs'; import { useHoldablePointer } from '../../gestures/Holdable/use-holdable-pointer'; import { useHoldBehavior } from '../../gestures/use-drag-behavior'; import { usePressTimeline } from '../../gestures/use-press-timeline'; import { HOLD_ITEM_TRANSFORM_DURATION, IS_WEB } from './constants'; import { useHoldMenuInternal } from './context'; import { HoldItemTwin } from './hold-item-twin'; import type { HoldItemProps } from './hold-menu-types'; import { MeasureMenuWidth } from './measure-menu-width'; import { useHoldItemActivation } from './use-hold-item-activation'; import { useHoldItemDrag } from './use-hold-item-drag'; import { useHoldItemGesture } from './use-hold-item-gesture'; import { useHoldItemMenu } from './use-hold-item-menu'; import { useHoldItemSqueeze } from './use-hold-item-squeeze'; /** The ghost's cosmetics when this item draws its own ghost (no `` above it). */ const GHOST_CLASS = 'z-50 opacity-80'; /** Rendered off-screen so it stays in the DOM for HTML5 `setDragImage` but is never seen. */ const OFFSCREEN_STYLE: ViewStyle = { left: 0, opacity: 0, pointerEvents: 'none', position: 'absolute', top: 0 }; /** * The gesture + portal wrapper — upstream's `HoldItem`, ported to the RNGH v2 * `Gesture` API and Reanimated 4, with the sibling's portal primitive in place * of `@gorhom/portal`. * * The in-place wrapper holds the gesture and hides (opacity → 0) while active; * the permanent portal twin (`HoldItemTwin`) is the lifted copy that travels * with the panel. Activation is measured on the UI thread (`measure()` inside * the gesture's `onStart` worklet) and drives the menu through the provider's * `menuProps` shared value. The squeeze, measurement and gesture layers live in * `use-hold-item-squeeze.ts` / `use-hold-item-activation.ts` / * `use-hold-item-gesture.ts`. * * ## Web * * On web (`IS_WEB`) the interaction is a right-click for `'hold'` — the * wrapper's `contextmenu` handler, which browsers also raise for Shift+F10 and * the ContextMenu key on a focused element, so the keyboard path comes for free * — and a plain `onClick` for `'tap'` / `'double-tap'`. The lift is not * native-only: the twin renders on every platform, so on web the held item * still lifts and travels with the panel exactly as on native. */ // biome-ignore lint/complexity/noExcessiveLinesPerFunction: the item's hook wiring (squeeze, activation, menu, gesture, drag) is one orchestration layer — splitting it would scatter the shared-value plumbing const HoldItemComponent = ({ items, bottom, containerStyles, disableMove, menuAnchorPosition, activateOn, hapticFeedback, actionParams, closeOnTap, longPressMinDurationMs = 150, dragOptions, onHold: onHoldProp, onOpenChange: onOpenChangeProp, disabled = false, testID, accessibilityLabel, accessibilityRole, children, }: HoldItemProps) => { const { state, menuProps, windowSize, rootViewportHeight, rootPageX, rootPageY, safeAreaInsets, rootRef } = useHoldMenuInternal(); const isActive = useSharedValue(false); /** Mirrors `isActive` into React state so the trigger can expose `aria-expanded`. */ const [isMenuOpen, setIsMenuOpen] = useState(false); useAnimatedReaction( () => isActive.value, (active, previous) => { // Skip the on-mount fire (previous === null) and no-op transitions. if (previous === null || active === previous) return; runOnJS(setIsMenuOpen)(active); }, [isActive], ); /** Widest-row content width, reported by `MeasureMenuWidth`'s `onLayout` before the first hold. */ const contentWidth = useSharedValue(0); const handleMeasureWidth = useCallback( (width: number) => { contentWidth.value = width; }, [contentWidth], ); /** * The twin/original handover, 0 = active (the twin is the visible copy), 1 = * released (the in-place item is back). A single shared value drives BOTH * copies, so they can never drift apart — two independent `withDelay` + * `withTiming` animations could resolve on different web frames and leave a * one-frame hole. * * Activation drives this 1 → 0 (the twin takes over) while the in-place item * holds its full opacity underneath — see `useHoldItemSqueeze`, which only * drops out once this hits 0. When the twin stays put it fades in over the * original (a timed cross-fade, so the pair never dims — stacked * semi-transparent layers don't sum to full opacity — and the twin never pops * in); when it travels it shows fully at once, so this snaps to 0 instantly. * Release pins this back to 0, then snaps it 0 → 1 after the twin travels * back, in lockstep with the in-place item. */ const releaseProgress = useSharedValue(1); /** Stable key for the portal twin — generated once per item, never changes. */ const name = `hold-item-${useId()}`; const containerRef = useAnimatedRef(); const { animatedContainerStyle, itemScale, isHold, canCallActivateFunctions, scaleHold, scaleTap, scaleBack } = useHoldItemSqueeze({ activateOn, hapticFeedback, items, state, isActive, releaseProgress, }); const { itemRectY, itemRectX, itemRectWidth, itemRectHeight, transformOrigin, transformValue, didMeasureLayout, activateAnimation, activateFromContextMenu, } = useHoldItemActivation({ containerRef, rootRef, items, actionParams, disableMove, bottom, menuAnchorPosition, menuProps, windowSize, rootViewportHeight, rootPageX, rootPageY, safeAreaInsets, contentWidth, scaleHold, }); useAnimatedReaction( () => isActive.value, (active, previous) => { // Skip the mapper's on-mount fire: `useAnimatedReaction` runs its reaction // once on mount with `previous === null` (the mapper starts dirty). Without // this guard that phantom "release" drives `releaseProgress` 1 → 0 → 1 over // `HOLD_ITEM_TRANSFORM_DURATION`, flashing every twin opaque at (0,0) while // the in-place item hides — the "all files stacked in one spot" bug on first // render. A real activation/deactivation always has a defined `previous`. if (previous === null) return; if (!active) { // Release pins the twin opaque while it travels back, then snaps both // copies on the same frame — no overlap window to dim, no gap to blink. releaseProgress.value = withSequence( withTiming(0, { duration: 0 }), withDelay(HOLD_ITEM_TRANSFORM_DURATION, withTiming(1, { duration: 0 })), ); return; } // The twin takes over the instant the menu activates. When it travels // (`tY !== 0`) it is drawn fully opaque from the first frame, so the // in-place item must drop out on that same frame — no lingering copy left // under the lifted one. When it stays put (`tY === 0`) the twin overlaps // the original, so a timed cross-fade carries the handover: the original // hides only once the twin is fully opaque, so the pair never dims and the // twin never pops in. releaseProgress.value = withTiming(0, { duration: transformValue.value === 0 ? HOLD_ITEM_TRANSFORM_DURATION : 0, }); }, ); // The twin's children only need to exist while the twin is showing — the // lifted copy that takes over on activation and travels back on release. At // rest `releaseProgress` is pinned to 1 (twin opacity 0, in-place item // opaque), so rendering the children there only duplicates their testIDs: on // Android, Detox's `by.id()` reads `view.getTag()`, which ignores the // `importantForAccessibility="no-hide-descendants"` the twin already applies, // so every descendant id matches twice and `.atIndex(0)` resolves to the 0×0 // ghost. Gating on `releaseProgress < 1` keeps the copy mounted only for the // life of the lift, so at rest the id exists once. const [showTwinChildren, setShowTwinChildren] = useState(false); useAnimatedReaction( () => releaseProgress.value < 1, (shouldShow, previous) => { // Skip the on-mount fire (`previous === null`): the predicate is already // false at rest, mirroring the isActive reaction's guard so no phantom // setState runs. if (previous === null || shouldShow === previous) return; runOnJS(setShowTwinChildren)(shouldShow); }, [releaseProgress], ); // Pre-mount the twin's children the moment the menu opens, on the JS thread — // a full squeeze ahead of the twin's fade-in/travel. Relying on the reaction // above alone would mount them only after `releaseProgress` first drops below // 1, and the travelling twin (opacity snaps to 1 instantly) would be empty for // that first frame while the in-place item is already hidden. // // The mount is deferred by one frame: it runs on the JS thread as a direct // result of the LongPress gesture's `onStart` (via `runOnJS`), so committing // a new native subtree — and starting its Reanimated enter animations — in // the same tick as the gesture's touch dispatch is a native-crash risk on // Android (Fabric mounting views while RNGH is still processing the touch // that activated the hold). One frame later the dispatch window has closed; // the twin's 150 ms fade-in never notices the 16 ms delay. const handleWillOpen = useCallback(() => { requestAnimationFrame(() => setShowTwinChildren(true)); }, []); const webHold = IS_WEB && isHold; const { handleActivate, handleOpen, closeMenu } = useHoldItemMenu({ items, disabled, onHold: onHoldProp, onOpenChange: onOpenChangeProp, onWillOpen: handleWillOpen, state, isActive, didMeasureLayout, scaleBack, }); const { gesture, handleContextMenu, handleWebTap, handleWebHold, onActivate } = useHoldItemGesture({ webHold, isHold, longPressMinDurationMs, activateOn, disabled, canCallActivateFunctions, didMeasureLayout, activateAnimation, isActive, scaleHold, scaleTap, scaleBack, activateFromContextMenu, onActivateJS: handleActivate, onOpenJS: handleOpen, }); const drag = useHoldItemDrag({ dragOptions, disabled, testID, longPressMinDurationMs, activate: onActivate, onActivate: handleActivate, closeMenu, }); // A web `'hold'` without a drag has no gesture and no `contextmenu` on a touch // screen — so give it the same touch long-press a `` has, gated to // exactly that shape. The drag path above already carries the hold when // `dragOptions` is set; this is the plain-hold half. Touch only (`cursorMode` // off): a mouse still uses the right-click `contextmenu` handler. const { timeline: webHoldTimeline } = usePressTimeline({ canDrag: false, onHold: handleWebHold, track: false, tuning: useHoldBehavior({ holdDelay: longPressMinDurationMs }), }); useHoldablePointer({ cursorMode: false, enabled: webHold && !disabled && dragOptions === undefined, // biome-ignore lint/plugin: the AnimatedRef resolves to the same DOM node; the cast only reconciles its type with `RefObject` nodeRef: containerRef as unknown as RefObject, timeline: webHoldTimeline, }); // The drag's host ref and the menu's measurement ref must point at the same node. const rootProps = drag.getRootProps(); // biome-ignore lint/plugin: ts/no-as-cast — an `AnimatedRef` is callable, so it works as a React ref at runtime; the cast only reconciles its type with `Ref` const mergedRef = mergeRefs([containerRef as unknown as Ref, rootProps.ref]); const previewNode = dragOptions?.preview ?? children; // `useDraggable` seeded `previewRef` from `dragOptions?.preview` alone, which is // `null` for a single-item drag (the scope returns nothing for fewer than two // paths) — the `` ghost would then draw nothing. Point the ref at // the same fallback the offscreen node uses, exactly as `HoldDraggable` does, so // a single-item drag still lifts a copy of the item. drag.previewRef.current = previewNode; // `preview` is `undefined` when there is no drag at all, and `null` for a // single-item drag (the scope returns nothing for fewer than two paths) — so // test it for *presence* (neither null nor undefined). A missing preview must // not leave an always-mounted offscreen ghost behind: it would render a second // copy of `children` that role/name queries match alongside the real entry. const drawsPreview = drag.showGhost || (dragOptions?.preview !== null && dragOptions?.preview !== undefined); // Web `'hold'` right-click: a NATIVE `contextmenu` listener, not React's // synthetic `onContextMenu`. React 18 delegates synthetic events to the root, // so an `onContextMenu` fires only after the event has already bubbled past an // ancestor's native listener — the background's scroll-container `contextmenu` // handler — too late to stop it opening its own menu. A native listener on this // wrapper runs in the bubble phase before every ancestor's. // biome-ignore lint/plugin: react/no-use-effect — a native DOM listener has no React/RN prop equivalent; it must be wired imperatively on the resolved node useEffect(() => { if (!webHold || disabled) return; // biome-ignore lint/plugin: ts/no-as-cast — the AnimatedRef resolves to the DOM node on web; the cast only reconciles its type with `HTMLElement` const node = containerRef.current as unknown as HTMLElement | null; if (!node?.addEventListener) return; node.addEventListener('contextmenu', handleContextMenu); return () => node.removeEventListener('contextmenu', handleContextMenu); }, [webHold, disabled, containerRef, handleContextMenu]); // A focusable, menu-opening trigger. `hasMenu` gates the menu semantics: an // inert item (empty `items`) is not a control. const hasMenu = !disabled && items.length > 0; // The wrapper is a button only when the consumer opts in. Most shipped callers // wrap a `