123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515 |
- 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<UserGuideProps, UserGuideState> {
- 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<UserGuideProps, UserGuideState> {
- 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<UserGuideState> {
- const states: Partial<UserGuideState> = {};
- 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 (
- <LocaleConsumer componentName="UserGuide">
- {(locale: Locale['UserGuide'], localeCode: Locale['code']) => (
- <div className={cls(`${popupPrefixCls}`, {
- [`${popupPrefixCls}-primary`]: isPrimaryTheme,
- })}
- >
- {cover && <div className={`${popupPrefixCls}-cover`}>{cover}</div>}
- <div className={`${popupPrefixCls}-body`}>
- {title && <div className={`${popupPrefixCls}-title`}>{title}</div>}
- {description && <div className={`${popupPrefixCls}-description`}>{description}</div>}
- <div className={`${popupPrefixCls}-footer`}>
- {steps.length > 1 && (
- <div className={`${popupPrefixCls}-indicator`}>
- {current + 1}/{steps.length}
- </div>
- )}
- <div className={`${popupPrefixCls}-buttons`}>
- {showSkipButton && !isLast && (
- <Button
- style={isPrimaryTheme ? { backgroundColor: 'var(--semi-color-fill-2)' } : {}}
- theme={isPrimaryTheme ? 'solid' : 'light'}
- type={isPrimaryTheme ? 'primary' : 'tertiary'}
- onClick={this.foundation.handleSkip}
- >
- {locale.skip}
- </Button>
- )}
- {showPrevButton && !isFirst && (
- <Button
- style={isPrimaryTheme ? { backgroundColor: 'var(--semi-color-fill-2)' } : {}}
- theme={isPrimaryTheme ? 'solid' : 'light'}
- type={isPrimaryTheme ? 'primary' : 'tertiary'}
- onClick={this.foundation.handlePrev}
- {...prevButtonProps}
- >
- {prevButtonProps?.children || locale.prev}
- </Button>
- )}
- <Button
- style={isPrimaryTheme ? { backgroundColor: '#FFF' } : {}}
- theme={isPrimaryTheme ? 'borderless' : 'solid'}
- type={'primary'}
- onClick={this.foundation.handleNext}
- {...nextButtonProps}
- >
- {isLast ? (finishText || locale.finish) : (nextButtonProps?.children || locale.next)}
- </Button>
- </div>
- </div>
- </div>
- </div>
- )}
- </LocaleConsumer>
- );
- }
- 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 (
- <Popover
- key={`userGuide-popup-${index}`}
- className={cls(`${prefixCls}-popover`, className)}
- style={{ ...basePopoverStyle, ...primaryStyle, ...style }}
- content={this.renderPopupContent(step, index)}
- position={step.position || position}
- trigger="custom"
- visible={visible && isCurrentStep}
- showArrow={step.showArrow !== false}
- >
- <div
- style={{
- position: 'fixed',
- left: rect.x - padding,
- top: rect.y - padding,
- width: rect.width + padding * 2,
- height: rect.height + padding * 2,
- pointerEvents: 'none',
- }}
- >
- </div>
- </Popover>
- );
- };
- 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 ? (
- <svg className={`${prefixCls}-spotlight`} style={{ zIndex }}>
- <defs>
- <mask id={`spotlight-${this.userGuideId}`}>
- <rect width="100%" height="100%" fill="white"/>
- <rect
- className={`${prefixCls}-spotlight-rect`}
- x={spotlightRect.x}
- y={spotlightRect.y}
- width={spotlightRect.width}
- height={spotlightRect.height}
- rx={4}
- fill="black"
- />
- </mask>
- </defs>
- {
- mask && <rect
- width="100%"
- height="100%"
- fill="var(--semi-color-overlay-bg)"
- mask={`url(#spotlight-${this.userGuideId})`}
- />
- }
- </svg>
- ) : null
- }
- </>
- );
- }
- renderIndicator = () => {
- const { steps } = this.props;
- const { current } = this.state;
- const indicatorContent: ReactNode[] = [];
- for (let i = 0; i < steps.length; i++) {
- indicatorContent.push(
- <span
- key={i}
- data-index={i}
- className={cls([`${cssClasses.PREFIX_MODAL}-indicator-item`], {
- [`${cssClasses.PREFIX_MODAL}-indicator-item-active`]: i === current
- })}
- ></span>
- );
- }
- 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 (
- <LocaleConsumer componentName="UserGuide">
- {(locale: Locale['UserGuide'], localeCode: Locale['code']) => (
- <Modal
- className={cssClasses.PREFIX_MODAL}
- bodyStyle={{ padding: 0 }}
- header={null}
- visible={visible}
- maskClosable={false}
- mask={mask}
- centered
- footer={null}
- >
- {cover &&
- <>
- <div className={`${cssClasses.PREFIX_MODAL}-cover`}>
- {cover}
- </div>
- <div className={`${cssClasses.PREFIX_MODAL}-indicator`}>
- {this.renderIndicator()}
- </div>
- </>
- }
- {
- (title || description) && (
- <div className={`${cssClasses.PREFIX_MODAL}-body`}>
- {title && <div className={`${cssClasses.PREFIX_MODAL}-body-title`}>{title}</div>}
- {description && <div className={`${cssClasses.PREFIX_MODAL}-body-description`}>{description}</div>}
- </div>
- )
- }
- <div className={`${cssClasses.PREFIX_MODAL}-footer`}>
- {showSkipButton && !isLast && (
- <Button
- type='tertiary'
- onClick={this.foundation.handleSkip}
- >
- {locale.skip}
- </Button>
- )}
- {showPrevButton && !isFirst && (
- <Button
- type='tertiary'
- onClick={this.foundation.handlePrev}
- {...prevButtonProps}
- >
- {prevButtonProps?.children || locale.prev}
- </Button>
- )}
- <Button
- theme='solid'
- onClick={this.foundation.handleNext}
- {...nextButtonProps}
- >
- {isLast ? (finishText || locale.finish) : (nextButtonProps?.children || locale.next)}
- </Button>
- </div>
- </Modal>
- )}
- </LocaleConsumer>
- );
- }
- render() {
- const { mode, steps, visible } = this.props;
- if (!visible || !steps.length) {
- return null;
- }
- return (
- <>
- {
- mode === 'popup' ? (
- <React.Fragment>
- {steps?.map((step, index) => this.renderStep(step, index))}
- {this.renderSpotlight()}
- </React.Fragment>
- ) : null
- }
- { mode === 'modal' && this.renderModal()}
- </>
- );
- }
- }
- export default UserGuide;
|