123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634 |
- import React, { Component } from 'react';
- import ReactDOM from 'react-dom';
- import cls from 'classnames';
- import PropTypes from 'prop-types';
- import { cssClasses, strings } from '@douyinfe/semi-foundation/typography/constants';
- import Typography from './typography';
- import Copyable from './copyable';
- import { IconSize as Size } from '../icons/index';
- import { isUndefined, omit, merge, isString } from 'lodash';
- import Tooltip from '../tooltip/index';
- import Popover from '../popover/index';
- import getRenderText from './util';
- import warning from '@douyinfe/semi-foundation/utils/warning';
- import isEnterPress from '@douyinfe/semi-foundation/utils/isEnterPress';
- import LocaleConsumer from '../locale/localeConsumer';
- import { Locale } from '../locale/interface';
- import { Ellipsis, EllipsisPos, ShowTooltip, TypographyBaseSize, TypographyBaseType } from './interface';
- import { CopyableConfig, LinkType } from './title';
- import { BaseProps } from '../_base/baseComponent';
- import { isSemiIcon } from '../_utils';
- export interface BaseTypographyProps extends BaseProps {
- copyable?: CopyableConfig | boolean;
- delete?: boolean;
- disabled?: boolean;
- icon?: React.ReactNode;
- ellipsis?: Ellipsis | boolean;
- mark?: boolean;
- underline?: boolean;
- link?: LinkType;
- strong?: boolean;
- type?: TypographyBaseType;
- size?: TypographyBaseSize;
- style?: React.CSSProperties;
- className?: string;
- code?: boolean;
- children?: React.ReactNode;
- component?: React.ElementType;
- spacing?: string;
- heading?: string;
- }
- interface BaseTypographyState {
- editable: boolean;
- copied: boolean;
- isOverflowed: boolean;
- ellipsisContent: string;
- expanded: boolean;
- isTruncated: boolean;
- first: boolean;
- prevChildren: React.ReactNode;
- }
- const prefixCls = cssClasses.PREFIX;
- const ELLIPSIS_STR = '...';
- const wrapperDecorations = (props: BaseTypographyProps, content: React.ReactNode) => {
- const { mark, code, underline, strong, link, disabled } = props;
- let wrapped = content;
- const wrap = (isNeeded: boolean | LinkType, tag: string) => {
- let wrapProps = {};
- if (!isNeeded) {
- return;
- }
- if (typeof isNeeded === 'object') {
- wrapProps = { ...isNeeded };
- }
- wrapped = React.createElement(tag, wrapProps, wrapped);
- };
- wrap(mark, 'mark');
- wrap(code, 'code');
- wrap(underline && !link, 'u');
- wrap(strong, 'strong');
- wrap(props.delete, 'del');
- wrap(link, disabled ? 'span' : 'a');
- return wrapped;
- };
- export default class Base extends Component<BaseTypographyProps, BaseTypographyState> {
- static propTypes = {
- children: PropTypes.node,
- copyable: PropTypes.oneOfType([
- PropTypes.shape({
- text: PropTypes.string,
- onCopy: PropTypes.func,
- successTip: PropTypes.node,
- copyTip: PropTypes.node,
- }),
- PropTypes.bool,
- ]),
- delete: PropTypes.bool,
- disabled: PropTypes.bool,
- // editable: PropTypes.bool,
- ellipsis: PropTypes.oneOfType([
- PropTypes.shape({
- rows: PropTypes.number,
- expandable: PropTypes.bool,
- expandText: PropTypes.string,
- onExpand: PropTypes.func,
- suffix: PropTypes.string,
- showTooltip: PropTypes.oneOfType([
- PropTypes.shape({
- type: PropTypes.string,
- opts: PropTypes.object,
- }),
- PropTypes.bool,
- ]),
- collapsible: PropTypes.bool,
- collapseText: PropTypes.string,
- pos: PropTypes.oneOf(['end', 'middle']),
- }),
- PropTypes.bool,
- ]),
- mark: PropTypes.bool,
- underline: PropTypes.bool,
- link: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]),
- spacing: PropTypes.oneOf(strings.SPACING),
- strong: PropTypes.bool,
- size: PropTypes.oneOf(strings.SIZE),
- type: PropTypes.oneOf(strings.TYPE),
- style: PropTypes.object,
- className: PropTypes.string,
- icon: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
- heading: PropTypes.string,
- component: PropTypes.string,
- };
- static defaultProps = {
- children: null as React.ReactNode,
- copyable: false,
- delete: false,
- disabled: false,
- // editable: false,
- ellipsis: false,
- icon: '',
- mark: false,
- underline: false,
- strong: false,
- link: false,
- type: 'primary',
- spacing: 'normal',
- size: 'normal',
- style: {},
- className: '',
- };
- wrapperRef: React.RefObject<any>;
- expandRef: React.RefObject<any>;
- copyRef: React.RefObject<any>;
- rafId: ReturnType<typeof requestAnimationFrame>;
- expandStr: string;
- collapseStr: string;
- constructor(props: BaseTypographyProps) {
- super(props);
- this.state = {
- editable: false,
- copied: false,
- // ellipsis
- // if text is overflow in container
- isOverflowed: true,
- ellipsisContent: null,
- expanded: false,
- // if text is truncated with js
- isTruncated: false,
- // record if has click expanded
- first: true,
- prevChildren: null,
- };
- this.wrapperRef = React.createRef();
- this.expandRef = React.createRef();
- this.copyRef = React.createRef();
- }
- componentDidMount() {
- if (this.props.ellipsis) {
- this.getEllipsisState();
- window.addEventListener('resize', this.onResize);
- }
- }
- static getDerivedStateFromProps(props: BaseTypographyProps, prevState: BaseTypographyState) {
- const { prevChildren } = prevState;
- const newState: Partial<BaseTypographyState> = {};
- newState.prevChildren = props.children;
- if (props.ellipsis && prevChildren !== props.children) {
- // reset ellipsis state if children update
- newState.isOverflowed = true;
- newState.ellipsisContent = null;
- newState.expanded = false;
- newState.isTruncated = false;
- newState.first = true;
- }
- return newState;
- }
- componentDidUpdate(prevProps: BaseTypographyProps) {
- // Render was based on outdated refs and needs to be rerun
- if (this.props.children !== prevProps.children) {
- this.forceUpdate();
- if (this.props.ellipsis) {
- this.getEllipsisState();
- }
- }
- }
- componentWillUnmount() {
- if (this.props.ellipsis) {
- window.removeEventListener('resize', this.onResize);
- }
- if (this.rafId) {
- window.cancelAnimationFrame(this.rafId);
- }
- }
- onResize = () => {
- if (this.rafId) {
- window.cancelAnimationFrame(this.rafId);
- }
- this.rafId = window.requestAnimationFrame(this.getEllipsisState.bind(this));
- };
- // if need to use js overflowed:
- // 1. text is expandable 2. expandText need to be shown 3. has extra operation 4. text need to ellipse from mid
- canUseCSSEllipsis = () => {
- const { copyable } = this.props;
- const { expandable, expandText, pos, suffix } = this.getEllipsisOpt();
- return !expandable && isUndefined(expandText) && !copyable && pos === 'end' && !suffix.length;
- };
- /**
- * whether truncated
- * rows < = 1 if there is overflow content, return true
- * rows > 1 if there is overflow height, return true
- * @param {Number} rows
- * @returns {Boolean}
- */
- shouldTruncated = (rows: number) => {
- if (!rows || rows < 1) {
- return false;
- }
- const updateOverflow =
- rows <= 1 ?
- this.wrapperRef.current.scrollWidth > this.wrapperRef.current.clientWidth :
- this.wrapperRef.current.scrollHeight > this.wrapperRef.current.offsetHeight;
- return updateOverflow;
- };
- showTooltip = () => {
- const { isOverflowed, isTruncated, expanded } = this.state;
- const { showTooltip, expandable, expandText } = this.getEllipsisOpt();
- const overflowed = !expanded && (isOverflowed || isTruncated);
- const noExpandText = !expandable && isUndefined(expandText);
- const show = noExpandText && overflowed && showTooltip;
- if (!show) {
- return show;
- }
- const defaultOpts = {
- type: 'tooltip',
- opts: {},
- };
- if (typeof showTooltip === 'object') {
- if (showTooltip.type && showTooltip.type.toLowerCase() === 'popover') {
- return merge(
- {
- opts: {
- style: { width: '240px' },
- showArrow: true,
- },
- },
- showTooltip
- );
- }
- return { ...defaultOpts, ...showTooltip };
- }
- return defaultOpts;
- };
- getEllipsisState() {
- const { rows, suffix, pos } = this.getEllipsisOpt();
- const { children } = this.props;
- // wait until element mounted
- if (!this.wrapperRef || !this.wrapperRef.current) {
- this.onResize();
- return false;
- }
- const { ellipsisContent, isOverflowed, isTruncated, expanded } = this.state;
- const updateOverflow = this.shouldTruncated(rows);
- const canUseCSSEllipsis = this.canUseCSSEllipsis();
- const needUpdate = updateOverflow !== isOverflowed;
- if (!rows || rows < 0 || expanded) {
- return undefined;
- }
- if (canUseCSSEllipsis) {
- if (needUpdate) {
- this.setState({ expanded: !updateOverflow });
- }
- return undefined;
- }
- const extraNode = [this.expandRef.current, this.copyRef && this.copyRef.current];
- warning(
- 'children' in this.props && typeof children !== 'string',
- "[Semi Typography] 'Only children with pure text could be used with ellipsis at this moment."
- );
- const content = getRenderText(
- ReactDOM.findDOMNode(this.wrapperRef.current) as HTMLElement,
- rows,
- children as string,
- extraNode,
- ELLIPSIS_STR,
- suffix,
- pos
- );
- if (children === content) {
- this.setState({ expanded: true });
- } else if (ellipsisContent !== content || isOverflowed !== updateOverflow) {
- this.setState({
- ellipsisContent: content,
- isOverflowed: updateOverflow,
- isTruncated: children !== content,
- });
- }
- return undefined;
- }
- /**
- * Triggered when the fold button is clicked to save the latest expanded state
- * @param {Event} e
- */
- toggleOverflow = (e: React.MouseEvent<HTMLAnchorElement>) => {
- const { onExpand, expandable, collapsible } = this.getEllipsisOpt();
- const { expanded } = this.state;
- onExpand && onExpand(!expanded, e);
- if ((expandable && !expanded) || (collapsible && expanded)) {
- this.setState({ expanded: !expanded, first: false });
- }
- };
- getEllipsisOpt = (): Ellipsis => {
- const { ellipsis } = this.props;
- if (!ellipsis) {
- return {};
- }
- const opt = {
- rows: 1,
- expandable: false,
- pos: 'end' as EllipsisPos,
- suffix: '',
- showTooltip: false,
- collapsible: false,
- expandText: (ellipsis as Ellipsis).expandable ? this.expandStr : undefined,
- collapseText: (ellipsis as Ellipsis).collapsible ? this.collapseStr : undefined,
- ...(typeof ellipsis === 'object' ? ellipsis : null),
- };
- return opt;
- };
- renderExpandable = () => {
- const { expandText, expandable, collapseText, collapsible } = this.getEllipsisOpt();
- const { expanded, first } = this.state;
- const noExpandText = !expandable && isUndefined(expandText);
- const noCollapseText = !collapsible && isUndefined(collapseText);
- let text;
- if (!expanded && !noExpandText) {
- text = expandText;
- } else if (expanded && !first && !noCollapseText) {
- // if expanded is true but the text is initally mounted, we dont show collapseText
- text = collapseText;
- }
- if (!noExpandText || !noCollapseText) {
- return (
- // TODO: replace `a` tag with `span` in next major version
- // NOTE: may have effect on style
- // eslint-disable-next-line jsx-a11y/anchor-is-valid
- <a
- role="button"
- tabIndex={0}
- className={`${prefixCls}-ellipsis-expand`}
- key="expand"
- ref={this.expandRef}
- aria-label={text}
- onClick={this.toggleOverflow}
- onKeyPress={e => isEnterPress(e) && this.toggleOverflow(e as any)}
- >
- {text}
- </a>
- );
- }
- return null;
- };
- /**
- * 获取文本的缩略class和style
- *
- * 截断类型:
- * - CSS 截断,仅在 rows=1 且没有 expandable、pos、suffix 时生效
- * - JS 截断,应对 CSS 无法阶段的场景
- * 相关变量
- * props:
- * - ellipsis:
- * - rows
- * - expandable
- * - pos
- * - suffix
- * state:
- * - isOverflowed,文本是否处于overflow状态
- * - expanded,文本是否处于折叠状态
- * - isTruncated,文本是否被js截断
- *
- * Get the abbreviated class and style of the text
- *
- * Truncation type:
- * -CSS truncation, which only takes effect when rows = 1 and there is no expandable, pos, suffix
- * -JS truncation, dealing with scenarios where CSS cannot stage
- * related variables
- * props:
- * -ellipsis:
- * -rows
- * -expandable
- * -pos
- * -suffix
- * state:
- * -isOverflowed, whether the text is in an overflow state
- * -expanded, whether the text is in a collapsed state
- * -isTruncated, whether the text is truncated by js
- * @returns {Object}
- */
- getEllipsisStyle = () => {
- const { ellipsis } = this.props;
- const { expandable } = this.getEllipsisOpt();
- if (!ellipsis) {
- return {
- ellipsisCls: '',
- ellipsisStyle: {},
- // ellipsisAttr: {}
- };
- }
- const { rows } = this.getEllipsisOpt();
- const { isOverflowed, expanded, isTruncated } = this.state;
- const useCSS = !expanded && this.canUseCSSEllipsis();
- const ellipsisCls = cls({
- [`${prefixCls}-ellipsis`]: true,
- [`${prefixCls}-ellipsis-single-line`]: rows === 1,
- [`${prefixCls}-ellipsis-multiple-line`]: rows > 1,
- [`${prefixCls}-ellipsis-overflow-ellipsis`]: rows === 1 && useCSS,
- });
- const ellipsisStyle = useCSS && rows > 1 ? { WebkitLineClamp: rows } : {};
- return {
- ellipsisCls,
- ellipsisStyle: isOverflowed ? ellipsisStyle : {},
- };
- };
- renderEllipsisText = (opt: Ellipsis) => {
- const { suffix } = opt;
- const { children } = this.props;
- const { isTruncated, expanded, isOverflowed, ellipsisContent } = this.state;
- if (expanded || !isTruncated) {
- return (
- <>
- {children}
- {suffix && suffix.length ? suffix : null}
- </>
- );
- }
- return (
- <span>
- {ellipsisContent}
- {/* {ELLIPSIS_STR} */}
- {suffix}
- </span>
- );
- };
- renderOperations() {
- return (
- <>
- {this.renderExpandable()}
- {this.renderCopy()}
- </>
- );
- }
- renderCopy() {
- const { copyable, children } = this.props;
- if (!copyable) {
- return null;
- }
- let copyContent: string;
- let hasObject = false;
- if (Array.isArray(children)) {
- copyContent = '';
- children.forEach(value => {
- if (typeof value === 'object') {
- hasObject = true;
- }
- copyContent += String(value);
- });
- } else if (typeof children !== 'object') {
- copyContent = String(children);
- } else {
- hasObject = true;
- copyContent = String(children);
- }
- warning(
- hasObject,
- 'Children in Typography is a object, it will case a [object Object] mistake when copy to clipboard.'
- );
- const copyConfig = {
- content: copyContent,
- duration: 3,
- ...(typeof copyable === 'object' ? copyable : null),
- };
- return <Copyable {...copyConfig} forwardRef={this.copyRef} />;
- }
- renderIcon() {
- const { icon, size } = this.props;
- if (!icon) {
- return null;
- }
- const iconSize: Size = size === 'small' ? 'small' : 'default';
- return (
- <span className={`${prefixCls}-icon`}>
- {isSemiIcon(icon) ? React.cloneElement((icon as React.ReactElement), { size: iconSize }) : icon}
- </span>
- );
- }
- renderContent() {
- const {
- component,
- children,
- className,
- type,
- spacing,
- disabled,
- style,
- ellipsis,
- icon,
- size,
- link,
- heading,
- ...rest
- } = this.props;
- const textProps = omit(rest, [
- 'strong',
- 'editable',
- 'mark',
- 'copyable',
- 'underline',
- 'code',
- // 'link',
- 'delete',
- ]);
- const iconNode = this.renderIcon();
- const ellipsisOpt = this.getEllipsisOpt();
- const { ellipsisCls, ellipsisStyle } = this.getEllipsisStyle();
- let textNode = ellipsis ? this.renderEllipsisText(ellipsisOpt) : children;
- const linkCls = cls({
- [`${prefixCls}-link-text`]: link,
- [`${prefixCls}-link-underline`]: this.props.underline && link,
- });
- textNode = wrapperDecorations(
- this.props,
- <>
- {iconNode}
- {this.props.link ? <span className={linkCls}>{textNode}</span> : textNode}
- </>
- );
- const hTagReg = /^h[1-6]$/;
- const wrapperCls = cls(className, ellipsisCls, {
- // [`${prefixCls}-primary`]: !type || type === 'primary',
- [`${prefixCls}-${type}`]: type && !link,
- [`${prefixCls}-${size}`]: size,
- [`${prefixCls}-link`]: link,
- [`${prefixCls}-disabled`]: disabled,
- [`${prefixCls}-${spacing}`]: spacing,
- [`${prefixCls}-${heading}`]: isString(heading) && hTagReg.test(heading),
- });
- return (
- <Typography
- className={wrapperCls}
- style={{ ...style, ...ellipsisStyle }}
- component={component}
- forwardRef={this.wrapperRef}
- {...textProps}
- >
- {textNode}
- {this.renderOperations()}
- </Typography>
- );
- }
- renderTipWrapper() {
- const { children } = this.props;
- const showTooltip = this.showTooltip();
- const content = this.renderContent();
- if (showTooltip) {
- const { type, opts } = showTooltip as ShowTooltip;
- if (type.toLowerCase() === 'popover') {
- return (
- <Popover content={children} position="top" {...opts}>
- {content}
- </Popover>
- );
- }
- return (
- <Tooltip content={children} position="top" {...opts}>
- {content}
- </Tooltip>
- );
- } else {
- return content;
- }
- }
- render() {
- return (
- <LocaleConsumer componentName="Typography">
- {(locale: Locale['Typography']) => {
- this.expandStr = locale.expand;
- this.collapseStr = locale.collapse;
- return this.renderTipWrapper();
- }}
- </LocaleConsumer>
- );
- }
- }
|