import { computed, nextTick, onBeforeUnmount, onMounted, type Ref, ref, watch } from 'vue' import { useRoute } from 'vue-router' import { type CreationPath, type OnboardingMode, onboardingTargetSelector, onboardingTours, type StepDestination, } from './onboardingConfig' type TourEvents = { complete: () => void skip: () => void closeSettings: () => void } const controlSpotlightPadding = 3 const missingTargetRetryLimit = 8 const missingTargetRetryDelay = 250 export function useOnboardingTour( visible: Ref, mode: Ref, events: TourEvents, ) { const route = useRoute() const stepIndex = ref(0) const targetRect = ref(null) const bubbleElement = ref() const bubbleSize = ref({ width: 512, height: 160 }) const waitingForRoute = ref(false) const creationPath = ref() const targetElement = ref() let targetObserver: ResizeObserver | undefined let bubbleObserver: ResizeObserver | undefined let targetRetryTimer: ReturnType | undefined let advanceTimer: ReturnType | undefined let unlockTimer: ReturnType | undefined let targetRetryCount = 0 let transitionLocked = false const steps = computed(() => onboardingTours[mode.value]) const step = computed(() => steps.value[stepIndex.value]) const isWelcomeStep = computed(() => step.value.id === 'welcome') const isDialogueStep = computed( () => !!step.value.targetId && (step.value.spotlight !== 'control' || !targetRect.value), ) const controlSpotlightStyle = computed(() => { if (!targetRect.value) return {} const rect = targetRect.value return { left: `${Math.max(0, rect.left - controlSpotlightPadding)}px`, top: `${Math.max(0, rect.top - controlSpotlightPadding)}px`, width: `${rect.width + controlSpotlightPadding * 2}px`, height: `${rect.height + controlSpotlightPadding * 2}px`, } }) const bubblePlacement = computed(() => { if (!targetRect.value || isDialogueStep.value) return { direction: 'center', style: {} } const rect = targetRect.value const safeInset = 16 const safeTop = 48 const bubbleWidth = Math.min(bubbleSize.value.width, window.innerWidth - safeInset * 2) const bubbleHeight = Math.min(bubbleSize.value.height, window.innerHeight - safeTop - safeInset) const gap = 20 const positions = [ { direction: 'right', left: rect.right + gap, top: rect.top + rect.height / 2 - bubbleHeight / 2, }, { direction: 'bottom', left: rect.left + rect.width / 2 - bubbleWidth / 2, top: rect.bottom + gap, }, { direction: 'left', left: rect.left - bubbleWidth - gap, top: rect.top + rect.height / 2 - bubbleHeight / 2, }, { direction: 'top', left: rect.left + rect.width / 2 - bubbleWidth / 2, top: rect.top - bubbleHeight - gap, }, ] const position = positions.find( (candidate) => candidate.left >= safeInset && candidate.top >= safeTop && candidate.left + bubbleWidth <= window.innerWidth - safeInset && candidate.top + bubbleHeight <= window.innerHeight - safeInset, ) ?? positions[0] return { direction: position.direction, style: { left: `${Math.min( Math.max(safeInset, position.left), window.innerWidth - bubbleWidth - safeInset, )}px`, top: `${Math.min( Math.max(safeTop, position.top), window.innerHeight - bubbleHeight - safeInset, )}px`, }, } }) function clearTargetTracking() { targetObserver?.disconnect() targetObserver = undefined if (targetRetryTimer) clearTimeout(targetRetryTimer) targetRetryTimer = undefined } function clearModalReservation() { document.body.classList.remove('onboarding-reserve-dialogue-space') document.body.style.removeProperty('--onboarding-dialogue-reserved-space') } function updateModalReservation() { const targetIsInModal = !!targetElement.value?.closest('[role="dialog"]') if (!visible.value || !isDialogueStep.value || !targetIsInModal) { clearModalReservation() return } document.body.classList.add('onboarding-reserve-dialogue-space') document.body.style.setProperty( '--onboarding-dialogue-reserved-space', `${Math.ceil(bubbleSize.value.height)}px`, ) } function updateBubbleSize() { if (!bubbleElement.value) return const { width, height } = bubbleElement.value.getBoundingClientRect() bubbleSize.value = { width, height } updateModalReservation() } function scheduleMissingTargetRetry(stepId: string) { if (targetRetryCount >= missingTargetRetryLimit) { // 超过重试上限:inspect 步(介绍性步骤)不自动跳过, // 保持无锚点状态,让气泡停靠底部等用户手动继续。 if (step.value.interaction === 'inspect') { targetElement.value = undefined targetRect.value = null clearModalReservation() return } void advance() return } targetRetryCount++ targetRetryTimer = setTimeout(() => { if (visible.value && step.value.id === stepId && !targetRect.value) updateTarget() }, missingTargetRetryDelay) } function updateTarget() { clearTargetTracking() if (!visible.value || !step.value.targetId) { targetElement.value = undefined targetRect.value = null clearModalReservation() return } const target = document.querySelector( onboardingTargetSelector(step.value.targetId), ) const rect = target?.getBoundingClientRect() if (!target || !rect || rect.width < 1 || rect.height < 1) { targetElement.value = undefined targetRect.value = null clearModalReservation() scheduleMissingTargetRetry(step.value.id) return } targetRetryCount = 0 targetElement.value = target const updateRect = () => { targetRect.value = target.getBoundingClientRect() } updateRect() targetObserver = new ResizeObserver(updateRect) targetObserver.observe(target) updateModalReservation() requestAnimationFrame(updateRect) } function goTo(destination: StepDestination) { if (destination === 'complete') { events.complete() return } const destinationIndex = steps.value.findIndex((candidate) => candidate.id === destination) if (destinationIndex === -1) { events.complete() return } stepIndex.value = destinationIndex } async function advance() { if (transitionLocked) return transitionLocked = true const destination = creationPath.value ? step.value.nextByCreationPath?.[creationPath.value] : step.value.next if (destination) { goTo(destination) scheduleUnlock() return } if (stepIndex.value === steps.value.length - 1) { events.complete() scheduleUnlock() return } if (step.value.closeSettingsAfter) events.closeSettings() stepIndex.value++ await nextTick() updateTarget() scheduleUnlock() } function scheduleUnlock() { if (unlockTimer) clearTimeout(unlockTimer) unlockTimer = setTimeout(() => { transitionLocked = false unlockTimer = undefined }, 180) } function scheduleDestination(destination?: StepDestination) { if (advanceTimer || transitionLocked) return if (destination) transitionLocked = true advanceTimer = setTimeout(() => { advanceTimer = undefined if (destination) { goTo(destination) scheduleUnlock() } else { void advance() } }, 150) } function handleManualClick() { if (step.value.interaction === 'manual') void advance() } function handleBranchClick(target: Element) { if (!step.value.branchByTarget) return false for (const [targetId, branch] of Object.entries(step.value.branchByTarget)) { if (!target.closest(onboardingTargetSelector(targetId))) continue creationPath.value = branch.creationPath scheduleDestination(branch.next) return true } return false } function handleDocumentClick(event: MouseEvent) { if (!visible.value) return const clickedElement = event.target instanceof Element ? event.target : null // 跳过/关闭按钮由其自身处理,不在全局点击里推进 if (clickedElement?.closest('[data-onboarding-skip]')) return // inspect 步:点击任意位置(含导览气泡本身)都继续下一步 if (step.value.interaction === 'inspect') { event.preventDefault() event.stopImmediatePropagation() void advance() return } // 非 inspect 步:点在导览 UI 自身时不处理 if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return if (!step.value.targetId || !['navigate', 'activate'].includes(step.value.interaction)) return if (clickedElement && handleBranchClick(clickedElement)) return if (!targetElement.value?.contains(event.target as Node)) return if (step.value.expectedPath && !route.path.startsWith(step.value.expectedPath)) { waitingForRoute.value = true return } scheduleDestination() } function handleKeydown(event: KeyboardEvent) { if (!visible.value || event.key !== 'Escape') return event.preventDefault() events.skip() } watch( () => route.path, (path) => { if (!waitingForRoute.value || !step.value.expectedPath) return if (!path.startsWith(step.value.expectedPath)) return waitingForRoute.value = false void advance() }, ) watch(visible, async (isVisible) => { if (isVisible) { stepIndex.value = 0 waitingForRoute.value = false creationPath.value = undefined targetRetryCount = 0 await nextTick() updateBubbleSize() if (bubbleElement.value) bubbleObserver?.observe(bubbleElement.value) } else { bubbleObserver?.disconnect() clearModalReservation() } updateTarget() }) watch(step, async () => { if (!visible.value) return targetRetryCount = 0 await nextTick() updateBubbleSize() updateTarget() }) watch(mode, () => { stepIndex.value = 0 }) onMounted(() => { document.addEventListener('click', handleDocumentClick, true) document.addEventListener('keydown', handleKeydown) window.addEventListener('resize', updateTarget) window.addEventListener('scroll', updateTarget, true) bubbleObserver = new ResizeObserver(updateBubbleSize) if (bubbleElement.value) bubbleObserver.observe(bubbleElement.value) }) onBeforeUnmount(() => { clearTargetTracking() clearModalReservation() bubbleObserver?.disconnect() if (advanceTimer) clearTimeout(advanceTimer) if (unlockTimer) clearTimeout(unlockTimer) document.removeEventListener('click', handleDocumentClick, true) document.removeEventListener('keydown', handleKeydown) window.removeEventListener('resize', updateTarget) window.removeEventListener('scroll', updateTarget, true) }) return { advance, bubbleElement, bubblePlacement, controlSpotlightStyle, handleManualClick, isDialogueStep, isWelcomeStep, step, stepIndex, steps, targetRect, } }