import React, { ReactNode } from 'react'; import cls from 'classnames'; import PropTypes from 'prop-types'; import { cssClasses, numbers, strings } from '@douyinfe/semi-foundation/userGuide/constants'; import UserGuideFoundation, { UserGuideAdapter } from '@douyinfe/semi-foundation/userGuide/foundation'; import { Position } from '../tooltip/index'; import BaseComponent from '../_base/baseComponent'; import Popover from '../popover'; import Button, { ButtonProps } from '../button'; import Modal from '../modal'; import { noop } from '@douyinfe/semi-foundation/utils/function'; import '@douyinfe/semi-foundation/userGuide/userGuide.scss'; import { BaseProps } from '../_base/baseComponent'; import isNullOrUndefined from '@douyinfe/semi-foundation/utils/isNullOrUndefined'; import { getUuidShort } from '@douyinfe/semi-foundation/utils/uuid'; import { Locale } from '../locale/interface'; import LocaleConsumer from '../locale/localeConsumer'; import { getScrollbarWidth } from '../_utils'; const prefixCls = cssClasses.PREFIX; export interface UserGuideProps extends BaseProps { className?: string; current?: number; finishText?: string; mask?: boolean; mode?: 'popup' | 'modal'; nextButtonProps?: ButtonProps; onChange?: (current: number) => void; onFinish?: () => void; onNext?: (current: number) => void; onPrev?: (current: number) => void; onSkip?: () => void; position?: Position; prevButtonProps?: ButtonProps; showPrevButton?: boolean; showSkipButton?: boolean; spotlightPadding?: number; steps: StepItem[]; style?: React.CSSProperties; theme?: 'default' | 'primary'; visible?: boolean; getPopupContainer?: () => HTMLElement; zIndex?: number } export interface StepItem { className?: string; cover?: ReactNode; target?: (() => Element) | Element; title?: string | ReactNode; description?: React.ReactNode; mask?: boolean; showArrow?: boolean; spotlightPadding?: number; theme?: 'default' | 'primary'; position?: Position } export interface UserGuideState { current: number; spotlightRect: DOMRect | null } class UserGuide extends BaseComponent { static propTypes = { mask: PropTypes.bool, mode: PropTypes.oneOf(strings.MODE), onChange: PropTypes.func, onFinish: PropTypes.func, onNext: PropTypes.func, onPrev: PropTypes.func, onSkip: PropTypes.func, position: PropTypes.oneOf(strings.POSITION_SET), showPrevButton: PropTypes.bool, showSkipButton: PropTypes.bool, theme: PropTypes.oneOf(strings.THEME), visible: PropTypes.bool, getPopupContainer: PropTypes.func, zIndex: PropTypes.number, }; static defaultProps: UserGuideProps = { mask: true, mode: 'popup', nextButtonProps: {}, onChange: noop, onFinish: noop, onNext: noop, onPrev: noop, onSkip: noop, position: 'bottom', prevButtonProps: {}, showPrevButton: true, showSkipButton: true, steps: [], theme: 'default', visible: false, zIndex: numbers.DEFAULT_Z_INDEX, }; private bodyOverflow: string; private scrollBarWidth: number; private originBodyWidth: string; foundation: UserGuideFoundation; userGuideId: string; constructor(props: UserGuideProps) { super(props); this.foundation = new UserGuideFoundation(this.adapter); this.state = { current: props.current || numbers.DEFAULT_CURRENT, spotlightRect: null, }; this.scrollBarWidth = 0; this.userGuideId = ''; } get adapter(): UserGuideAdapter { return { ...super.adapter, disabledBodyScroll: () => { const { getPopupContainer } = this.props; this.bodyOverflow = document.body.style.overflow || ''; if (!getPopupContainer && this.bodyOverflow !== 'hidden') { document.body.style.overflow = 'hidden'; document.body.style.width = `calc(${this.originBodyWidth || '100%'} - ${this.scrollBarWidth}px)`; } }, enabledBodyScroll: () => { const { getPopupContainer } = this.props; if (!getPopupContainer && this.bodyOverflow !== 'hidden') { document.body.style.overflow = this.bodyOverflow; document.body.style.width = this.originBodyWidth; } }, notifyChange: (current: number) => { this.props.onChange(current); }, notifyFinish: () => { this.props.onFinish(); }, notifyNext: (current: number) => { this.props.onNext(current); }, notifyPrev: (current: number) => { this.props.onPrev(current); }, notifySkip: () => { this.props.onSkip(); }, setCurrent: (current: number) => { this.setState({ current }); } }; } static getDerivedStateFromProps(props: UserGuideProps, state: UserGuideState): Partial { const states: Partial = {}; if (!isNullOrUndefined(props.current) && props.current !== state.current) { states.current = props.current; } return states; } componentDidMount() { this.foundation.init(); this.scrollBarWidth = getScrollbarWidth(); this.userGuideId = getUuidShort(); } componentDidUpdate(prevProps: UserGuideProps, prevStates: UserGuideState) { const { steps, mode, visible } = this.props; const { current } = this.state; if (visible !== prevProps.visible) { if (visible) { this.foundation.beforeShow(); this.setState({ current: 0 }); } else { this.foundation.afterHide(); } } if (mode === 'popup' && (prevStates.current !== current) && steps[current] || (prevProps.visible !== visible)) { this.updateSpotlightRect(); } } componentWillUnmount() { this.foundation.destroy(); } scrollTargetIntoViewIfNeeded(target: Element) { if (!target) { return ; } const rect = target.getBoundingClientRect(); const isInViewport = rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth); if (!isInViewport) { target.scrollIntoView({ behavior: 'auto', block: 'center' }); } } async updateSpotlightRect() { const { steps, spotlightPadding } = this.props; const { current } = this.state; const step = steps[current]; if (step.target) { const target = typeof step.target === 'function' ? step.target() : step.target; // Checks if the target element is within the viewport, and scrolls it into view if not this.scrollTargetIntoViewIfNeeded(target); const rect = target?.getBoundingClientRect(); const padding = step?.spotlightPadding || spotlightPadding || numbers.DEFAULT_SPOTLIGHT_PADDING; const newRects = new DOMRect( rect.x - padding, rect.y - padding, rect.width + padding * 2, rect.height + padding * 2 ); requestAnimationFrame(() => { this.setState({ spotlightRect: newRects }); }); } } renderPopupContent(step: StepItem, index: number) { const { showPrevButton, showSkipButton, theme, steps, finishText, nextButtonProps, prevButtonProps } = this.props; const { current } = this.state; const isFirst = index === 0; const isLast = index === steps.length - 1; const popupPrefixCls = `${prefixCls}-popup-content`; const isPrimaryTheme = theme === 'primary' || step?.theme === 'primary'; const { cover, title, description } = step; return ( {(locale: Locale['UserGuide'], localeCode: Locale['code']) => (
{cover &&
{cover}
}
{title &&
{title}
} {description &&
{description}
}
{steps.length > 1 && (
{current + 1}/{steps.length}
)}
{showSkipButton && !isLast && ( )} {showPrevButton && !isFirst && ( )}
)}
); } renderStep = (step: StepItem, index: number) => { const { theme, position, visible, className, style, spotlightPadding } = this.props; const { current } = this.state; const isCurrentStep = current === index; if (!step.target) { return null; } const basePopoverStyle = { padding: 0 }; const target = typeof step.target === 'function' ? step.target() : step.target; const rect = target.getBoundingClientRect(); const padding = step?.spotlightPadding || spotlightPadding || numbers.DEFAULT_SPOTLIGHT_PADDING; const isPrimaryTheme = theme === 'primary' || step?.theme === 'primary'; const primaryStyle = isPrimaryTheme ? { backgroundColor: 'var(--semi-color-primary)' } : {}; return (
); }; renderSpotlight() { const { steps, mask, zIndex } = this.props; const { spotlightRect, current } = this.state; const step = steps[current]; if (!step.target) { return null; } if (!spotlightRect) { this.updateSpotlightRect(); } return ( <> { spotlightRect ? ( { mask && ( <> ) } ) : null } ); } renderIndicator = () => { const { steps } = this.props; const { current } = this.state; const indicatorContent: ReactNode[] = []; for (let i = 0; i < steps.length; i++) { indicatorContent.push( ); } return indicatorContent; } renderModal = () => { const { visible, steps, showSkipButton, showPrevButton, finishText, nextButtonProps, prevButtonProps, mask } = this.props; const { current } = this.state; const step = steps[current]; const isFirst = current === 0; const isLast = current === steps.length - 1; const { cover, title, description } = step; return ( {(locale: Locale['UserGuide'], localeCode: Locale['code']) => ( {cover && <>
{cover}
{this.renderIndicator()}
} { (title || description) && (
{title &&
{title}
} {description &&
{description}
}
) }
{showSkipButton && !isLast && ( )} {showPrevButton && !isFirst && ( )}
)}
); } render() { const { mode, steps, visible } = this.props; if (!visible || !steps.length) { return null; } return ( <> { mode === 'popup' ? ( {steps?.map((step, index) => this.renderStep(step, index))} {this.renderSpotlight()} ) : null } { mode === 'modal' && this.renderModal()} ); } } export default UserGuide;