base.tsx 20 KB

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