base.tsx 22 KB

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