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 (
)
}
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