import * as React from 'react'; import toArray from 'rc-util/lib/Children/toArray'; import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; import { isValidText } from './util'; interface MeasureTextProps { style?: React.CSSProperties; children: React.ReactNode; } interface MeasureTextRef { isExceed: () => boolean; getHeight: () => number; } const MeasureText = React.forwardRef( ({ style, children }, ref) => { const spanRef = React.useRef(null); React.useImperativeHandle(ref, () => ({ isExceed: () => { const span = spanRef.current!; return span.scrollHeight > span.clientHeight; }, getHeight: () => spanRef.current!.clientHeight, })); return ( {children} ); }, ); const getNodesLen = (nodeList: React.ReactElement[]) => nodeList.reduce((totalLen, node) => totalLen + (isValidText(node) ? String(node).length : 1), 0); function sliceNodes(nodeList: React.ReactElement[], len: number) { let currLen = 0; const currentNodeList: React.ReactNode[] = []; for (let i = 0; i < nodeList.length; i += 1) { // Match to return if (currLen === len) { return currentNodeList; } const node = nodeList[i]; const canCut = isValidText(node); const nodeLen = canCut ? String(node).length : 1; const nextLen = currLen + nodeLen; // Exceed but current not which means we need cut this // This will not happen on validate ReactElement if (nextLen > len) { const restLen = len - currLen; currentNodeList.push(String(node).slice(0, restLen)); return currentNodeList; } currentNodeList.push(node); currLen = nextLen; } return nodeList; } export interface EllipsisProps { enableMeasure?: boolean; text?: React.ReactNode; width: number; rows: number; children: ( cutChildren: React.ReactNode[], /** Tell current `text` is exceed the `rows` which can be ellipsis */ canEllipsis: boolean, ) => React.ReactNode; onEllipsis: (isEllipsis: boolean) => void; expanded: boolean; /** * Mark for misc update. Which will not affect ellipsis content length. * e.g. tooltip content update. */ miscDeps: any[]; } // Measure for the `text` is exceed the `rows` or not const STATUS_MEASURE_NONE = 0; const STATUS_MEASURE_PREPARE = 1; const STATUS_MEASURE_START = 2; const STATUS_MEASURE_NEED_ELLIPSIS = 3; const STATUS_MEASURE_NO_NEED_ELLIPSIS = 4; const lineClipStyle: React.CSSProperties = { display: '-webkit-box', overflow: 'hidden', WebkitBoxOrient: 'vertical', }; export default function EllipsisMeasure(props: EllipsisProps) { const { enableMeasure, width, text, children, rows, expanded, miscDeps, onEllipsis } = props; const nodeList = React.useMemo(() => toArray(text), [text]); const nodeLen = React.useMemo(() => getNodesLen(nodeList), [text]); // ========================= Full Content ========================= // Used for measure only, which means it's always render as no need ellipsis const fullContent = React.useMemo(() => children(nodeList, false), [text]); // ========================= Cut Content ========================== const [ellipsisCutIndex, setEllipsisCutIndex] = React.useState<[number, number] | null>(null); const cutMidRef = React.useRef(null); // ========================= NeedEllipsis ========================= const measureWhiteSpaceRef = React.useRef(null); const needEllipsisRef = React.useRef(null); // Measure for `rows-1` height, to avoid operation exceed the line height const descRowsEllipsisRef = React.useRef(null); const symbolRowEllipsisRef = React.useRef(null); const [canEllipsis, setCanEllipsis] = React.useState(false); const [needEllipsis, setNeedEllipsis] = React.useState(STATUS_MEASURE_NONE); const [ellipsisHeight, setEllipsisHeight] = React.useState(0); const [parentWhiteSpace, setParentWhiteSpace] = React.useState< React.CSSProperties['whiteSpace'] | null >(null); // Trigger start measure useLayoutEffect(() => { if (enableMeasure && width && nodeLen) { setNeedEllipsis(STATUS_MEASURE_PREPARE); } else { setNeedEllipsis(STATUS_MEASURE_NONE); } }, [width, text, rows, enableMeasure, nodeList]); // Measure process useLayoutEffect(() => { if (needEllipsis === STATUS_MEASURE_PREPARE) { setNeedEllipsis(STATUS_MEASURE_START); // Parent ref `white-space` const nextWhiteSpace = measureWhiteSpaceRef.current && getComputedStyle(measureWhiteSpaceRef.current).whiteSpace; setParentWhiteSpace(nextWhiteSpace); } else if (needEllipsis === STATUS_MEASURE_START) { const isOverflow = !!needEllipsisRef.current?.isExceed(); setNeedEllipsis(isOverflow ? STATUS_MEASURE_NEED_ELLIPSIS : STATUS_MEASURE_NO_NEED_ELLIPSIS); setEllipsisCutIndex(isOverflow ? [0, nodeLen] : null); setCanEllipsis(isOverflow); // Get the basic height of ellipsis rows const baseRowsEllipsisHeight = needEllipsisRef.current?.getHeight() || 0; // Get the height of `rows - 1` + symbol height const descRowsEllipsisHeight = rows === 1 ? 0 : descRowsEllipsisRef.current?.getHeight() || 0; const symbolRowEllipsisHeight = symbolRowEllipsisRef.current?.getHeight() || 0; const maxRowsHeight = Math.max( baseRowsEllipsisHeight, // height of rows with ellipsis descRowsEllipsisHeight + symbolRowEllipsisHeight, ); setEllipsisHeight(maxRowsHeight + 1); onEllipsis(isOverflow); } }, [needEllipsis]); // ========================= Cut Measure ========================== const cutMidIndex = ellipsisCutIndex ? Math.ceil((ellipsisCutIndex[0] + ellipsisCutIndex[1]) / 2) : 0; useLayoutEffect(() => { const [minIndex, maxIndex] = ellipsisCutIndex || [0, 0]; if (minIndex !== maxIndex) { const midHeight = cutMidRef.current?.getHeight() || 0; const isOverflow = midHeight > ellipsisHeight; let targetMidIndex = cutMidIndex; if (maxIndex - minIndex === 1) { targetMidIndex = isOverflow ? minIndex : maxIndex; } setEllipsisCutIndex(isOverflow ? [minIndex, targetMidIndex] : [targetMidIndex, maxIndex]); } }, [ellipsisCutIndex, cutMidIndex]); // ========================= Text Content ========================= const finalContent = React.useMemo(() => { // Skip everything if `enableMeasure` is disabled if (!enableMeasure) { return children(nodeList, false); } if ( needEllipsis !== STATUS_MEASURE_NEED_ELLIPSIS || !ellipsisCutIndex || ellipsisCutIndex[0] !== ellipsisCutIndex[1] ) { const content = children(nodeList, false); // Limit the max line count to avoid scrollbar blink unless no need ellipsis // https://github.com/ant-design/ant-design/issues/42958 if ([STATUS_MEASURE_NO_NEED_ELLIPSIS, STATUS_MEASURE_NONE].includes(needEllipsis)) { return content; } return ( {content} ); } return children(expanded ? nodeList : sliceNodes(nodeList, ellipsisCutIndex[0]), canEllipsis); }, [expanded, needEllipsis, ellipsisCutIndex, nodeList, ...miscDeps]); // ============================ Render ============================ const measureStyle: React.CSSProperties = { width, margin: 0, padding: 0, whiteSpace: parentWhiteSpace === 'nowrap' ? 'normal' : 'inherit', }; return ( <> {/* Final show content */} {finalContent} {/* Measure if current content is exceed the rows */} {needEllipsis === STATUS_MEASURE_START && ( <> {/** With `rows` */} {fullContent} {/** With `rows - 1` */} {fullContent} {/** With `rows - 1` */} {children([], true)} )} {/* Real size overflow measure */} {needEllipsis === STATUS_MEASURE_NEED_ELLIPSIS && ellipsisCutIndex && ellipsisCutIndex[0] !== ellipsisCutIndex[1] && ( {children(sliceNodes(nodeList, cutMidIndex), true)} )} {/* Measure white-space */} {needEllipsis === STATUS_MEASURE_PREPARE && ( )} ); }