import * as React from 'react'; import EditOutlined from '@ant-design/icons/EditOutlined'; import classNames from 'classnames'; import ResizeObserver from 'rc-resize-observer'; import type { AutoSizeType } from 'rc-textarea'; import toArray from 'rc-util/lib/Children/toArray'; import useIsomorphicLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; import useMergedState from 'rc-util/lib/hooks/useMergedState'; import omit from 'rc-util/lib/omit'; import { composeRef } from 'rc-util/lib/ref'; import { isStyleSupport } from '../../_util/styleChecker'; import TransButton from '../../_util/transButton'; import { ConfigContext } from '../../config-provider'; import useLocale from '../../locale/useLocale'; import type { TooltipProps } from '../../tooltip'; import Tooltip from '../../tooltip'; import Editable from '../Editable'; import useCopyClick from '../hooks/useCopyClick'; import useMergedConfig from '../hooks/useMergedConfig'; import usePrevious from '../hooks/usePrevious'; import useUpdatedEffect from '../hooks/useUpdatedEffect'; import type { TypographyProps } from '../Typography'; import Typography from '../Typography'; import CopyBtn from './CopyBtn'; import Ellipsis from './Ellipsis'; import EllipsisTooltip from './EllipsisTooltip'; export type BaseType = 'secondary' | 'success' | 'warning' | 'danger'; export interface CopyConfig { text?: string | (() => string | Promise); onCopy?: (event?: React.MouseEvent) => void; icon?: React.ReactNode; tooltips?: React.ReactNode; format?: 'text/plain' | 'text/html'; tabIndex?: number; } interface EditConfig { text?: string; editing?: boolean; icon?: React.ReactNode; tooltip?: React.ReactNode; onStart?: () => void; onChange?: (value: string) => void; onCancel?: () => void; onEnd?: () => void; maxLength?: number; autoSize?: boolean | AutoSizeType; triggerType?: ('icon' | 'text')[]; enterIcon?: React.ReactNode; tabIndex?: number; } export interface EllipsisConfig { rows?: number; expandable?: boolean | 'collapsible'; suffix?: string; symbol?: React.ReactNode | ((expanded: boolean) => React.ReactNode); defaultExpanded?: boolean; expanded?: boolean; onExpand?: (e: React.MouseEvent, info: { expanded: boolean }) => void; onEllipsis?: (ellipsis: boolean) => void; tooltip?: React.ReactNode | TooltipProps; } export interface BlockProps extends TypographyProps { title?: string; editable?: boolean | EditConfig; copyable?: boolean | CopyConfig; type?: BaseType; disabled?: boolean; ellipsis?: boolean | EllipsisConfig; // decorations code?: boolean; mark?: boolean; underline?: boolean; delete?: boolean; strong?: boolean; keyboard?: boolean; italic?: boolean; } function wrapperDecorations( { mark, code, underline, delete: del, strong, keyboard, italic }: BlockProps, content: React.ReactNode, ) { let currentContent = content; function wrap(tag: string, needed?: boolean) { if (!needed) { return; } currentContent = React.createElement(tag, {}, currentContent); } wrap('strong', strong); wrap('u', underline); wrap('del', del); wrap('code', code); wrap('mark', mark); wrap('kbd', keyboard); wrap('i', italic); return currentContent; } const ELLIPSIS_STR = '...'; const Base = React.forwardRef((props, ref) => { const { prefixCls: customizePrefixCls, className, style, type, disabled, children, ellipsis, editable, copyable, component, title, ...restProps } = props; const { getPrefixCls, direction } = React.useContext(ConfigContext); const [textLocale] = useLocale('Text'); const typographyRef = React.useRef(null); const editIconRef = React.useRef(null); // ============================ MISC ============================ const prefixCls = getPrefixCls('typography', customizePrefixCls); const textProps = omit(restProps, [ 'mark', 'code', 'delete', 'underline', 'strong', 'keyboard', 'italic', ]); // ========================== Editable ========================== const [enableEdit, editConfig] = useMergedConfig(editable); const [editing, setEditing] = useMergedState(false, { value: editConfig.editing, }); const { triggerType = ['icon'] } = editConfig; const triggerEdit = (edit: boolean) => { if (edit) { editConfig.onStart?.(); } setEditing(edit); }; // Focus edit icon when back const prevEditing = usePrevious(editing); useUpdatedEffect(() => { if (!editing && prevEditing) { editIconRef.current?.focus(); } }, [editing]); const onEditClick = (e?: React.MouseEvent) => { e?.preventDefault(); triggerEdit(true); }; const onEditChange = (value: string) => { editConfig.onChange?.(value); triggerEdit(false); }; const onEditCancel = () => { editConfig.onCancel?.(); triggerEdit(false); }; // ========================== Copyable ========================== const [enableCopy, copyConfig] = useMergedConfig(copyable); const { copied, copyLoading, onClick: onCopyClick } = useCopyClick({ copyConfig, children }); // ========================== Ellipsis ========================== const [isLineClampSupport, setIsLineClampSupport] = React.useState(false); const [isTextOverflowSupport, setIsTextOverflowSupport] = React.useState(false); const [isJsEllipsis, setIsJsEllipsis] = React.useState(false); const [isNativeEllipsis, setIsNativeEllipsis] = React.useState(false); const [isNativeVisible, setIsNativeVisible] = React.useState(true); const [enableEllipsis, ellipsisConfig] = useMergedConfig(ellipsis, { expandable: false, symbol: (isExpanded) => (isExpanded ? textLocale?.collapse : textLocale?.expand), }); const [expanded, setExpanded] = useMergedState(ellipsisConfig.defaultExpanded || false, { value: ellipsisConfig.expanded, }); const mergedEnableEllipsis = enableEllipsis && (!expanded || ellipsisConfig.expandable === 'collapsible'); // Shared prop to reduce bundle size const { rows = 1 } = ellipsisConfig; const needMeasureEllipsis = React.useMemo( () => // Disable ellipsis mergedEnableEllipsis && // Provide suffix (ellipsisConfig.suffix !== undefined || ellipsisConfig.onEllipsis || // Can't use css ellipsis since we need to provide the place for button ellipsisConfig.expandable || enableEdit || enableCopy), [mergedEnableEllipsis, ellipsisConfig, enableEdit, enableCopy], ); useIsomorphicLayoutEffect(() => { if (enableEllipsis && !needMeasureEllipsis) { setIsLineClampSupport(isStyleSupport('webkitLineClamp')); setIsTextOverflowSupport(isStyleSupport('textOverflow')); } }, [needMeasureEllipsis, enableEllipsis]); const [cssEllipsis, setCssEllipsis] = React.useState(mergedEnableEllipsis); const canUseCssEllipsis = React.useMemo(() => { if (needMeasureEllipsis) { return false; } if (rows === 1) { return isTextOverflowSupport; } return isLineClampSupport; }, [needMeasureEllipsis, isTextOverflowSupport, isLineClampSupport]); // We use effect to change from css ellipsis to js ellipsis. // To make SSR still can see the ellipsis. useIsomorphicLayoutEffect(() => { setCssEllipsis(canUseCssEllipsis && mergedEnableEllipsis); }, [canUseCssEllipsis, mergedEnableEllipsis]); const isMergedEllipsis = mergedEnableEllipsis && (cssEllipsis ? isNativeEllipsis : isJsEllipsis); const cssTextOverflow = mergedEnableEllipsis && rows === 1 && cssEllipsis; const cssLineClamp = mergedEnableEllipsis && rows > 1 && cssEllipsis; // >>>>> Expand const onExpandClick: EllipsisConfig['onExpand'] = (e, info) => { setExpanded(info.expanded); ellipsisConfig.onExpand?.(e, info); }; const [ellipsisWidth, setEllipsisWidth] = React.useState(0); const onResize = ({ offsetWidth }: { offsetWidth: number }) => { setEllipsisWidth(offsetWidth); }; // >>>>> JS Ellipsis const onJsEllipsis = (jsEllipsis: boolean) => { setIsJsEllipsis(jsEllipsis); // Trigger if changed if (isJsEllipsis !== jsEllipsis) { ellipsisConfig.onEllipsis?.(jsEllipsis); } }; // >>>>> Native ellipsis React.useEffect(() => { const textEle = typographyRef.current; if (enableEllipsis && cssEllipsis && textEle) { const currentEllipsis = cssLineClamp ? textEle.offsetHeight < textEle.scrollHeight : textEle.offsetWidth < textEle.scrollWidth; if (isNativeEllipsis !== currentEllipsis) { setIsNativeEllipsis(currentEllipsis); } } }, [enableEllipsis, cssEllipsis, children, cssLineClamp, isNativeVisible, ellipsisWidth]); // https://github.com/ant-design/ant-design/issues/36786 // Use IntersectionObserver to check if element is invisible React.useEffect(() => { const textEle = typographyRef.current; if ( typeof IntersectionObserver === 'undefined' || !textEle || !cssEllipsis || !mergedEnableEllipsis ) { return; } /* eslint-disable-next-line compat/compat */ const observer = new IntersectionObserver(() => { setIsNativeVisible(!!textEle.offsetParent); }); observer.observe(textEle!); return () => { observer.disconnect(); }; }, [cssEllipsis, mergedEnableEllipsis]); // ========================== Tooltip =========================== let tooltipProps: TooltipProps = {}; if (ellipsisConfig.tooltip === true) { tooltipProps = { title: editConfig.text ?? children }; } else if (React.isValidElement(ellipsisConfig.tooltip)) { tooltipProps = { title: ellipsisConfig.tooltip }; } else if (typeof ellipsisConfig.tooltip === 'object') { tooltipProps = { title: editConfig.text ?? children, ...ellipsisConfig.tooltip }; } else { tooltipProps = { title: ellipsisConfig.tooltip }; } const topAriaLabel = React.useMemo(() => { const isValid = (val: any): val is string | number => ['string', 'number'].includes(typeof val); if (!enableEllipsis || cssEllipsis) { return undefined; } if (isValid(editConfig.text)) { return editConfig.text; } if (isValid(children)) { return children; } if (isValid(title)) { return title; } if (isValid(tooltipProps.title)) { return tooltipProps.title; } return undefined; }, [enableEllipsis, cssEllipsis, title, tooltipProps.title, isMergedEllipsis]); // =========================== Render =========================== // >>>>>>>>>>> Editing input if (editing) { return ( ); } // >>>>>>>>>>> Typography // Expand const renderExpand = () => { const { expandable, symbol } = ellipsisConfig; if (!expandable) { return null; } if (expanded && expandable !== 'collapsible') { return null; } return ( onExpandClick(e!, { expanded: !expanded })} aria-label={expanded ? textLocale.collapse : textLocale?.expand} > {typeof symbol === 'function' ? symbol(expanded) : symbol} ); }; // Edit const renderEdit = () => { if (!enableEdit) { return; } const { icon, tooltip, tabIndex } = editConfig; const editTitle = toArray(tooltip)[0] || textLocale?.edit; const ariaLabel = typeof editTitle === 'string' ? editTitle : ''; return triggerType.includes('icon') ? ( {icon || } ) : null; }; // Copy const renderCopy = () => { if (!enableCopy) { return null; } return ( ); }; const renderOperations = (canEllipsis: boolean) => [ // (renderExpanded || ellipsisConfig.collapsible) && renderExpand(), canEllipsis && renderExpand(), renderEdit(), renderCopy(), ]; const renderEllipsis = (canEllipsis: boolean) => [ canEllipsis && !expanded && ( {ELLIPSIS_STR} ), ellipsisConfig.suffix, renderOperations(canEllipsis), ]; return ( {(resizeRef: React.RefObject) => ( {(node, canEllipsis) => wrapperDecorations( props, <> {node.length > 0 && canEllipsis && !expanded && topAriaLabel ? ( {node} ) : ( node )} {renderEllipsis(canEllipsis)} , ) } )} ); }); export default Base;