diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index 249d7b7e98..8481ee6b9b 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -23,6 +23,7 @@ import { type GestureUpdateEvent, type PanGestureHandlerEventPayload, } from 'react-native-gesture-handler' +import {KeyboardEvents} from 'react-native-keyboard-controller' import Animated, { clamp, interpolate, @@ -35,6 +36,7 @@ import Animated, { type WithSpringConfig, } from 'react-native-reanimated' import { + type EdgeInsets, useSafeAreaFrame, useSafeAreaInsets, } from 'react-native-safe-area-context' @@ -110,6 +112,7 @@ export function Root({children}: {children: React.ReactNode}) { const playHaptic = useHaptics() const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full') const [measurement, setMeasurement] = useState(null) + const returnLocationSV = useSharedValue<{x: number; y: number} | null>(null) const animationSV = useSharedValue(0) const translationSV = useSharedValue(0) const isFocused = useIsFocused() @@ -142,6 +145,7 @@ export function Root({children}: {children: React.ReactNode}) { ({ isOpen: !!measurement && isFocused, measurement, + returnLocationSV, animationSV, translationSV, mode, @@ -149,6 +153,8 @@ export function Root({children}: {children: React.ReactNode}) { setMeasurement(evt) setMode(mode) animationSV.set(withSpring(1, SPRING_IN)) + // reset return location + returnLocationSV.set(null) }, close: () => { animationSV.set( @@ -156,6 +162,9 @@ export function Root({children}: {children: React.ReactNode}) { if (finished) { hoverablesSV.set({}) translationSV.set(0) + // note: return location has to be reset on open, + // rather than on close, otherwise there's a flicker + // where the reanimated update is faster than the react render runOnJS(onCompletedClose)() } }), @@ -194,6 +203,7 @@ export function Root({children}: {children: React.ReactNode}) { }) satisfies ContextType, [ measurement, + returnLocationSV, setMeasurement, onCompletedClose, isFocused, @@ -225,7 +235,7 @@ export function Root({children}: {children: React.ReactNode}) { export function Trigger({children, label, contentLabel, style}: TriggerProps) { const context = useContextMenuContext() const playHaptic = useHaptics() - const {top: topInset} = useSafeAreaInsets() + const insets = useSafeAreaInsets() const ref = useRef(null) const isFocused = useIsFocused() const [image, setImage] = useState(null) @@ -237,23 +247,8 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) { const open = useNonReactiveCallback( async (mode: 'full' | 'auxiliary-only') => { playHaptic() - Keyboard.dismiss() const [measurement, capture] = await Promise.all([ - new Promise(resolve => { - ref.current?.measureInWindow((x, y, width, height) => - resolve({ - x, - y: - y + - platform({ - default: 0, - android: topInset, // not included in measurement - }), - width, - height, - }), - ) - }), + measureView(ref.current, insets), captureRef(ref, {result: 'data-uri'}).catch(err => { logger.error(err instanceof Error ? err : String(err), { message: 'Failed to capture image of context menu trigger', @@ -262,16 +257,45 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) { return '' }), ]) + Keyboard.dismiss() setImage(capture) - setPendingMeasurement({measurement, mode}) + if (measurement) { + setPendingMeasurement({measurement, mode}) + } }, ) + // after keyboard hides, the position might change - set a return location + useEffect(() => { + if (context.isOpen && context.measurement) { + const hide = KeyboardEvents.addListener('keyboardDidHide', () => { + measureView(ref.current, insets) + .then(newMeasurement => { + if (!newMeasurement || !context.measurement) return + if ( + newMeasurement.x !== context.measurement.x || + newMeasurement.y !== context.measurement.y + ) { + context.returnLocationSV.set({ + x: newMeasurement.x, + y: newMeasurement.y, + }) + } + }) + .catch(() => {}) + }) + + return () => { + hide.remove() + } + } + }, [context, insets]) + const doubleTapGesture = useMemo(() => { return Gesture.Tap() .numberOfTaps(2) .hitSlop(HITSLOP_10) - .onEnd(() => open('auxiliary-only')) + .onEnd(() => void open('auxiliary-only')) .runOnJS(true) }, [open]) @@ -360,6 +384,7 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) { animation={animationSV} image={image} measurement={measurement} + returnLocation={context.returnLocationSV} onDisplay={() => { if (pendingMeasurement) { context.open( @@ -384,6 +409,7 @@ function TriggerClone({ animation, image, measurement, + returnLocation, onDisplay, label, }: { @@ -391,14 +417,29 @@ function TriggerClone({ animation: SharedValue image: string measurement: Measurement + returnLocation: SharedValue<{x: number; y: number} | null> onDisplay: () => void label: string }) { const {_} = useLingui() - const animatedStyles = useAnimatedStyle(() => ({ - transform: [{translateY: translation.get() * animation.get()}], - })) + const animatedStyles = useAnimatedStyle(() => { + const anim = animation.get() + const ret = returnLocation.get() + const returnOffsetX = ret + ? interpolate(anim, [0, 1], [ret.x - measurement.x, 0]) + : 0 + const returnOffsetY = ret + ? interpolate(anim, [0, 1], [ret.y - measurement.y, 0]) + : 0 + + return { + transform: [ + {translateX: returnOffsetX}, + {translateY: translation.get() * anim + returnOffsetY}, + ], + } + }) const handleError = useCallback( (evt: ImageErrorEventData) => { @@ -874,6 +915,25 @@ export function Divider() { ) } +function measureView(view: View | null, insets: EdgeInsets) { + if (!view) return Promise.resolve(null) + return new Promise(resolve => { + view?.measureInWindow((x, y, width, height) => + resolve({ + x, + y: + y + + platform({ + default: 0, + android: insets.top, // not included in measurement + }), + width, + height, + }), + ) + }) +} + function getHoveredHoverable( evt: | GestureStateChangeEvent diff --git a/src/components/ContextMenu/types.ts b/src/components/ContextMenu/types.ts index bda2245420..7d4f3019a8 100644 --- a/src/components/ContextMenu/types.ts +++ b/src/components/ContextMenu/types.ts @@ -49,6 +49,7 @@ export type ContextType = { translationSV: SharedValue mode: 'full' | 'auxiliary-only' open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void + returnLocationSV: SharedValue<{x: number; y: number} | null> close: () => void registerHoverable: ( id: string,