base.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. import React, { Component, ReactElement } from 'react';
  2. import ReactDOM from 'react-dom';
  3. import cls from 'classnames';
  4. import PropTypes from 'prop-types';
  5. import { cssClasses, strings } from '@douyinfe/semi-foundation/typography/constants';
  6. import Typography from './typography';
  7. import Copyable from './copyable';
  8. import { IconSize as Size } from '../icons/index';
  9. import { isUndefined, omit, merge, isString } from 'lodash';
  10. import Tooltip from '../tooltip/index';
  11. import Popover from '../popover/index';
  12. import getRenderText from './util';
  13. import warning from '@douyinfe/semi-foundation/utils/warning';
  14. import isEnterPress from '@douyinfe/semi-foundation/utils/isEnterPress';
  15. import LocaleConsumer from '../locale/localeConsumer';
  16. import { Locale } from '../locale/interface';
  17. import { Ellipsis, EllipsisPos, ShowTooltip, TypographyBaseSize, TypographyBaseType } from './interface';
  18. import { CopyableConfig, LinkType } from './title';
  19. import { BaseProps } from '../_base/baseComponent';
  20. import { isSemiIcon } from '../_utils';
  21. export interface BaseTypographyProps extends BaseProps {
  22. copyable?: CopyableConfig | boolean;
  23. delete?: boolean;
  24. disabled?: boolean;
  25. icon?: React.ReactNode;
  26. ellipsis?: Ellipsis | boolean;
  27. mark?: boolean;
  28. underline?: boolean;
  29. link?: LinkType;
  30. strong?: boolean;
  31. type?: TypographyBaseType;
  32. size?: TypographyBaseSize;
  33. style?: React.CSSProperties;
  34. className?: string;
  35. code?: boolean;
  36. children?: React.ReactNode;
  37. component?: React.ElementType;
  38. spacing?: string;
  39. heading?: string;
  40. }
  41. interface BaseTypographyState {
  42. editable: boolean;
  43. copied: boolean;
  44. isOverflowed: boolean;
  45. ellipsisContent: string;
  46. expanded: boolean;
  47. isTruncated: boolean;
  48. first: boolean;
  49. prevChildren: React.ReactNode;
  50. }
  51. const prefixCls = cssClasses.PREFIX;
  52. const ELLIPSIS_STR = '...';
  53. const wrapperDecorations = (props: BaseTypographyProps, content: React.ReactNode) => {
  54. const { mark, code, underline, strong, link, disabled, icon, } = props;
  55. let wrapped = content;
  56. const wrap = (isNeeded: boolean | LinkType, tag: string) => {
  57. let wrapProps = icon ? { style: { display: 'inline-flex', alignItems: 'center' } } : {};
  58. if (!isNeeded) {
  59. return;
  60. }
  61. if (typeof isNeeded === 'object') {
  62. wrapProps = { ...isNeeded } as any;
  63. }
  64. wrapped = React.createElement(tag, wrapProps, wrapped);
  65. };
  66. wrap(mark, 'mark');
  67. wrap(code, 'code');
  68. wrap(underline && !link, 'u');
  69. wrap(strong, 'strong');
  70. wrap(props.delete, 'del');
  71. wrap(link, disabled ? 'span' : 'a');
  72. // When the content is not wrapped, and there is more than one element in the content (one of which is an icon),
  73. // use span to wrap the content, so that the content in the span is vertically aligned
  74. if (wrapped === content && icon) {
  75. wrap(true, 'span');
  76. }
  77. return wrapped;
  78. };
  79. export default class Base extends Component<BaseTypographyProps, BaseTypographyState> {
  80. static propTypes = {
  81. children: PropTypes.node,
  82. copyable: PropTypes.oneOfType([
  83. PropTypes.shape({
  84. text: PropTypes.string,
  85. onCopy: PropTypes.func,
  86. successTip: PropTypes.node,
  87. copyTip: PropTypes.node,
  88. }),
  89. PropTypes.bool,
  90. ]),
  91. delete: PropTypes.bool,
  92. disabled: PropTypes.bool,
  93. // editable: PropTypes.bool,
  94. ellipsis: PropTypes.oneOfType([
  95. PropTypes.shape({
  96. rows: PropTypes.number,
  97. expandable: PropTypes.bool,
  98. expandText: PropTypes.string,
  99. onExpand: PropTypes.func,
  100. suffix: PropTypes.string,
  101. showTooltip: PropTypes.oneOfType([
  102. PropTypes.shape({
  103. type: PropTypes.string,
  104. opts: PropTypes.object,
  105. }),
  106. PropTypes.bool,
  107. ]),
  108. collapsible: PropTypes.bool,
  109. collapseText: PropTypes.string,
  110. pos: PropTypes.oneOf(['end', 'middle']),
  111. }),
  112. PropTypes.bool,
  113. ]),
  114. mark: PropTypes.bool,
  115. underline: PropTypes.bool,
  116. link: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]),
  117. spacing: PropTypes.oneOf(strings.SPACING),
  118. strong: PropTypes.bool,
  119. size: PropTypes.oneOf(strings.SIZE),
  120. type: PropTypes.oneOf(strings.TYPE),
  121. style: PropTypes.object,
  122. className: PropTypes.string,
  123. icon: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
  124. heading: PropTypes.string,
  125. component: PropTypes.string,
  126. };
  127. static defaultProps = {
  128. children: null as React.ReactNode,
  129. copyable: false,
  130. delete: false,
  131. disabled: false,
  132. // editable: false,
  133. ellipsis: false,
  134. icon: '',
  135. mark: false,
  136. underline: false,
  137. strong: false,
  138. link: false,
  139. type: 'primary',
  140. spacing: 'normal',
  141. size: 'normal',
  142. style: {},
  143. className: '',
  144. };
  145. wrapperRef: React.RefObject<any>;
  146. expandRef: React.RefObject<any>;
  147. copyRef: React.RefObject<any>;
  148. rafId: ReturnType<typeof requestAnimationFrame>;
  149. expandStr: string;
  150. collapseStr: string;
  151. constructor(props: BaseTypographyProps) {
  152. super(props);
  153. this.state = {
  154. editable: false,
  155. copied: false,
  156. // ellipsis
  157. // if text is overflow in container
  158. isOverflowed: true,
  159. ellipsisContent: null,
  160. expanded: false,
  161. // if text is truncated with js
  162. isTruncated: false,
  163. // record if has click expanded
  164. first: true,
  165. prevChildren: null,
  166. };
  167. this.wrapperRef = React.createRef();
  168. this.expandRef = React.createRef();
  169. this.copyRef = React.createRef();
  170. }
  171. componentDidMount() {
  172. if (this.props.ellipsis) {
  173. this.getEllipsisState();
  174. window.addEventListener('resize', this.onResize);
  175. }
  176. }
  177. static getDerivedStateFromProps(props: BaseTypographyProps, prevState: BaseTypographyState) {
  178. const { prevChildren } = prevState;
  179. const newState: Partial<BaseTypographyState> = {};
  180. newState.prevChildren = props.children;
  181. if (props.ellipsis && prevChildren !== props.children) {
  182. // reset ellipsis state if children update
  183. newState.isOverflowed = true;
  184. newState.ellipsisContent = null;
  185. newState.expanded = false;
  186. newState.isTruncated = false;
  187. newState.first = true;
  188. }
  189. return newState;
  190. }
  191. componentDidUpdate(prevProps: BaseTypographyProps) {
  192. // Render was based on outdated refs and needs to be rerun
  193. if (this.props.children !== prevProps.children) {
  194. this.forceUpdate();
  195. if (this.props.ellipsis) {
  196. this.getEllipsisState();
  197. }
  198. }
  199. }
  200. componentWillUnmount() {
  201. if (this.props.ellipsis) {
  202. window.removeEventListener('resize', this.onResize);
  203. }
  204. if (this.rafId) {
  205. window.cancelAnimationFrame(this.rafId);
  206. }
  207. }
  208. onResize = () => {
  209. if (this.rafId) {
  210. window.cancelAnimationFrame(this.rafId);
  211. }
  212. this.rafId = window.requestAnimationFrame(this.getEllipsisState.bind(this));
  213. };
  214. // if need to use js overflowed:
  215. // 1. text is expandable 2. expandText need to be shown 3. has extra operation 4. text need to ellipse from mid
  216. canUseCSSEllipsis = () => {
  217. const { copyable } = this.props;
  218. const { expandable, expandText, pos, suffix } = this.getEllipsisOpt();
  219. return !expandable && isUndefined(expandText) && !copyable && pos === 'end' && !suffix.length;
  220. };
  221. /**
  222. * whether truncated
  223. * rows < = 1 if there is overflow content, return true
  224. * rows > 1 if there is overflow height, return true
  225. * @param {Number} rows
  226. * @returns {Boolean}
  227. */
  228. shouldTruncated = (rows: number) => {
  229. if (!rows || rows < 1) {
  230. return false;
  231. }
  232. const updateOverflow =
  233. rows <= 1 ?
  234. this.wrapperRef.current.scrollWidth > this.wrapperRef.current.clientWidth :
  235. this.wrapperRef.current.scrollHeight > this.wrapperRef.current.offsetHeight;
  236. return updateOverflow;
  237. };
  238. showTooltip = () => {
  239. const { isOverflowed, isTruncated, expanded } = this.state;
  240. const { showTooltip, expandable, expandText } = this.getEllipsisOpt();
  241. const overflowed = !expanded && (isOverflowed || isTruncated);
  242. const noExpandText = !expandable && isUndefined(expandText);
  243. const show = noExpandText && overflowed && showTooltip;
  244. if (!show) {
  245. return show;
  246. }
  247. const defaultOpts = {
  248. type: 'tooltip',
  249. opts: {},
  250. };
  251. if (typeof showTooltip === 'object') {
  252. if (showTooltip.type && showTooltip.type.toLowerCase() === 'popover') {
  253. return merge(
  254. {
  255. opts: {
  256. style: { width: '240px' },
  257. showArrow: true,
  258. },
  259. },
  260. showTooltip
  261. );
  262. }
  263. return { ...defaultOpts, ...showTooltip };
  264. }
  265. return defaultOpts;
  266. };
  267. getEllipsisState() {
  268. const { rows, suffix, pos } = this.getEllipsisOpt();
  269. const { children } = this.props;
  270. // wait until element mounted
  271. if (!this.wrapperRef || !this.wrapperRef.current) {
  272. this.onResize();
  273. return false;
  274. }
  275. const { ellipsisContent, isOverflowed, isTruncated, expanded } = this.state;
  276. const updateOverflow = this.shouldTruncated(rows);
  277. const canUseCSSEllipsis = this.canUseCSSEllipsis();
  278. const needUpdate = updateOverflow !== isOverflowed;
  279. if (!rows || rows < 0 || expanded) {
  280. return undefined;
  281. }
  282. if (canUseCSSEllipsis) {
  283. if (needUpdate) {
  284. this.setState({ expanded: !updateOverflow });
  285. }
  286. return undefined;
  287. }
  288. const extraNode = [this.expandRef.current, this.copyRef && this.copyRef.current];
  289. warning(
  290. 'children' in this.props && typeof children !== 'string',
  291. "[Semi Typography] 'Only children with pure text could be used with ellipsis at this moment."
  292. );
  293. const content = getRenderText(
  294. ReactDOM.findDOMNode(this.wrapperRef.current) as HTMLElement,
  295. rows,
  296. children as string,
  297. extraNode,
  298. ELLIPSIS_STR,
  299. suffix,
  300. pos
  301. );
  302. if (children === content) {
  303. this.setState({ expanded: true });
  304. } else if (ellipsisContent !== content || isOverflowed !== updateOverflow) {
  305. this.setState({
  306. ellipsisContent: content,
  307. isOverflowed: updateOverflow,
  308. isTruncated: children !== content,
  309. });
  310. }
  311. return undefined;
  312. }
  313. /**
  314. * Triggered when the fold button is clicked to save the latest expanded state
  315. * @param {Event} e
  316. */
  317. toggleOverflow = (e: React.MouseEvent<HTMLAnchorElement>) => {
  318. const { onExpand, expandable, collapsible } = this.getEllipsisOpt();
  319. const { expanded } = this.state;
  320. onExpand && onExpand(!expanded, e);
  321. if ((expandable && !expanded) || (collapsible && expanded)) {
  322. this.setState({ expanded: !expanded, first: false });
  323. }
  324. };
  325. getEllipsisOpt = (): Ellipsis => {
  326. const { ellipsis } = this.props;
  327. if (!ellipsis) {
  328. return {};
  329. }
  330. const opt = {
  331. rows: 1,
  332. expandable: false,
  333. pos: 'end' as EllipsisPos,
  334. suffix: '',
  335. showTooltip: false,
  336. collapsible: false,
  337. expandText: (ellipsis as Ellipsis).expandable ? this.expandStr : undefined,
  338. collapseText: (ellipsis as Ellipsis).collapsible ? this.collapseStr : undefined,
  339. ...(typeof ellipsis === 'object' ? ellipsis : null),
  340. };
  341. return opt;
  342. };
  343. renderExpandable = () => {
  344. const { expandText, expandable, collapseText, collapsible } = this.getEllipsisOpt();
  345. const { expanded, first } = this.state;
  346. const noExpandText = !expandable && isUndefined(expandText);
  347. const noCollapseText = !collapsible && isUndefined(collapseText);
  348. let text;
  349. if (!expanded && !noExpandText) {
  350. text = expandText;
  351. } else if (expanded && !first && !noCollapseText) {
  352. // if expanded is true but the text is initally mounted, we dont show collapseText
  353. text = collapseText;
  354. }
  355. if (!noExpandText || !noCollapseText) {
  356. return (
  357. // TODO: replace `a` tag with `span` in next major version
  358. // NOTE: may have effect on style
  359. // eslint-disable-next-line jsx-a11y/anchor-is-valid
  360. <a
  361. role="button"
  362. tabIndex={0}
  363. className={`${prefixCls}-ellipsis-expand`}
  364. key="expand"
  365. ref={this.expandRef}
  366. aria-label={text}
  367. onClick={this.toggleOverflow}
  368. onKeyPress={e => isEnterPress(e) && this.toggleOverflow(e as any)}
  369. >
  370. {text}
  371. </a>
  372. );
  373. }
  374. return null;
  375. };
  376. /**
  377. * 获取文本的缩略class和style
  378. *
  379. * 截断类型:
  380. * - CSS 截断,仅在 rows=1 且没有 expandable、pos、suffix 时生效
  381. * - JS 截断,应对 CSS 无法阶段的场景
  382. * 相关变量
  383. * props:
  384. * - ellipsis:
  385. * - rows
  386. * - expandable
  387. * - pos
  388. * - suffix
  389. * state:
  390. * - isOverflowed,文本是否处于overflow状态
  391. * - expanded,文本是否处于折叠状态
  392. * - isTruncated,文本是否被js截断
  393. *
  394. * Get the abbreviated class and style of the text
  395. *
  396. * Truncation type:
  397. * -CSS truncation, which only takes effect when rows = 1 and there is no expandable, pos, suffix
  398. * -JS truncation, dealing with scenarios where CSS cannot stage
  399. * related variables
  400. * props:
  401. * -ellipsis:
  402. * -rows
  403. * -expandable
  404. * -pos
  405. * -suffix
  406. * state:
  407. * -isOverflowed, whether the text is in an overflow state
  408. * -expanded, whether the text is in a collapsed state
  409. * -isTruncated, whether the text is truncated by js
  410. * @returns {Object}
  411. */
  412. getEllipsisStyle = () => {
  413. const { ellipsis } = this.props;
  414. const { expandable } = this.getEllipsisOpt();
  415. if (!ellipsis) {
  416. return {
  417. ellipsisCls: '',
  418. ellipsisStyle: {},
  419. // ellipsisAttr: {}
  420. };
  421. }
  422. const { rows } = this.getEllipsisOpt();
  423. const { isOverflowed, expanded, isTruncated } = this.state;
  424. const useCSS = !expanded && this.canUseCSSEllipsis();
  425. const ellipsisCls = cls({
  426. [`${prefixCls}-ellipsis`]: true,
  427. [`${prefixCls}-ellipsis-single-line`]: rows === 1,
  428. [`${prefixCls}-ellipsis-multiple-line`]: rows > 1,
  429. [`${prefixCls}-ellipsis-overflow-ellipsis`]: rows === 1 && useCSS,
  430. });
  431. const ellipsisStyle = useCSS && rows > 1 ? { WebkitLineClamp: rows } : {};
  432. return {
  433. ellipsisCls,
  434. ellipsisStyle: isOverflowed ? ellipsisStyle : {},
  435. };
  436. };
  437. renderEllipsisText = (opt: Ellipsis) => {
  438. const { suffix } = opt;
  439. const { children } = this.props;
  440. const { isTruncated, expanded, isOverflowed, ellipsisContent } = this.state;
  441. if (expanded || !isTruncated) {
  442. return (
  443. <>
  444. {children}
  445. {suffix && suffix.length ? suffix : null}
  446. </>
  447. );
  448. }
  449. return (
  450. <span>
  451. {ellipsisContent}
  452. {/* {ELLIPSIS_STR} */}
  453. {suffix}
  454. </span>
  455. );
  456. };
  457. renderOperations() {
  458. return (
  459. <>
  460. {this.renderExpandable()}
  461. {this.renderCopy()}
  462. </>
  463. );
  464. }
  465. renderCopy() {
  466. const { copyable, children } = this.props;
  467. if (!copyable) {
  468. return null;
  469. }
  470. let copyContent: string;
  471. let hasObject = false;
  472. if (Array.isArray(children)) {
  473. copyContent = '';
  474. children.forEach(value => {
  475. if (typeof value === 'object') {
  476. hasObject = true;
  477. }
  478. copyContent += String(value);
  479. });
  480. } else if (typeof children !== 'object') {
  481. copyContent = String(children);
  482. } else {
  483. hasObject = true;
  484. copyContent = String(children);
  485. }
  486. warning(
  487. hasObject,
  488. 'Children in Typography is a object, it will case a [object Object] mistake when copy to clipboard.'
  489. );
  490. const copyConfig = {
  491. content: copyContent,
  492. duration: 3,
  493. ...(typeof copyable === 'object' ? copyable : null),
  494. };
  495. return <Copyable {...copyConfig} forwardRef={this.copyRef} />;
  496. }
  497. renderIcon() {
  498. const { icon, size } = this.props;
  499. if (!icon) {
  500. return null;
  501. }
  502. const iconSize: Size = size === 'small' ? 'small' : 'default';
  503. return (
  504. <span className={`${prefixCls}-icon`} x-semi-prop="icon">
  505. {isSemiIcon(icon) ? React.cloneElement((icon as React.ReactElement), { size: iconSize }) : icon}
  506. </span>
  507. );
  508. }
  509. renderContent() {
  510. const {
  511. component,
  512. children,
  513. className,
  514. type,
  515. spacing,
  516. disabled,
  517. style,
  518. ellipsis,
  519. icon,
  520. size,
  521. link,
  522. heading,
  523. ...rest
  524. } = this.props;
  525. const textProps = omit(rest, [
  526. 'strong',
  527. 'editable',
  528. 'mark',
  529. 'copyable',
  530. 'underline',
  531. 'code',
  532. // 'link',
  533. 'delete',
  534. ]);
  535. const iconNode = this.renderIcon();
  536. const ellipsisOpt = this.getEllipsisOpt();
  537. const { ellipsisCls, ellipsisStyle } = this.getEllipsisStyle();
  538. let textNode = ellipsis ? this.renderEllipsisText(ellipsisOpt) : children;
  539. const linkCls = cls({
  540. [`${prefixCls}-link-text`]: link,
  541. [`${prefixCls}-link-underline`]: this.props.underline && link,
  542. });
  543. textNode = wrapperDecorations(
  544. this.props,
  545. <>
  546. {iconNode}
  547. {this.props.link ? <span className={linkCls}>{textNode}</span> : textNode}
  548. </>
  549. );
  550. const hTagReg = /^h[1-6]$/;
  551. const wrapperCls = cls(className, ellipsisCls, {
  552. // [`${prefixCls}-primary`]: !type || type === 'primary',
  553. [`${prefixCls}-${type}`]: type && !link,
  554. [`${prefixCls}-${size}`]: size,
  555. [`${prefixCls}-link`]: link,
  556. [`${prefixCls}-disabled`]: disabled,
  557. [`${prefixCls}-${spacing}`]: spacing,
  558. [`${prefixCls}-${heading}`]: isString(heading) && hTagReg.test(heading),
  559. });
  560. return (
  561. <Typography
  562. className={wrapperCls}
  563. style={{ ...style, ...ellipsisStyle }}
  564. component={component}
  565. forwardRef={this.wrapperRef}
  566. {...textProps}
  567. >
  568. {textNode}
  569. {this.renderOperations()}
  570. </Typography>
  571. );
  572. }
  573. renderTipWrapper() {
  574. const { children } = this.props;
  575. const showTooltip = this.showTooltip();
  576. const content = this.renderContent();
  577. if (showTooltip) {
  578. const { type, opts } = showTooltip as ShowTooltip;
  579. if (type.toLowerCase() === 'popover') {
  580. return (
  581. <Popover content={children} position="top" {...opts}>
  582. {content}
  583. </Popover>
  584. );
  585. }
  586. return (
  587. <Tooltip content={children} position="top" {...opts}>
  588. {content}
  589. </Tooltip>
  590. );
  591. } else {
  592. return content;
  593. }
  594. }
  595. render() {
  596. return (
  597. <LocaleConsumer componentName="Typography">
  598. {(locale: Locale['Typography']) => {
  599. this.expandStr = locale.expand;
  600. this.collapseStr = locale.collapse;
  601. return this.renderTipWrapper();
  602. }}
  603. </LocaleConsumer>
  604. );
  605. }
  606. }