index.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. import React, { ReactNode } from 'react';
  2. import cls from 'classnames';
  3. import PropTypes from 'prop-types';
  4. import { cssClasses, numbers, strings } from '@douyinfe/semi-foundation/userGuide/constants';
  5. import UserGuideFoundation, { UserGuideAdapter } from '@douyinfe/semi-foundation/userGuide/foundation';
  6. import { Position } from '../tooltip/index';
  7. import BaseComponent from '../_base/baseComponent';
  8. import Popover from '../popover';
  9. import Button, { ButtonProps } from '../button';
  10. import Modal from '../modal';
  11. import { noop } from '@douyinfe/semi-foundation/utils/function';
  12. import '@douyinfe/semi-foundation/userGuide/userGuide.scss';
  13. import { BaseProps } from '../_base/baseComponent';
  14. import isNullOrUndefined from '@douyinfe/semi-foundation/utils/isNullOrUndefined';
  15. import { getUuidShort } from '@douyinfe/semi-foundation/utils/uuid';
  16. import { Locale } from '../locale/interface';
  17. import LocaleConsumer from '../locale/localeConsumer';
  18. import { getScrollbarWidth } from '../_utils';
  19. const prefixCls = cssClasses.PREFIX;
  20. export interface UserGuideProps extends BaseProps {
  21. className?: string;
  22. current?: number;
  23. finishText?: string;
  24. mask?: boolean;
  25. mode?: 'popup' | 'modal';
  26. nextButtonProps?: ButtonProps;
  27. onChange?: (current: number) => void;
  28. onFinish?: () => void;
  29. onNext?: (current: number) => void;
  30. onPrev?: (current: number) => void;
  31. onSkip?: () => void;
  32. position?: Position;
  33. prevButtonProps?: ButtonProps;
  34. showPrevButton?: boolean;
  35. showSkipButton?: boolean;
  36. spotlightPadding?: number;
  37. steps: StepItem[];
  38. style?: React.CSSProperties;
  39. theme?: 'default' | 'primary';
  40. visible?: boolean;
  41. getPopupContainer?: () => HTMLElement;
  42. zIndex?: number
  43. }
  44. export interface StepItem {
  45. className?: string;
  46. cover?: ReactNode;
  47. target?: (() => Element) | Element;
  48. title?: string | ReactNode;
  49. description?: React.ReactNode;
  50. mask?: boolean;
  51. showArrow?: boolean;
  52. spotlightPadding?: number;
  53. theme?: 'default' | 'primary';
  54. position?: Position
  55. }
  56. export interface UserGuideState {
  57. current: number;
  58. spotlightRect: DOMRect | null
  59. }
  60. class UserGuide extends BaseComponent<UserGuideProps, UserGuideState> {
  61. static propTypes = {
  62. mask: PropTypes.bool,
  63. mode: PropTypes.oneOf(strings.MODE),
  64. onChange: PropTypes.func,
  65. onFinish: PropTypes.func,
  66. onNext: PropTypes.func,
  67. onPrev: PropTypes.func,
  68. onSkip: PropTypes.func,
  69. position: PropTypes.oneOf(strings.POSITION_SET),
  70. showPrevButton: PropTypes.bool,
  71. showSkipButton: PropTypes.bool,
  72. theme: PropTypes.oneOf(strings.THEME),
  73. visible: PropTypes.bool,
  74. getPopupContainer: PropTypes.func,
  75. zIndex: PropTypes.number,
  76. };
  77. static defaultProps: UserGuideProps = {
  78. mask: true,
  79. mode: 'popup',
  80. nextButtonProps: {},
  81. onChange: noop,
  82. onFinish: noop,
  83. onNext: noop,
  84. onPrev: noop,
  85. onSkip: noop,
  86. position: 'bottom',
  87. prevButtonProps: {},
  88. showPrevButton: true,
  89. showSkipButton: true,
  90. steps: [],
  91. theme: 'default',
  92. visible: false,
  93. zIndex: numbers.DEFAULT_Z_INDEX,
  94. };
  95. private bodyOverflow: string;
  96. private scrollBarWidth: number;
  97. private originBodyWidth: string;
  98. foundation: UserGuideFoundation;
  99. userGuideId: string;
  100. constructor(props: UserGuideProps) {
  101. super(props);
  102. this.foundation = new UserGuideFoundation(this.adapter);
  103. this.state = {
  104. current: props.current || numbers.DEFAULT_CURRENT,
  105. spotlightRect: null,
  106. };
  107. this.scrollBarWidth = 0;
  108. this.userGuideId = '';
  109. }
  110. get adapter(): UserGuideAdapter<UserGuideProps, UserGuideState> {
  111. return {
  112. ...super.adapter,
  113. disabledBodyScroll: () => {
  114. const { getPopupContainer } = this.props;
  115. this.bodyOverflow = document.body.style.overflow || '';
  116. if (!getPopupContainer && this.bodyOverflow !== 'hidden') {
  117. document.body.style.overflow = 'hidden';
  118. document.body.style.width = `calc(${this.originBodyWidth || '100%'} - ${this.scrollBarWidth}px)`;
  119. }
  120. },
  121. enabledBodyScroll: () => {
  122. const { getPopupContainer } = this.props;
  123. if (!getPopupContainer && this.bodyOverflow !== 'hidden') {
  124. document.body.style.overflow = this.bodyOverflow;
  125. document.body.style.width = this.originBodyWidth;
  126. }
  127. },
  128. notifyChange: (current: number) => {
  129. this.props.onChange(current);
  130. },
  131. notifyFinish: () => {
  132. this.props.onFinish();
  133. },
  134. notifyNext: (current: number) => {
  135. this.props.onNext(current);
  136. },
  137. notifyPrev: (current: number) => {
  138. this.props.onPrev(current);
  139. },
  140. notifySkip: () => {
  141. this.props.onSkip();
  142. },
  143. setCurrent: (current: number) => {
  144. this.setState({ current });
  145. }
  146. };
  147. }
  148. static getDerivedStateFromProps(props: UserGuideProps, state: UserGuideState): Partial<UserGuideState> {
  149. const states: Partial<UserGuideState> = {};
  150. if (!isNullOrUndefined(props.current) && props.current !== state.current) {
  151. states.current = props.current;
  152. }
  153. return states;
  154. }
  155. componentDidMount() {
  156. this.foundation.init();
  157. this.scrollBarWidth = getScrollbarWidth();
  158. this.userGuideId = getUuidShort();
  159. }
  160. componentDidUpdate(prevProps: UserGuideProps, prevStates: UserGuideState) {
  161. const { steps, mode, visible } = this.props;
  162. const { current } = this.state;
  163. if (visible !== prevProps.visible) {
  164. if (visible) {
  165. this.foundation.beforeShow();
  166. this.setState({ current: 0 });
  167. } else {
  168. this.foundation.afterHide();
  169. }
  170. }
  171. if (mode === 'popup' && (prevStates.current !== current) && steps[current] || (prevProps.visible !== visible)) {
  172. this.updateSpotlightRect();
  173. }
  174. }
  175. componentWillUnmount() {
  176. this.foundation.destroy();
  177. }
  178. scrollTargetIntoViewIfNeeded(target: Element) {
  179. if (!target) {
  180. return ;
  181. }
  182. const rect = target.getBoundingClientRect();
  183. const isInViewport =
  184. rect.top >= 0 &&
  185. rect.left >= 0 &&
  186. rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
  187. rect.right <= (window.innerWidth || document.documentElement.clientWidth);
  188. if (!isInViewport) {
  189. target.scrollIntoView({
  190. behavior: 'auto',
  191. block: 'center'
  192. });
  193. }
  194. }
  195. async updateSpotlightRect() {
  196. const { steps, spotlightPadding } = this.props;
  197. const { current } = this.state;
  198. const step = steps[current];
  199. if (step.target) {
  200. const target = typeof step.target === 'function' ? step.target() : step.target;
  201. // Checks if the target element is within the viewport, and scrolls it into view if not
  202. this.scrollTargetIntoViewIfNeeded(target);
  203. const rect = target?.getBoundingClientRect();
  204. const padding = step?.spotlightPadding || spotlightPadding || numbers.DEFAULT_SPOTLIGHT_PADDING;
  205. const newRects = new DOMRect(
  206. rect.x - padding,
  207. rect.y - padding,
  208. rect.width + padding * 2,
  209. rect.height + padding * 2
  210. );
  211. requestAnimationFrame(() => {
  212. this.setState({ spotlightRect: newRects });
  213. });
  214. }
  215. }
  216. renderPopupContent(step: StepItem, index: number) {
  217. const { showPrevButton, showSkipButton, theme, steps, finishText, nextButtonProps, prevButtonProps } = this.props;
  218. const { current } = this.state;
  219. const isFirst = index === 0;
  220. const isLast = index === steps.length - 1;
  221. const popupPrefixCls = `${prefixCls}-popup-content`;
  222. const isPrimaryTheme = theme === 'primary' || step?.theme === 'primary';
  223. const { cover, title, description } = step;
  224. return (
  225. <LocaleConsumer componentName="UserGuide">
  226. {(locale: Locale['UserGuide'], localeCode: Locale['code']) => (
  227. <div className={cls(`${popupPrefixCls}`, {
  228. [`${popupPrefixCls}-primary`]: isPrimaryTheme,
  229. })}
  230. >
  231. {cover && <div className={`${popupPrefixCls}-cover`}>{cover}</div>}
  232. <div className={`${popupPrefixCls}-body`}>
  233. {title && <div className={`${popupPrefixCls}-title`}>{title}</div>}
  234. {description && <div className={`${popupPrefixCls}-description`}>{description}</div>}
  235. <div className={`${popupPrefixCls}-footer`}>
  236. {steps.length > 1 && (
  237. <div className={`${popupPrefixCls}-indicator`}>
  238. {current + 1}/{steps.length}
  239. </div>
  240. )}
  241. <div className={`${popupPrefixCls}-buttons`}>
  242. {showSkipButton && !isLast && (
  243. <Button
  244. style={isPrimaryTheme ? { backgroundColor: 'var(--semi-color-fill-2)' } : {}}
  245. theme={isPrimaryTheme ? 'solid' : 'light'}
  246. type={isPrimaryTheme ? 'primary' : 'tertiary'}
  247. onClick={this.foundation.handleSkip}
  248. >
  249. {locale.skip}
  250. </Button>
  251. )}
  252. {showPrevButton && !isFirst && (
  253. <Button
  254. style={isPrimaryTheme ? { backgroundColor: 'var(--semi-color-fill-2)' } : {}}
  255. theme={isPrimaryTheme ? 'solid' : 'light'}
  256. type={isPrimaryTheme ? 'primary' : 'tertiary'}
  257. onClick={this.foundation.handlePrev}
  258. {...prevButtonProps}
  259. >
  260. {prevButtonProps?.children || locale.prev}
  261. </Button>
  262. )}
  263. <Button
  264. style={isPrimaryTheme ? { backgroundColor: '#FFF' } : {}}
  265. theme={isPrimaryTheme ? 'borderless' : 'solid'}
  266. type={'primary'}
  267. onClick={this.foundation.handleNext}
  268. {...nextButtonProps}
  269. >
  270. {isLast ? (finishText || locale.finish) : (nextButtonProps?.children || locale.next)}
  271. </Button>
  272. </div>
  273. </div>
  274. </div>
  275. </div>
  276. )}
  277. </LocaleConsumer>
  278. );
  279. }
  280. renderStep = (step: StepItem, index: number) => {
  281. const { theme, position, visible, className, style, spotlightPadding } = this.props;
  282. const { current } = this.state;
  283. const isCurrentStep = current === index;
  284. if (!step.target) {
  285. return null;
  286. }
  287. const basePopoverStyle = { padding: 0 };
  288. const target = typeof step.target === 'function' ? step.target() : step.target;
  289. const rect = target.getBoundingClientRect();
  290. const padding = step?.spotlightPadding || spotlightPadding || numbers.DEFAULT_SPOTLIGHT_PADDING;
  291. const isPrimaryTheme = theme === 'primary' || step?.theme === 'primary';
  292. const primaryStyle = isPrimaryTheme ? { backgroundColor: 'var(--semi-color-primary)' } : {};
  293. return (
  294. <Popover
  295. key={`userGuide-popup-${index}`}
  296. className={cls(`${prefixCls}-popover`, className)}
  297. style={{ ...basePopoverStyle, ...primaryStyle, ...style }}
  298. content={this.renderPopupContent(step, index)}
  299. position={step.position || position}
  300. trigger="custom"
  301. visible={visible && isCurrentStep}
  302. showArrow={step.showArrow !== false}
  303. >
  304. <div
  305. style={{
  306. position: 'fixed',
  307. left: rect.x - padding,
  308. top: rect.y - padding,
  309. width: rect.width + padding * 2,
  310. height: rect.height + padding * 2,
  311. pointerEvents: 'none',
  312. }}
  313. >
  314. </div>
  315. </Popover>
  316. );
  317. };
  318. renderSpotlight() {
  319. const { steps, mask, zIndex } = this.props;
  320. const { spotlightRect, current } = this.state;
  321. const step = steps[current];
  322. if (!step.target) {
  323. return null;
  324. }
  325. if (!spotlightRect) {
  326. this.updateSpotlightRect();
  327. }
  328. return (
  329. <>
  330. {
  331. spotlightRect ? (
  332. <svg className={`${prefixCls}-spotlight`} style={{ zIndex }}>
  333. <defs>
  334. <mask id={`spotlight-${this.userGuideId}`}>
  335. <rect width="100%" height="100%" fill="white"/>
  336. <rect
  337. className={`${prefixCls}-spotlight-rect`}
  338. x={spotlightRect.x}
  339. y={spotlightRect.y}
  340. width={spotlightRect.width}
  341. height={spotlightRect.height}
  342. rx={4}
  343. fill="black"
  344. />
  345. </mask>
  346. </defs>
  347. {
  348. mask && (
  349. <>
  350. <rect
  351. width="100%"
  352. height="100%"
  353. fill="var(--semi-color-overlay-bg)"
  354. mask={`url(#spotlight-${this.userGuideId})`} />
  355. <rect
  356. x={0}
  357. y={0}
  358. width="100%"
  359. height={spotlightRect.y}
  360. fill="transparent"
  361. className={`${prefixCls}-spotlight-transparent-rect`} />
  362. <rect
  363. x={0}
  364. y={spotlightRect.y}
  365. width={spotlightRect.x}
  366. height={spotlightRect.height}
  367. fill="transparent"
  368. className={`${prefixCls}-spotlight-transparent-rect`} />
  369. <rect
  370. x={spotlightRect.x + spotlightRect.width}
  371. y={spotlightRect.y}
  372. width={`calc(100% - ${spotlightRect.x + spotlightRect.width}px)`}
  373. height={spotlightRect.height}
  374. fill="transparent"
  375. className={`${prefixCls}-spotlight-transparent-rect`} />
  376. <rect
  377. y={spotlightRect.y + spotlightRect.height}
  378. width="100%"
  379. height={`calc(100% - ${spotlightRect.y + spotlightRect.height}px)`}
  380. fill="transparent"
  381. className={`${prefixCls}-spotlight-transparent-rect`} />
  382. </>
  383. )
  384. }
  385. </svg>
  386. ) : null
  387. }
  388. </>
  389. );
  390. }
  391. renderIndicator = () => {
  392. const { steps } = this.props;
  393. const { current } = this.state;
  394. const indicatorContent: ReactNode[] = [];
  395. for (let i = 0; i < steps.length; i++) {
  396. indicatorContent.push(
  397. <span
  398. key={i}
  399. data-index={i}
  400. className={cls([`${cssClasses.PREFIX_MODAL}-indicator-item`], {
  401. [`${cssClasses.PREFIX_MODAL}-indicator-item-active`]: i === current
  402. })}
  403. ></span>
  404. );
  405. }
  406. return indicatorContent;
  407. }
  408. renderModal = () => {
  409. const { visible, steps, showSkipButton, showPrevButton, finishText, nextButtonProps, prevButtonProps, mask } = this.props;
  410. const { current } = this.state;
  411. const step = steps[current];
  412. const isFirst = current === 0;
  413. const isLast = current === steps.length - 1;
  414. const { cover, title, description } = step;
  415. return (
  416. <LocaleConsumer componentName="UserGuide">
  417. {(locale: Locale['UserGuide'], localeCode: Locale['code']) => (
  418. <Modal
  419. className={cssClasses.PREFIX_MODAL}
  420. bodyStyle={{ padding: 0 }}
  421. header={null}
  422. visible={visible}
  423. maskClosable={false}
  424. mask={mask}
  425. centered
  426. footer={null}
  427. >
  428. {cover &&
  429. <>
  430. <div className={`${cssClasses.PREFIX_MODAL}-cover`}>
  431. {cover}
  432. </div>
  433. <div className={`${cssClasses.PREFIX_MODAL}-indicator`}>
  434. {this.renderIndicator()}
  435. </div>
  436. </>
  437. }
  438. {
  439. (title || description) && (
  440. <div className={`${cssClasses.PREFIX_MODAL}-body`}>
  441. {title && <div className={`${cssClasses.PREFIX_MODAL}-body-title`}>{title}</div>}
  442. {description && <div className={`${cssClasses.PREFIX_MODAL}-body-description`}>{description}</div>}
  443. </div>
  444. )
  445. }
  446. <div className={`${cssClasses.PREFIX_MODAL}-footer`}>
  447. {showSkipButton && !isLast && (
  448. <Button
  449. type='tertiary'
  450. onClick={this.foundation.handleSkip}
  451. >
  452. {locale.skip}
  453. </Button>
  454. )}
  455. {showPrevButton && !isFirst && (
  456. <Button
  457. type='tertiary'
  458. onClick={this.foundation.handlePrev}
  459. {...prevButtonProps}
  460. >
  461. {prevButtonProps?.children || locale.prev}
  462. </Button>
  463. )}
  464. <Button
  465. theme='solid'
  466. onClick={this.foundation.handleNext}
  467. {...nextButtonProps}
  468. >
  469. {isLast ? (finishText || locale.finish) : (nextButtonProps?.children || locale.next)}
  470. </Button>
  471. </div>
  472. </Modal>
  473. )}
  474. </LocaleConsumer>
  475. );
  476. }
  477. render() {
  478. const { mode, steps, visible } = this.props;
  479. if (!visible || !steps.length) {
  480. return null;
  481. }
  482. return (
  483. <>
  484. {
  485. mode === 'popup' ? (
  486. <React.Fragment>
  487. {steps?.map((step, index) => this.renderStep(step, index))}
  488. {this.renderSpotlight()}
  489. </React.Fragment>
  490. ) : null
  491. }
  492. { mode === 'modal' && this.renderModal()}
  493. </>
  494. );
  495. }
  496. }
  497. export default UserGuide;