import { type RefObject, useEffect, useRef } from 'react'; import type { EmitEvent } from '~/hooks/useEventEmitter'; import type { MergedProps } from '~/hooks/useTourEngine'; import { EVENTS, LIFECYCLE, STATUS } from '~/literals'; import { treeChanges } from '~/modules/changes'; import { getElement, getScrollParent, getScrollTargetToCenter, getScrollTo, hasPosition, scrollDocument, scrollTo, } from '~/modules/dom'; import { log } from '~/modules/helpers'; import createStore from '~/modules/store'; import type { StoreState } from '~/modules/store'; import type { Lifecycle, PositionData, StepMerged } from '~/types'; interface UseScrollEffectParams { emitEvent: EmitEvent; previousState: StoreState | undefined; props: MergedProps; state: StoreState; step: StepMerged | null; store: RefObject>; } function adjustForPlacement( scrollY: number, options: { beaconPosition: PositionData | null; lifecycle: Lifecycle; scrollOffset: number; step: StepMerged; }, ): number { const { beaconPosition, lifecycle, scrollOffset, step } = options; if (step.scrollTarget || step.spotlightTarget) { return Math.max(0, scrollY); } let adjustedY = scrollY - step.spotlightPadding.top; if (lifecycle === LIFECYCLE.BEACON_BEFORE && beaconPosition?.placement) { const y = getMainAxisOffset(beaconPosition); if (!['bottom'].includes(beaconPosition.placement)) { adjustedY += Math.floor(y - scrollOffset); } } else if (lifecycle === LIFECYCLE.TOOLTIP_BEFORE) { const { placement } = step; if (placement === 'top') { const floaterElement = document.querySelector('.react-joyride__floater'); const floaterHeight = floaterElement?.getBoundingClientRect().height ?? 0; const arrowSize = step.floatingOptions?.hideArrow ? 0 : step.arrowSize; const gap = step.offset + step.spotlightPadding.top + arrowSize; adjustedY -= floaterHeight + gap; } else if (placement === 'left' || placement === 'right') { const floaterElement = document.querySelector('.react-joyride__floater'); const floaterHeight = floaterElement?.getBoundingClientRect().height ?? 0; const targetEl = getElement(step.target); const targetHeight = targetEl?.getBoundingClientRect().height ?? 0; // After base scroll, the target center sits at this distance from viewport top const targetCenterY = scrollOffset + step.spotlightPadding.top + targetHeight / 2; // The floater is centered on the target, so its top edge would be here const floaterTopY = targetCenterY - floaterHeight / 2; if (floaterTopY < scrollOffset) { adjustedY -= scrollOffset - floaterTopY; } } } return Math.max(0, adjustedY); } function getMainAxisOffset(data: PositionData): number { const offsetData = data.middlewareData?.offset as { x: number; y: number } | undefined; if (!offsetData) { return 0; } return ['left', 'right'].some(p => data.placement.startsWith(p)) ? offsetData.x : offsetData.y; } export default function useScrollEffect({ emitEvent, previousState, props, state, step, store, }: UseScrollEffectParams): void { const { index, lifecycle, positioned, scrolling, status } = state; const cancelScrollRef = useRef<(() => void) | null>(null); const stateRef = useRef(state); const previousStateRef = useRef(previousState); const propsRef = useRef(props); const stepRef = useRef(step); stateRef.current = state; previousStateRef.current = previousState; propsRef.current = props; stepRef.current = step; useEffect(() => { return () => { cancelScrollRef.current?.(); }; }, []); useEffect(() => { if (!previousStateRef.current || !stepRef.current) { return; } const { hasChangedTo } = treeChanges(stateRef.current, previousStateRef.current); const currentStep = stepRef.current; const { debug } = propsRef.current; const { scrollDuration } = currentStep; const isBeforePhase = lifecycle === LIFECYCLE.BEACON_BEFORE || lifecycle === LIFECYCLE.TOOLTIP_BEFORE; if ( status === STATUS.RUNNING && isBeforePhase && scrolling && hasChangedTo('positioned', true) ) { const target = getElement( currentStep.scrollTarget ?? currentStep.spotlightTarget ?? currentStep.target, ); const beaconPosition = store.current.getPositionData('beacon'); const scrollParent = getScrollParent(target); const hasCustomScroll = scrollParent ? !scrollParent.isSameNode(scrollDocument()) : false; cancelScrollRef.current?.(); const handleScroll = async () => { if (hasCustomScroll && !hasPosition(scrollParent as HTMLElement)) { const pageElement = scrollDocument(); const pageScrollY = getScrollTargetToCenter(scrollParent as Element); const pageScrollData = { initial: pageElement.scrollTop, target: pageScrollY, element: pageElement, duration: scrollDuration, }; emitEvent(EVENTS.SCROLL_START, currentStep, { scroll: pageScrollData }); const { cancel: cancelPage, promise: pagePromise } = scrollTo(pageScrollY, { element: pageElement, duration: scrollDuration, }); cancelScrollRef.current = cancelPage; await pagePromise; emitEvent(EVENTS.SCROLL_END, currentStep, { scroll: pageScrollData }); } const baseScrollY = Math.floor(getScrollTo(target, currentStep.scrollOffset)) || 0; const scrollY = hasCustomScroll ? baseScrollY : adjustForPlacement(baseScrollY, { beaconPosition, lifecycle, scrollOffset: currentStep.scrollOffset, step: currentStep, }); log( debug, `step:${index}`, 'scroll', hasCustomScroll ? 'custom' : 'document', `${baseScrollY} → ${scrollY}`, ); const scrollElement = scrollParent as Element; const scrollData = { initial: scrollElement.scrollTop, target: scrollY, element: scrollElement, duration: scrollDuration, }; emitEvent(EVENTS.SCROLL_START, currentStep, { scroll: scrollData }); const { cancel, promise } = scrollTo(scrollY, { element: scrollElement, duration: scrollDuration, }); cancelScrollRef.current = cancel; await promise; emitEvent(EVENTS.SCROLL_END, currentStep, { scroll: scrollData }); store.current.updateState({ scrolling: false }); }; handleScroll().catch(() => { store.current.updateState({ scrolling: false }); }); } }, [emitEvent, index, lifecycle, positioned, scrolling, status, store]); }