387 lines
11 KiB
TypeScript
387 lines
11 KiB
TypeScript
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<boolean>,
|
|
mode: Ref<OnboardingMode>,
|
|
events: TourEvents,
|
|
) {
|
|
const route = useRoute()
|
|
const stepIndex = ref(0)
|
|
const targetRect = ref<DOMRect | null>(null)
|
|
const bubbleElement = ref<HTMLElement>()
|
|
const bubbleSize = ref({ width: 512, height: 160 })
|
|
const waitingForRoute = ref(false)
|
|
const creationPath = ref<CreationPath>()
|
|
const targetElement = ref<HTMLElement>()
|
|
let targetObserver: ResizeObserver | undefined
|
|
let bubbleObserver: ResizeObserver | undefined
|
|
let targetRetryTimer: ReturnType<typeof setTimeout> | undefined
|
|
let advanceTimer: ReturnType<typeof setTimeout> | undefined
|
|
let unlockTimer: ReturnType<typeof setTimeout> | 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<HTMLElement>(
|
|
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,
|
|
}
|
|
}
|