diff --git a/packages/components/tour/demo/CustomMask.md b/packages/components/tour/demo/CustomMask.md index b54d9f034..573818128 100644 --- a/packages/components/tour/demo/CustomMask.md +++ b/packages/components/tour/demo/CustomMask.md @@ -7,7 +7,7 @@ title: ## zh -通过 `mask.color` 以及 `mask.class` 自定义遮罩。 +通过 `mask.color`, `mask.container` 以及 `mask.class` 自定义遮罩。 ## en diff --git a/packages/components/tour/demo/CustomMask.vue b/packages/components/tour/demo/CustomMask.vue index 7eb4875fe..3ce9b40d2 100644 --- a/packages/components/tour/demo/CustomMask.vue +++ b/packages/components/tour/demo/CustomMask.vue @@ -14,11 +14,7 @@ - + diff --git a/packages/components/tour/src/Mask.tsx b/packages/components/tour/src/Mask.tsx index 79a704626..6502617fe 100644 --- a/packages/components/tour/src/Mask.tsx +++ b/packages/components/tour/src/Mask.tsx @@ -14,7 +14,16 @@ import { tourToken } from './token' export default defineComponent({ setup() { const { globalHashId, hashId } = useThemeToken('tour') - const { mergedPrefixCls, mergedProps, maskPath, maskAttrs, maskClass, maskStyle } = inject(tourToken)! + const { + mergedPrefixCls, + mergedProps, + maskPath, + maskOutlinePath, + maskAttrs, + maskClass, + maskStyle, + maskOutlineStyle, + } = inject(tourToken)! const classes = computed(() => { const prefixCls = `${mergedPrefixCls.value}-mask` @@ -32,7 +41,14 @@ export default defineComponent({ return () => { return ( - + + {maskOutlinePath.value && ( + + )} ) } diff --git a/packages/components/tour/src/composables/useActiveStep.ts b/packages/components/tour/src/composables/useActiveStep.ts index 59ea925e1..f62145dd3 100644 --- a/packages/components/tour/src/composables/useActiveStep.ts +++ b/packages/components/tour/src/composables/useActiveStep.ts @@ -12,7 +12,7 @@ import type { TourLocale } from '@idux/components/locales' import { type ComputedRef, watch } from 'vue' -import { isFunction, isNumber, isString } from 'lodash-es' +import { isFunction, isNumber, isString, merge } from 'lodash-es' import { convertElement, useState } from '@idux/cdk/utils' @@ -43,7 +43,7 @@ export function useActiveStep( } const gap = step.gap ?? props.gap - const mergedGap = isNumber(gap) ? { offset: gap } : gap + const mergedGap = merge({ ...props.gap }, isNumber(gap) ? { offset: gap } : gap) const target = async () => { if (!step.target) { @@ -116,16 +116,12 @@ export function useActiveStep( } watch( - [activeIndex, () => mergedProps.value.steps], - ([current, steps], [, preSteps]) => { - if (current === activeStep.value?.index && steps === preSteps) { - return - } - + [activeIndex, () => mergedProps.value.steps, mergedProps], + ([current]) => { destroySteps() pushCurrentUpdate(current) }, - { immediate: true, flush: 'post' }, + { immediate: true, flush: 'post', deep: true }, ) return activeStep diff --git a/packages/components/tour/src/composables/useMask.ts b/packages/components/tour/src/composables/useMask.ts index b7fc9d7cc..2e9818926 100644 --- a/packages/components/tour/src/composables/useMask.ts +++ b/packages/components/tour/src/composables/useMask.ts @@ -28,9 +28,11 @@ const animateDuration = 200 export interface MaskContext { maskPath: ComputedRef + maskOutlinePath: ComputedRef maskAttrs: ComputedRef maskClass: ComputedRef maskStyle: ComputedRef + maskOutlineStyle: ComputedRef isAnimating: ComputedRef onAnimateEnd: (cb: () => void) => void } @@ -44,21 +46,40 @@ export function useMask( currentZIndex: ComputedRef, ): MaskContext { const [maskPath, setMaskPath] = useState('') + const [maskOutlinePath, setMaskOutlinePath] = useState(undefined) + + const updateMask = (positionInfo: Omit | null | false) => { + if (positionInfo === false) { + setMaskPath('') + setMaskOutlinePath(undefined) + } else { + setMaskPath(getMaskPath(positionInfo)) + setMaskOutlinePath(getMaskOutlinePath(positionInfo)) + } + } + const [isAnimating, setIsAnimating] = useState(false) const animateCbs = new Set<() => void>() - const maskAttrs = computed(() => (positionInfo.value ? getMaskAttrs(positionInfo.value) : {})) + const maskAttrs = computed(() => (positionInfo.value ? getMaskAttrs() : {})) const maskStyle = computed(() => { const { mask } = activeStep.value ?? {} return normalizeStyle({ fill: isBoolean(mask) ? undefined : mask?.color, - width: convertCssPixel(positionInfo.value?.windowWidth ?? window.innerWidth), - height: convertCssPixel(positionInfo.value?.windowHeight ?? window.innerHeight), + width: convertCssPixel(window.innerWidth), + height: convertCssPixel(window.innerHeight), zIndex: currentZIndex.value, }) as CSSProperties }) + const maskOutlineStyle = computed(() => { + const { mask } = activeStep.value ?? {} + + return normalizeStyle({ + fill: isBoolean(mask) ? undefined : mask?.outlineColor, + }) as CSSProperties + }) const maskClass = computed(() => { const { mask } = activeStep.value ?? {} @@ -89,22 +110,25 @@ export function useMask( const tick = () => { const elapsed = Date.now() - start - setMaskPath( - getMaskPath({ - windowHeight: to.windowHeight, - windowWidth: to.windowWidth, - x: easeInOutQuad(elapsed, from.x, to.x - from.x, animateDuration), - y: easeInOutQuad(elapsed, from.y, to.y - from.y, animateDuration), - width: easeInOutQuad(elapsed, from.width, to.width - from.width, animateDuration), - height: easeInOutQuad(elapsed, from.height, to.height - from.height, animateDuration), - radius: easeInOutQuad(elapsed, from.radius, to.radius - from.radius, animateDuration), - }), - ) + const currentTickPos = { + containerWidth: to.containerWidth, + containerHeight: to.containerHeight, + containerX: to.containerX, + containerY: to.containerY, + x: easeInOutQuad(elapsed, from.x, to.x - from.x, animateDuration), + y: easeInOutQuad(elapsed, from.y, to.y - from.y, animateDuration), + width: easeInOutQuad(elapsed, from.width, to.width - from.width, animateDuration), + height: easeInOutQuad(elapsed, from.height, to.height - from.height, animateDuration), + radius: easeInOutQuad(elapsed, from.radius, to.radius - from.radius, animateDuration), + outline: to.outline, + } + + updateMask(currentTickPos) if (elapsed < animateDuration) { rAFHandle = rAF(tick) } else { - setMaskPath(getMaskPath(to)) + updateMask(to) setIsAnimating(false) runAnimateCbs() } @@ -124,9 +148,9 @@ export function useMask( if (!activeStep.value?.mask) { cancelAnimate() - setMaskPath('') + updateMask(false) } else if (!mergedProps.value.animatable || pos?.origin !== 'index' || !prePos || !pos) { - setMaskPath(getMaskPath(pos)) + updateMask(pos) if (mergedProps.value.animatable && (isAnimating.value || activeIndex.value !== _tempIndex)) { runAnimateCbs() @@ -147,23 +171,20 @@ export function useMask( return { maskPath, + maskOutlinePath, maskAttrs, maskStyle, + maskOutlineStyle, maskClass, isAnimating, onAnimateEnd, } } -function getMaskPath(positionInfo: Omit | null): string { - const viewBoxRect = (width: number, height: number) => `M${width},0L0,0L0,${height}L${width},${height}L${width},0Z` - - if (!positionInfo) { - return viewBoxRect(window.innerWidth, window.innerHeight) - } - - const { windowWidth, windowHeight, x, y, width, height, radius } = positionInfo - +function arch(h: 1 | -1, v: 1 | -1, radius: number) { + return `a${radius},${radius} 0 0 1 ${h * radius},${v * radius}` +} +function drawRect(x: number, y: number, width: number, height: number, radius: number = 0) { // prevent glitches when stage is too small for radius const limitedRadius = Math.min(radius, width / 2, height / 2) @@ -175,23 +196,36 @@ function getMaskPath(positionInfo: Omit | null): s const boxWidth = width - normalizedRadius * 2 const boxHeight = height - normalizedRadius * 2 - const arch = (h: 1 | -1, v: 1 | -1) => - `a${normalizedRadius},${normalizedRadius} 0 0 1 ${h * normalizedRadius},${v * normalizedRadius}` + return `M${boxX},${boxY} h${boxWidth} ${arch(1, 1, normalizedRadius)} v${boxHeight} ${arch(-1, 1, normalizedRadius)} h-${boxWidth} ${arch( + -1, + -1, + normalizedRadius, + )} v-${boxHeight} ${arch(1, -1, normalizedRadius)} z` +} - /* eslint-disable indent */ - return `${viewBoxRect(windowWidth, windowHeight)} - M${boxX},${boxY} h${boxWidth} ${arch(1, 1)} v${boxHeight} ${arch(-1, 1)} h-${boxWidth} ${arch( - -1, - -1, - )} v-${boxHeight} ${arch(1, -1)} z` - /* eslint-enable indent */ +function getMaskPath(positionInfo: Omit | null): string { + if (!positionInfo) { + return drawRect(0, 0, window.innerWidth, window.innerHeight, 0) + } + + const { containerX, containerY, containerWidth, containerHeight, x, y, width, height, radius } = positionInfo + + return `${drawRect(containerX, containerY, containerWidth, containerHeight)}${drawRect(x, y, width, height, radius)}` } -function getMaskAttrs(positionInfo: TargetPositionInfo): SVGAttributes { - const { windowWidth, windowHeight } = positionInfo +function getMaskOutlinePath(positionInfo: Omit | null): string | undefined { + if (!positionInfo || !positionInfo.outline) { + return + } + + const { x, y, width, height, radius, outline } = positionInfo + + return `${drawRect(x - 1, y - 1, width + 2, height + 2, radius)}${drawRect(x + outline, y + outline, width - outline * 2, height - outline * 2, radius)}` +} +function getMaskAttrs(): SVGAttributes { return { - viewBox: `0 0 ${windowWidth} ${windowHeight}`, + viewBox: `0 0 ${window.innerWidth} ${window.innerHeight}`, version: '1.1', preserveAspectRatio: 'xMinYMin slice', } diff --git a/packages/components/tour/src/composables/useMergedProps.ts b/packages/components/tour/src/composables/useMergedProps.ts index 80db6d5d9..306e74bd6 100644 --- a/packages/components/tour/src/composables/useMergedProps.ts +++ b/packages/components/tour/src/composables/useMergedProps.ts @@ -5,26 +5,31 @@ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE */ -import type { TourProps } from '../types' +import type { TargetGap, TourProps } from '../types' import type { TourConfig } from '@idux/components/config' import { type ComputedRef, computed } from 'vue' +import { isNumber, merge } from 'lodash-es' + export type MergedTourProps = Omit> & - Required>> + Required> & { gap: TargetGap }> export function useMergedProps(props: TourProps, config: TourConfig): ComputedRef { - return computed(() => ({ - ...props, - animatable: props.animatable ?? config.animatable, - gap: props.gap ?? config.gap, - offset: props.offset ?? config.offset, - overlayContainer: props.overlayContainer ?? config.overlayContainer, - placement: props.placement ?? config.placement, - showArrow: props.showArrow ?? config.showArrow, - scrollIntoViewOptions: props.scrollIntoViewOptions ?? config.scrollIntoViewOptions, - closeOnClick: props.closeOnClick ?? config.closeOnClick, - closeOnEsc: props.closeOnEsc ?? config.closeOnEsc, - closable: props.closable ?? config.closable, - })) + return computed(() => { + const propGap = props.gap ? (isNumber(props.gap) ? { offset: props.gap } : { ...props.gap }) : undefined + return { + ...props, + animatable: props.animatable ?? config.animatable, + gap: propGap ? merge({ ...config.gap }, propGap) : config.gap, + offset: props.offset ?? config.offset, + overlayContainer: props.overlayContainer ?? config.overlayContainer, + placement: props.placement ?? config.placement, + showArrow: props.showArrow ?? config.showArrow, + scrollIntoViewOptions: props.scrollIntoViewOptions ?? config.scrollIntoViewOptions, + closeOnClick: props.closeOnClick ?? config.closeOnClick, + closeOnEsc: props.closeOnEsc ?? config.closeOnEsc, + closable: props.closable ?? config.closable, + } + }) } diff --git a/packages/components/tour/src/composables/useStepChange.ts b/packages/components/tour/src/composables/useStepChange.ts index a2294ea4e..05df28dae 100644 --- a/packages/components/tour/src/composables/useStepChange.ts +++ b/packages/components/tour/src/composables/useStepChange.ts @@ -35,18 +35,25 @@ export function useStepChange( animate: false, } + const isLocked = () => locks.stepChange || locks.animate const lock = () => { - Object.keys(locks).forEach(key => (locks[key as keyof typeof locks] = true)) + locks.stepChange = true + locks.animate = true setIsStepChanging(true) } const unlock = (lock: keyof typeof locks) => { + const locked = isLocked() locks[lock] = false - if (Object.keys(locks).every(key => !locks[key as keyof typeof locks])) { + if (locked && !locks.animate && !locks.stepChange) { setIsStepChanging(false) } } + const unlockAll = () => { + unlock('animate') + unlock('stepChange') + } const setIsStepChanging = (changing: boolean) => { if (isStepChanging.value === changing) { @@ -76,7 +83,7 @@ export function useStepChange( watch( activeIndex, (current, pre) => { - if (current !== pre && visible.value) { + if (current !== pre) { transitionTmr && clearTimeout(transitionTmr) lock() } @@ -88,11 +95,16 @@ export function useStepChange( watch( activeStep, (step, preStep) => { - if (step && !preStep) { + if (!isLocked()) { runStepChangeCbs() return } + if (step && !preStep) { + unlockAll() + return + } + if (!mergedProps.value.animatable || !step?.mask || !visible.value) { unlock('animate') } diff --git a/packages/components/tour/src/composables/useTarget.ts b/packages/components/tour/src/composables/useTarget.ts index 4c9d6d460..343db7e36 100644 --- a/packages/components/tour/src/composables/useTarget.ts +++ b/packages/components/tour/src/composables/useTarget.ts @@ -10,6 +10,8 @@ import type { ResolvedTourStep, TargetPositionInfo, TargetPositionOrigin } from import { type ComputedRef, type Ref, onMounted, onUnmounted, shallowRef, watch } from 'vue' +import { isBoolean } from 'lodash-es' + import { useEventListener, useState } from '@idux/cdk/utils' import { isInViewPort } from '../utils' @@ -33,21 +35,74 @@ export function useTarget( targetRef.value = (await activeStep.value?.target()) ?? null } - const updatePopsition = (scrollIntoView = false, origin: TargetPositionOrigin = 'index') => { - const targetEl = targetRef.value - const { offset = 0, radius = 0 } = activeStep.value?.gap ?? {} + const getContainerPos = () => { + const mask = activeStep.value?.mask + if (isBoolean(mask) || !mask || !mask.container || mask.container === 'viewport') { + return { + containerX: 0, + containerY: 0, + containerWidth: window.innerWidth, + containerHeight: window.innerHeight, + } + } + const { x, y, width, height } = mask.container + return { + containerX: x, + containerY: y, + containerWidth: width, + containerHeight: height, + } + } + + const getTargetPositionInfo = () => { + const targetEl = targetRef.value + const { offset = 0, radius = 0, outline = 0 } = activeStep.value?.gap ?? {} if (!targetEl) { - setPositionInfo({ - windowWidth: window.innerWidth, - windowHeight: window.innerHeight, + return { x: window.innerWidth / 2, y: window.innerHeight / 2, width: 0, height: 0, radius, - origin, - }) + outline, + } + } + + let { x, y, width, height } = targetEl.getBoundingClientRect() + + // enlarge offset by 1 when outline is provided + // because outline should be painted right 1px outside of target rect + const mergedOffset = offset + (outline ? outline + 1 : 0) + + if (mergedOffset) { + x -= mergedOffset + y -= mergedOffset + width += mergedOffset * 2 + height += mergedOffset * 2 + } + + return { + x, + y, + width, + height, + radius, + outline, + } + } + + const updatePopsition = (scrollIntoView = false, origin: TargetPositionOrigin = 'index') => { + const targetEl = targetRef.value + + const positionInfo = { + ...getContainerPos(), + ...getTargetPositionInfo(), + origin, + } + + if (!targetEl) { + setPositionInfo(positionInfo) return } @@ -59,31 +114,7 @@ export function useTarget( return } - const { x, y, width, height } = targetEl.getBoundingClientRect() - - if (!offset) { - setPositionInfo({ - windowWidth: window.innerWidth, - windowHeight: window.innerHeight, - x, - y, - width, - height, - radius, - origin, - }) - } else { - setPositionInfo({ - windowWidth: window.innerWidth, - windowHeight: window.innerHeight, - x: x - offset, - y: y - offset, - width: width + offset * 2, - height: height + offset * 2, - radius, - origin, - }) - } + setPositionInfo(positionInfo) } let stopResizeLisiten: (() => void) | undefined @@ -91,7 +122,11 @@ export function useTarget( onMounted(() => { watch([() => activeStep.value?.target, visible], updateTarget, { immediate: true }) - watch(targetRef, () => updatePopsition(true, 'index'), { immediate: true }) + watch( + [targetRef, activeStep], + ([target], [preTarget]) => updatePopsition(true, target !== preTarget ? 'index' : 'step-update'), + { flush: 'post', immediate: true }, + ) watch(visible, () => updatePopsition(true, 'visible')) watch( visible, diff --git a/packages/components/tour/src/types.ts b/packages/components/tour/src/types.ts index 0d3e6737d..3cd1ff92a 100644 --- a/packages/components/tour/src/types.ts +++ b/packages/components/tour/src/types.ts @@ -12,27 +12,33 @@ import type { DefineComponent, HTMLAttributes, PropType } from 'vue' import { ɵOverlayPlacementDef } from '@idux/components/_private/overlay' -export type TargetPositionOrigin = 'resize' | 'scroll' | 'index' | 'visible' +export type TargetPositionOrigin = 'resize' | 'scroll' | 'index' | 'visible' | 'step-update' export interface TargetPositionInfo { - windowWidth: number - windowHeight: number + containerX: number + containerY: number + containerHeight: number + containerWidth: number x: number y: number width: number height: number radius: number + outline: number origin: TargetPositionOrigin } export interface TargetGap { offset?: number radius?: number + outline?: number } export interface TourMaskOptions { color?: string class?: string + container?: 'viewport' | { x: number; y: number; width: number; height: number } + outlineColor?: string } export type TargetGetter = () => MaybeElement | null | Promise diff --git a/packages/components/tour/style/index.less b/packages/components/tour/style/index.less index f7f8cf82c..9e34c4bc5 100644 --- a/packages/components/tour/style/index.less +++ b/packages/components/tour/style/index.less @@ -15,10 +15,14 @@ stroke-miterlimit: 2; pointer-events: none; - & > path { + & &-target { pointer-events: auto; cursor: auto; } + + & &-outline { + fill: var(--ix-tour-outline-color); + } } &-placeholder { diff --git a/packages/components/tour/theme/default.ts b/packages/components/tour/theme/default.ts index c1a1a8d27..030956584 100644 --- a/packages/components/tour/theme/default.ts +++ b/packages/components/tour/theme/default.ts @@ -7,12 +7,22 @@ import type { CertainThemeTokens, GlobalThemeTokens } from '@idux/components/theme' export function getDefaultThemeTokens(tokens: GlobalThemeTokens): CertainThemeTokens<'tour'> { - const { colorText, colorTextInfo, colorContainerBg, borderRadiusMd, fontSizeSm, marginSizeXs, paddingSizeLg } = tokens + const { + colorPrimary, + colorText, + colorTextInfo, + colorContainerBg, + borderRadiusMd, + fontSizeSm, + marginSizeXs, + paddingSizeLg, + } = tokens return { bgColor: colorContainerBg, descriptionColor: colorText, indicatorsColor: colorTextInfo, + outlineColor: colorPrimary, borderRadius: borderRadiusMd, diff --git a/packages/components/tour/theme/tokens.ts b/packages/components/tour/theme/tokens.ts index f74c1541c..900f8e95d 100644 --- a/packages/components/tour/theme/tokens.ts +++ b/packages/components/tour/theme/tokens.ts @@ -9,6 +9,7 @@ export interface TourThemeTokens { bgColor: string descriptionColor: string indicatorsColor: string + outlineColor: string borderRadius: number