import React, { useEffect, useState } from "react"; import scrollIntoView, { CustomBehaviorOptions, Options, } from "scroll-into-view-if-needed"; import classNames from "classnames"; import { Button } from "../button"; import { Overlay } from "../overlay"; import { MediaObject } from "../mediaobject"; import { BubbleProps, BubbleContent } from "../bubble"; import { useConfig } from "../util"; import { injectValue } from "../_util/inject-value"; import { useDefault } from "../_util/use-default"; import { useTranslation } from "../i18n"; export interface GuideStep { /** * 需要高亮的元素 */ element: Element | (() => Element); /** * 气泡位置 */ placement?: BubbleProps["placement"]; /** * 标题 */ title?: React.ReactNode; /** * 描述 */ description?: React.ReactNode; /** * 配图 URL */ image?: string; /** * 弹出位置偏离参考位置的位移 * * 如: `10`、`"50%"`、`"10 + 10%"`、`[10, 10]` * * @since 2.6.3 */ placementOffset?: BubbleProps["placementOffset"]; /** * 气泡箭头是否指向目标元素中心 * @since 2.6.3 */ arrowPointAtCenter?: BubbleProps["arrowPointAtCenter"]; /** * 步骤气泡自定义类名 * @since 2.5.0 */ bubbleClassName?: string; /** * 步骤气泡自定义样式 * @since 2.5.0 */ bubbleStyle?: React.CSSProperties; /** * 滚动元素至视野范围的配置 * * **详见:https://www.npmjs.com/package/scroll-into-view-if-needed** * * @default { behavior: "smooth", block: "center", inline: "center" } * @since 2.6.18 */ scrollIntoViewOptions?: CustomBehaviorOptions | Options | boolean; } export interface GuideProps { /** * 引导步骤 */ steps?: GuideStep[]; /** * 开始引导内容 */ startContent?: GuideStep; /** * 包含 `children` 时,在 `children` 渲染完成后展示引导 */ children?: JSX.Element; /** * 当前步骤. 如果有引导内容(非第一步),current值设为 -1 */ current?: number; /** * 步骤变化回调 * * @since 2.6.10 支持 `context` */ onCurrentChange?: ( current: number, context: { from: "start" | "back" | "next" | "finish" | "cancel" } ) => void; /** * 受控展示 * @default true */ visible?: boolean; /** * 隐藏取消按钮 * @default false */ hideCancelButton?: boolean; /** * 隐藏计数 * @default false * @since 2.6.4 */ hideCount?: boolean; /** * 隐藏上一步按钮 * @default false */ showBackButton?: boolean; /** * 取消按钮文字 * @default "跳过" */ cancelText?: string; /** * 上一步按钮文字 * @default "上一步" */ backText?: string; /** * 下一步按钮文字 * @default "下一步" */ nextText?: string; /** * 完成按钮文字 * @default "完成" */ finishText?: string; /** * 是否自动滚动元素至视野范围 * @default false */ autoScrollIntoView?: boolean; /** * 下一步(及完成)按钮样式 * @default "link" * @since 2.6.18 */ nextButtonTheme?: "link" | "primary"; /** * 是否显示遮罩 * @default true * @since 2.7.4 */ showMask?: boolean; /** * 是否显示圆点 * @default false * @since 2.7.4 */ showDot?: boolean; /** * 引导开始按钮文字 * @default "确定" * @since 2.7.4 */ startFinishText?: string; /** * 是否显示椭圆动画 * @default false * @since 2.7.4 */ visibleEllipseAnimation?: boolean; /** * 是否显示三角箭头 * @default true * @since 2.7.4 */ visibleTriangleArrow?: boolean; /** * 是否显示关闭按钮 * @default false * @since 2.7.4 */ visibleCloseBtn?: boolean; } const borderWidth = 1; function getOffset(element: HTMLElement) { if (!element) { return {}; } const { body } = document; const docEl = document.documentElement; const scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop; const scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft; try { const x = element.getBoundingClientRect(); return { top: x.top + scrollTop, width: x.width, height: x.height, left: x.left + scrollLeft, }; } catch (_) { return {}; } } // 抽取body公共函数 const renderBody = (step, classPrefix) => { const { title, description, image } = step; return (
) } > {title && (

{title}

)} {description && (
{description}
)}
); }; // 抽取圆点函数 const renderDot = (count, current, classPrefix) => { return (
{[...Array(count)].map((items, index) => ( ))}
); }; // 获取overlay公共属性 const getOverlayCommonProps = (zIndex, key, placement, placementOffset) => { return { key, placement, placementOffset, modifiers: { flip: { enabled: false }, preventOverflow: { enabled: false }, hide: { enabled: false }, }, overlayProps: { style: { zIndex } }, }; }; export function Guide({ steps = [], startContent, children = null, visible = true, autoScrollIntoView, current, onCurrentChange, showBackButton, hideCount, hideCancelButton, cancelText, backText, nextText, finishText, nextButtonTheme = "link", showDot = false, showMask = true, startFinishText, visibleEllipseAnimation = false, visibleTriangleArrow = true, visibleCloseBtn = false, }: GuideProps) { const t = useTranslation(); const { classPrefix } = useConfig(); const [currentIndex, setCurrentIndex] = useDefault( current, -1, onCurrentChange ); const [isStartStep, setIsStartStep] = useState(false); // 保证子级结点存在 useEffect(() => { if (startContent && (typeof current === "undefined" || current < 0)) { setIsStartStep(true); } else { setIsStartStep(false); } if (current <= -2) { setIsStartStep(false); } if (typeof current === "undefined") { setCurrentIndex(0, { from: "start" }); } }, [current, startContent, setCurrentIndex]); let step = startContent; let count = 0; if (steps && !isStartStep) { step = steps[currentIndex]; count = steps.length; } useEffect(() => { if (step) { const element = injectValue(step.element)(); if (element) { scrollIntoView( element, step.scrollIntoViewOptions || { behavior: "smooth", block: "center", inline: "center", } ); } } }, [autoScrollIntoView, step]); if (!visible || !step) { return null; } let offset = step.placementOffset || 10; if (step.arrowPointAtCenter) { offset = Array.isArray(offset) ? offset[1] : offset; const [, placementModifier] = step.placement.split("-"); if (placementModifier === "start") { offset = ["50%-26", offset]; } else if (placementModifier === "end") { offset = ["26-50%", offset]; } } const element = injectValue(step.element)(); const { placement } = step; const { height, width } = getOffset(element as HTMLElement); if (!element) { return null; } const maskLayerProps = getOverlayCommonProps( 9999, "highlight", "top-start", 0 ); const layerProps = getOverlayCommonProps(10000, "bubble", placement, offset); return ( { const left = +style.left; const top = +style.top; const p = `M ${left - borderWidth} ${top - borderWidth} H ${left + width + borderWidth} V ${top + height + borderWidth} H ${left - borderWidth} L ${left - borderWidth} ${top - borderWidth} Z`; return ( ); }} {...maskLayerProps} />, ( {visibleEllipseAnimation && (
)} {visibleCloseBtn && ( )} {showBackButton && ((startContent && current >= 0) || (!startContent && current > 0)) && ( )} {current < count - 1 ? ( ) : ( )}
)} />, ]} > {ref => { return (
{ if (typeof ref === "function") { ref(element as HTMLElement); } }} > {children}
); }}
); } Guide.displayName = "Guide";