import * as React from 'react'; import toArray from 'rc-util/lib/Children/toArray'; import useIsomorphicLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; export interface EllipsisProps { enabledMeasure?: boolean; text?: React.ReactNode; width: number; fontSize: number; rows: number; children: (cutChildren: React.ReactNode[], needEllipsis: boolean) => React.ReactNode; onEllipsis: (isEllipsis: boolean) => void; } function cuttable(node: React.ReactElement) { const type = typeof node; return type === 'string' || type === 'number'; } function getNodesLen(nodeList: React.ReactElement[]) { let totalLen = 0; nodeList.forEach((node) => { if (cuttable(node)) { totalLen += String(node).length; } else { totalLen += 1; } }); return totalLen; } 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 = cuttable(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; } const NONE = 0; const PREPARE = 1; const WALKING = 2; const DONE_WITH_ELLIPSIS = 3; const DONE_WITHOUT_ELLIPSIS = 4; type WalkingState = | typeof NONE | typeof PREPARE | typeof WALKING | typeof DONE_WITH_ELLIPSIS | typeof DONE_WITHOUT_ELLIPSIS; const Ellipsis: React.FC = ({ enabledMeasure, children, text, width, fontSize, rows, onEllipsis, }) => { const [[startLen, midLen, endLen], setCutLength] = React.useState< [startLen: number, midLen: number, endLen: number] >([0, 0, 0]); // record last done with ellipsis width const [lastLen, setLastLen] = React.useState(0); const [walkingState, setWalkingState] = React.useState(NONE); const [singleRowHeight, setSingleRowHeight] = React.useState(0); const singleRowRef = React.useRef(null); const midRowRef = React.useRef(null); const nodeList = React.useMemo(() => toArray(text), [text]); const totalLen = React.useMemo(() => getNodesLen(nodeList), [nodeList]); const mergedChildren = React.useMemo(() => { if (!enabledMeasure || walkingState !== DONE_WITH_ELLIPSIS) { // if has lastLen, use it as temporary width to avoid lots of text to squeeze space. if (lastLen && walkingState !== DONE_WITHOUT_ELLIPSIS && enabledMeasure) return children(sliceNodes(nodeList, lastLen), lastLen < totalLen); return children(nodeList, false); } return children(sliceNodes(nodeList, midLen), midLen < totalLen); }, [enabledMeasure, walkingState, children, nodeList, midLen, totalLen]); // ======================== Walk ======================== useIsomorphicLayoutEffect(() => { if (enabledMeasure && width && fontSize && totalLen) { setWalkingState(PREPARE); setCutLength([0, Math.ceil(totalLen / 2), totalLen]); } }, [enabledMeasure, width, fontSize, text, totalLen, rows]); useIsomorphicLayoutEffect(() => { if (walkingState === PREPARE) { setSingleRowHeight(singleRowRef.current?.offsetHeight || 0); } }, [walkingState]); useIsomorphicLayoutEffect(() => { if (singleRowHeight) { if (walkingState === PREPARE) { // Ignore if position is enough const midHeight = midRowRef.current?.offsetHeight || 0; const maxHeight = rows * singleRowHeight; if (midHeight <= maxHeight) { setWalkingState(DONE_WITHOUT_ELLIPSIS); onEllipsis(false); } else { setWalkingState(WALKING); } } else if (walkingState === WALKING) { if (startLen !== endLen) { const midHeight = midRowRef.current?.offsetHeight || 0; const maxHeight = rows * singleRowHeight; let nextStartLen = startLen; let nextEndLen = endLen; // We reach the last round if (startLen === endLen - 1) { nextEndLen = startLen; } else if (midHeight <= maxHeight) { nextStartLen = midLen; } else { nextEndLen = midLen; } const nextMidLen = Math.ceil((nextStartLen + nextEndLen) / 2); setCutLength([nextStartLen, nextMidLen, nextEndLen]); } else { setWalkingState(DONE_WITH_ELLIPSIS); setLastLen(midLen); onEllipsis(true); } } } }, [walkingState, startLen, endLen, rows, singleRowHeight]); // ======================= Render ======================= const measureStyle: React.CSSProperties = { width, whiteSpace: 'normal', margin: 0, padding: 0, }; const renderMeasure = ( content: React.ReactNode, ref: React.Ref, style: React.CSSProperties, ) => ( {content} ); const renderMeasureSlice = (len: number, ref: React.Ref) => { const sliceNodeList = sliceNodes(nodeList, len); return renderMeasure(children(sliceNodeList, true), ref, measureStyle); }; return ( <> {mergedChildren} {/* Measure usage */} {enabledMeasure && walkingState !== DONE_WITH_ELLIPSIS && walkingState !== DONE_WITHOUT_ELLIPSIS && ( <> {/* `l` for top & `g` for bottom measure */} {renderMeasure('lg', singleRowRef, { wordBreak: 'keep-all', whiteSpace: 'nowrap' })} {/* {renderMeasureSlice(midLen, midRowRef)} */} {walkingState === PREPARE ? renderMeasure(children(nodeList, false), midRowRef, measureStyle) : renderMeasureSlice(midLen, midRowRef)} )} ); }; if (process.env.NODE_ENV !== 'production') { Ellipsis.displayName = 'Ellipsis'; } export default Ellipsis;