diff --git a/src/catalogue/components/catalogue.tsx b/src/catalogue/components/catalogue.tsx index 1af83225b..4118020f8 100644 --- a/src/catalogue/components/catalogue.tsx +++ b/src/catalogue/components/catalogue.tsx @@ -103,7 +103,7 @@ const Catalogue = = {}, T extends readOnlyTab = a return (
- +
{edit && renderNodeHover(item)}
diff --git a/src/chat/conversations/groupTitle/index.tsx b/src/chat/conversations/groupTitle/index.tsx index 71b89822a..855d16cb9 100644 --- a/src/chat/conversations/groupTitle/index.tsx +++ b/src/chat/conversations/groupTitle/index.tsx @@ -14,7 +14,7 @@ const GroupTitle: React.FC = (props) => { maxWidth="100%" placement="right" destroyTooltipOnHide - watchParentSizeChange + dynamic /> )} diff --git a/src/chat/conversations/item/index.tsx b/src/chat/conversations/item/index.tsx index c2dea873d..9e8bf6c90 100644 --- a/src/chat/conversations/item/index.tsx +++ b/src/chat/conversations/item/index.tsx @@ -44,7 +44,7 @@ const Item: React.FC = (props) => { {info.icon &&
{info.icon}
}
{ fireEvent.mouseLeave(element); }); }); + +describe('use Typography by default (dynamic not set)', () => { + afterEach(() => { + cleanup(); + }); + + test('renders antd Typography.Text instead of the measured span', () => { + const { container, getByText } = render(); + const { value } = defaultProps; + + expect(getByText(value)).toBeInTheDocument(); + expect(getByText(value).className.split(' ')).toContain('ant-typography'); + expect(container.querySelector('.dtc-ellipsis-text')).toBeNull(); + }); + + test('renders Typography.Text when dynamic prop is omitted', () => { + const { container } = render(); + expect(container.querySelector('.ant-typography')).not.toBeNull(); + expect(container.querySelector('.dtc-ellipsis-text')).toBeNull(); + }); + + test('renders content when value is a function', () => { + const { getByText } = render( '函数返回值'} dynamic={false} />); + expect(getByText('函数返回值')).toBeInTheDocument(); + }); +}); diff --git a/src/ellipsisText/__tests__/useEllipsisTextStyle.test.tsx b/src/ellipsisText/__tests__/useEllipsisTextStyle.test.tsx new file mode 100644 index 000000000..4a3d9375c --- /dev/null +++ b/src/ellipsisText/__tests__/useEllipsisTextStyle.test.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { cleanup, render } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react-hooks'; + +import useEllipsisTextStyle from '../useEllipsisTextStyle'; +import { getRangeWidth, getStyle, getTextContainerWidth } from '../utils'; + +jest.mock('../utils', () => { + const actual = jest.requireActual('../utils'); + return { + ...actual, + getValidContainerElement: jest.fn(), + getRangeWidth: jest.fn(), + getStyle: jest.fn(), + getTextContainerWidth: jest.fn(), + }; +}); + +describe('Test useEllipsisTextStyle', () => { + const mockGetRangeWidth = getRangeWidth as jest.Mock; + const mockGetStyle = getStyle as jest.Mock; + const mockGetTextContainerWidth = getTextContainerWidth as jest.Mock; + + beforeEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + it('should return a ref, overflow state, style, and trigger function', () => { + const { result } = renderHook(() => useEllipsisTextStyle('Test Text')); + const [ref, isOverflow, style, updateTextStyle] = result.current; + + expect(ref).toBeInstanceOf(Object); + expect(typeof isOverflow).toBe('boolean'); + expect(style).toBeInstanceOf(Object); + expect(typeof updateTextStyle).toBe('function'); + }); + + it('should calculate overflow correctly', () => { + const { result } = renderHook(() => useEllipsisTextStyle('Test Text')); + const [ref, , , updateTextStyle] = result.current; + + render(); + + mockGetRangeWidth.mockReturnValue(150); + mockGetTextContainerWidth.mockReturnValue(100); + + act(() => { + updateTextStyle(); + }); + + const [, isOverflow, style] = result.current; + + expect(mockGetTextContainerWidth).toHaveBeenCalled(); + expect(isOverflow).toBe(true); + expect(style.maxWidth).toBe(100); + }); + + it('should not overflow if the container width is sufficient', () => { + const { result } = renderHook(() => useEllipsisTextStyle('Test Text')); + const [ref, , , updateTextStyle] = result.current; + + render(); + + mockGetRangeWidth.mockReturnValue(80); + mockGetTextContainerWidth.mockReturnValue(100); + + act(() => { + updateTextStyle(); + }); + const [, isOverflow, style] = result.current; + + expect(isOverflow).toBe(false); + expect(style.maxWidth).toBe(100); + }); + + it('should inherit cursor style from parent', () => { + const { result } = renderHook(() => useEllipsisTextStyle('Test Text')); + const [ref, , , updateTextStyle] = result.current; + + render(); + + mockGetTextContainerWidth.mockReturnValue(100); + mockGetStyle.mockReturnValue('pointer'); + + act(() => { + updateTextStyle(); + }); + const [, , style] = result.current; + + expect(style.cursor).toBe('pointer'); + }); + + it('should have cursor style as default when text is not overflowing and parent cursor is default', () => { + const { result } = renderHook(() => useEllipsisTextStyle('Test Text')); + const [ref, , , updateTextStyle] = result.current; + + render(); + + mockGetRangeWidth.mockReturnValue(80); + mockGetTextContainerWidth.mockReturnValue(100); + mockGetStyle.mockReturnValue('default'); + + act(() => { + updateTextStyle(); + }); + const [, isOverflow, style] = result.current; + + expect(isOverflow).toBe(false); + expect(style.cursor).toBe('default'); + }); + + it('should have cursor style as pointer when text is overflowing and parent cursor is default', () => { + const { result } = renderHook(() => useEllipsisTextStyle('Test Text')); + const [ref, , , updateTextStyle] = result.current; + + render(); + + mockGetRangeWidth.mockReturnValue(150); + mockGetTextContainerWidth.mockReturnValue(100); + mockGetStyle.mockReturnValue('default'); + + act(() => { + updateTextStyle(); + }); + const [, isOverflow, style] = result.current; + + expect(isOverflow).toBe(true); + expect(style.cursor).toBe('pointer'); + }); + + it('should set container width when container width < maxWidth', () => { + const { result } = renderHook(() => useEllipsisTextStyle('Test Text', 120)); + const [ref, , , updateTextStyle] = result.current; + + render(); + + mockGetTextContainerWidth.mockReturnValue(100); + + act(() => { + updateTextStyle(); + }); + const [, , style] = result.current; + + expect(style.maxWidth).toBe(100); + }); + + it('should set maxWidth when container width > maxWidth ', () => { + const { result } = renderHook(() => useEllipsisTextStyle('Test Text', 80)); + const [ref, , , updateTextStyle] = result.current; + + render(); + + mockGetTextContainerWidth.mockReturnValue(80); + + act(() => { + updateTextStyle(); + }); + const [, , style] = result.current; + + expect(style.maxWidth).toBe(80); + }); +}); diff --git a/src/ellipsisText/demos/dynamic.tsx b/src/ellipsisText/demos/dynamic.tsx new file mode 100644 index 000000000..fa3b09d5c --- /dev/null +++ b/src/ellipsisText/demos/dynamic.tsx @@ -0,0 +1,38 @@ +import React, { useState } from 'react'; +import { Radio, Slider } from 'antd'; +import { EllipsisText } from 'dt-react-component'; + +const LONG_TEXT = + '这是一段很长的文本,用来对比默认省略与动态测量的差异,你可以尝试拖动滑块和切换模式来观察变化'; + +export default () => { + const [mode, setMode] = useState<'default' | 'dynamic'>('default'); + const [maxWidth, setMaxWidth] = useState(180); + const dynamic = mode === 'dynamic'; + + return ( +
+ setMode(e.target.value)}> + 默认(Typography 轻量省略) + dynamic(动态测量) + +
+
maxWidth:{maxWidth}px(仅 dynamic 时生效,默认模式下置灰)
+ +
+
+ {dynamic ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/src/ellipsisText/demos/flex.tsx b/src/ellipsisText/demos/flex.tsx index 610a23f7f..3432f3994 100644 --- a/src/ellipsisText/demos/flex.tsx +++ b/src/ellipsisText/demos/flex.tsx @@ -11,6 +11,7 @@ export default () => { }} > { > 我是来捣乱的 { > { { }} > { + const [options, setOptions] = React.useState<{ label: string; value: string }[]>([]); + + const sleep = (time: number) => { + return new Promise((resolve) => { + setTimeout(resolve, time); + }); + }; + + const getOptions = async () => { + await sleep(2000); + const opt = [ + { + label: '任务名称', + value: 'task-name-1', + }, + { + label: '这是一个较长的任务名称', + value: 'task-name-2', + }, + { + label: '这是一段特别长的任务名称', + value: 'task-name-3', + }, + { + label: '这是一段特别长的任务名称,用于验证在文本过长时展示省略号', + value: 'task-name-4', + }, + { + label: '这是一段特别长的任务名称,用于验证在文本过长时优雅地展示省略号,并通过悬停查看完整内容', + value: 'task-name-5', + }, + ]; + setOptions(opt); + }; + + useEffect(() => { + getOptions(); + }, []); + + return ( +
+ +
+ ); +}; diff --git a/src/ellipsisText/demos/watchParent.tsx b/src/ellipsisText/demos/watchParent.tsx deleted file mode 100644 index 00c277342..000000000 --- a/src/ellipsisText/demos/watchParent.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React, { useState } from 'react'; -import { Divider, Radio, RadioChangeEvent } from 'antd'; -import { EllipsisText } from 'dt-react-component'; - -export default () => { - const [width, setWidth] = useState(200); - const onChange = (e: RadioChangeEvent) => { - setWidth(e.target.value); - }; - - return ( - <> - - 200px - 500px - - -
- -
- - ); -}; diff --git a/src/ellipsisText/index.md b/src/ellipsisText/index.md index a658adfed..7b46f4eda 100644 --- a/src/ellipsisText/index.md +++ b/src/ellipsisText/index.md @@ -10,31 +10,33 @@ demo: ## 何时使用 -用于长文本省略,根据最近块级父元素自动计算文本最大宽度,也可以手动传最大宽度,hover 显示完整内容。 +用于长文本省略。默认使用 antd `Typography.Text` 的 `ellipsis` 做轻量省略并悬停展示完整内容;当需要精确计算宽度(如 `maxWidth` 传字符串/百分比/`calc(100% - x px)`,或处于 flex / Select 等容器场景)时,通过 `dynamic` 开关开启动态测量,并自动监听容器尺寸变化重新计算。 ## 注意 -- 宽度的计算过程是一个比较耗时的动作,同一页面内该组件使用次数保持在 100 个以内,如果存在性能问题,考虑先支持虚拟滚动等技术后,在使用该组件。 +- 默认(不传 `dynamic`)走 `Typography.Text` 分支,性能更优、无需测量。 +- 开启 `dynamic` 后,宽度的计算是一个比较耗时的动作,同一页面内该组件使用次数保持在 100 个以内,如果存在性能问题,考虑先支持虚拟滚动等技术后,在使用该组件。 ## 示例 - - - + + + + - + ## API -| 参数 | 说明 | 类型 | 默认值 | -| --------------------- | ------------------------------------------ | -------------------------------- | ------ | -| value | 显示文本内容 | `ReactNode \| () => ReactNode` | - | -| title | 提示文字 | `ReactNode \| () => ReactNode` | value | -| className | 为文本内容所在节点添加自定义样式名 | `string` | - | -| maxWidth | 文本内容的最大宽度 | `string \| number` | - | -| watchParentSizeChange | 监听父元素大小的变更,默认监听 window 窗口 | ` boolean` | false | +| 参数 | 说明 | 类型 | 默认值 | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------ | +| value | 显示文本内容 | `ReactNode \| () => ReactNode` | - | +| title | 提示文字 | `ReactNode \| () => ReactNode` | value | +| className | 为文本内容所在节点添加自定义样式名 | `string` | - | +| dynamic | 是否开启动态测量计算宽度。true 时支持 `maxWidth`/Tooltip 透传,并自动监听容器尺寸变化重测;false 时使用 antd `Typography.Text` 做轻量省略 | `boolean` | false | +| maxWidth | 文本内容的最大宽度(仅 `dynamic` 时生效) | `string \| number` | - | :::info 其余参数继承自 [继承 antd4.x 的 Tooltip](https://4x.ant.design/components/tooltip-cn/#API) diff --git a/src/ellipsisText/index.tsx b/src/ellipsisText/index.tsx index ae9257622..3e1fff77d 100644 --- a/src/ellipsisText/index.tsx +++ b/src/ellipsisText/index.tsx @@ -1,23 +1,19 @@ -import React, { - CSSProperties, - ReactNode, - useCallback, - useLayoutEffect, - useRef, - useState, -} from 'react'; -import { Tooltip } from 'antd'; +import React, { ReactNode } from 'react'; +import { Typography } from 'antd'; import { AbstractTooltipProps, RenderFunction } from 'antd/lib/tooltip'; -import classNames from 'classnames'; +import { omit } from 'lodash-es'; -import Resize from '../resize'; +import MeasureEllipsis from './measureEllipsis'; +import { DEFAULT_MAX_WIDTH } from './utils'; import './style.scss'; +export { DEFAULT_MAX_WIDTH }; + export interface IEllipsisTextProps extends AbstractTooltipProps { /** * 文本内容 */ - value: string | number | ReactNode | RenderFunction; + value: ReactNode | RenderFunction; /** * 提示内容 * @default value @@ -32,228 +28,47 @@ export interface IEllipsisTextProps extends AbstractTooltipProps { */ maxWidth?: string | number; /** - * 监听父元素大小的改变 + * 是否启用动态测量计算宽度。 + * true:通过测量计算判断文本是否溢出(支持 maxWidth/Tooltip 透传),并自动监听容器尺寸变化重测; + * false(默认):使用 antd Typography.Text 的 ellipsis 做轻量省略。 */ - watchParentSizeChange?: boolean; + dynamic?: boolean; /** * antd Tooltip */ [propName: string]: any; } -export interface NewHTMLElement extends HTMLElement { - currentStyle?: CSSStyleDeclaration; -} - -const DEFAULT_MAX_WIDTH = 120; - const EllipsisText = (props: IEllipsisTextProps) => { - const { - value, - title = value, - className, - maxWidth, - watchParentSizeChange = false, - ...otherProps - } = props; - - const ellipsisRef = useRef(null); - const observerEle = - watchParentSizeChange && ellipsisRef.current?.parentElement - ? ellipsisRef.current?.parentElement - : null; - - const [visible, setVisible] = useState(false); - const [width, setWidth] = useState(DEFAULT_MAX_WIDTH); - const [cursor, setCursor] = useState('default'); - - useLayoutEffect(() => { - onResize(); - }, [value, maxWidth]); - - /** - * @description: 根据属性名,获取dom的属性值 - * @param {NewHTMLElement} dom - * @param {string} attr - * @return {*} - */ - const getStyle = (dom: NewHTMLElement, attr: string) => { - if (!dom) { - return null; - } - // Compatible width IE8 - // @ts-ignore - return window.getComputedStyle(dom)?.[attr] || dom.currentStyle?.[attr]; - }; - - /** - * @description: 根据属性名,获取dom的属性值为number的属性。如: height、width。。。 - * @param {NewHTMLElement} dom - * @param {string} attr - * @return {*} - */ - const getNumTypeStyleValue = (dom: NewHTMLElement, attr: string) => { - return parseInt(getStyle(dom, attr)); - }; - - /** - * @description: 10 -> 10, - * @description: 10px -> 10, - * @description: 90% -> ele.width * 0.9 - * @description: calc(100% - 32px) -> ele.width - 32 - * @param {*} ele - * @param {string & number} maxWidth - * @return {*} - */ - const transitionWidth = (ele: HTMLElement, maxWidth: string | number) => { - const eleWidth = getActualWidth(ele); - - if (typeof maxWidth === 'number') { - return maxWidth > eleWidth ? eleWidth : maxWidth; // 如果父元素的宽度小于传入的最大宽度,返回父元素的宽度 - } + const { dynamic = false } = props; - const numMatch = maxWidth.match(/^(\d+)(px)?$/); - if (numMatch) { - return +numMatch[1] > eleWidth ? eleWidth : +numMatch[1]; // 如果父元素的宽度小于传入的最大宽度,返回父元素的宽度 - } + if (dynamic) { + // dynamic 仅用于上层分流,交给测量组件前剔除,避免透传到 Tooltip + return ; + } - const percentMatch = maxWidth.match(/^(\d+)%$/); - if (percentMatch) { - return eleWidth * (parseInt(percentMatch[1]) / 100); - } + const { value, title = value, className, maxWidth, ...restProps } = props; - const relativeMatch = maxWidth.match(/^calc\(100% - (\d+)px\)$/); - if (relativeMatch) { - return eleWidth - parseInt(relativeMatch[1]); - } - - return eleWidth; - }; - - const hideEleContent = (node: HTMLElement) => { - node.style.display = 'none'; - }; - - const showEleContent = (node: HTMLElement) => { - node.style.display = 'inline-block'; - }; - - /** - * @description: 获取能够得到宽度的最近父元素宽度。行内元素无法获得宽度,需向上查找父元素 - * @param {HTMLElement} ele - * @return {*} - */ - const getContainerWidth = (ele: HTMLElement): number | string => { - if (!ele) return DEFAULT_MAX_WIDTH; - - const { scrollWidth, parentElement } = ele; - - // 如果是行内元素,获取不到宽度,则向上寻找父元素 - if (scrollWidth === 0) { - return getContainerWidth(parentElement!); - } - // 如果设置了最大宽度,则直接返回宽度 - if (maxWidth) { - return transitionWidth(ele, maxWidth); - } - - hideEleContent(ellipsisRef.current!); - - const availableWidth = getAvailableWidth(ele); - - return availableWidth < 0 ? 0 : availableWidth; - }; - - /** - * @description: 获取dom元素的内容宽度 - * @param {HTMLElement} ele - * @return {*} - */ - const getRangeWidth = (ele: HTMLElement): any => { - const range = document.createRange(); - range.selectNodeContents(ele); - const rangeWidth = range.getBoundingClientRect().width; - - return rangeWidth; - }; - - /** - * @description: 获取元素不包括 padding 的宽度 - * @param {HTMLElement} ele - * @return {*} - */ - const getActualWidth = (ele: HTMLElement) => { - const width = ele.getBoundingClientRect().width; - const paddingLeft = getNumTypeStyleValue(ele, 'paddingLeft'); - const paddingRight = getNumTypeStyleValue(ele, 'paddingRight'); - return width - paddingLeft - paddingRight; - }; - - /** - * @description: 获取dom的可用宽度 - * @param {HTMLElement} ele - * @return {*} - */ - const getAvailableWidth = (ele: HTMLElement) => { - const width = getActualWidth(ele); - const contentWidth = getRangeWidth(ele); - const ellipsisWidth = width - contentWidth; - return ellipsisWidth; - }; - - /** - * @description: 计算父元素的宽度是否满足内容的大小 - * @return {*} - */ - const onResize = () => { - if (!ellipsisRef.current) { - return; - } + // maxWidth 仅 dynamic 分支生效,未开启时开发态提示 + if (maxWidth && process.env.NODE_ENV !== 'production') { + console.warn( + '[EllipsisText] `maxWidth` 仅在开启 `dynamic` 时生效;当前未传 `dynamic`,该参数将被忽略。' + ); + } - const ellipsisNode = ellipsisRef.current; - const parentElement = ellipsisNode.parentElement!; - const rangeWidth = getRangeWidth(ellipsisNode); - const containerWidth = getContainerWidth(parentElement); - const visible = rangeWidth > containerWidth; - setVisible(visible); - setWidth(containerWidth); - const parentCursor = getStyle(parentElement, 'cursor'); - if (parentCursor !== 'default') { - // 继承父元素的 hover 手势 - setCursor(parentCursor); - } else { - // 截取文本时,则改变 hover 手势为 pointer - visible && setCursor('pointer'); - } - showEleContent(ellipsisNode); - }; + const content = typeof value === 'function' ? value() : value; + const tooltipContent = typeof title === 'function' ? title() : title; - const renderText = useCallback(() => { - const style: CSSProperties = { - maxWidth: width, - cursor, - }; - return ( - - {typeof value === 'function' ? value() : value} - - ); - }, [width, cursor, value]); + // 其余 props 视为 Tooltip 配置透传,dynamic 剔除 + const tooltipProps = omit(restProps, ['dynamic']); return ( - - {visible ? ( - - {renderText()} - - ) : ( - renderText() - )} - + + {content} + ); }; diff --git a/src/ellipsisText/measureEllipsis.tsx b/src/ellipsisText/measureEllipsis.tsx new file mode 100644 index 000000000..656d0c379 --- /dev/null +++ b/src/ellipsisText/measureEllipsis.tsx @@ -0,0 +1,47 @@ +import React, { useCallback } from 'react'; +import { Tooltip } from 'antd'; +import classNames from 'classnames'; + +import Resize from '../resize'; +import type { IEllipsisTextProps } from './index'; +import useEllipsisTextStyle from './useEllipsisTextStyle'; +import { getValidContainerElement } from './utils'; + +/** + * @description 内部组件:dynamic 开启时通过动态测量判断文本是否溢出。 + */ +const MeasureEllipsis = (props: IEllipsisTextProps) => { + const { value, title = value, className, maxWidth, ...otherProps } = props; + const [textRef, isOverflow, style, onResize] = useEllipsisTextStyle(value, maxWidth); + + // 监听容器尺寸变化以重新测量 + const observerEle = textRef.current?.parentElement + ? getValidContainerElement(textRef.current?.parentElement) + : null; + + const renderText = useCallback(() => { + return ( + + {typeof value === 'function' ? value() : value} + + ); + }, [style, value, className]); + + return ( + + {isOverflow ? ( + + {renderText()} + + ) : ( + renderText() + )} + + ); +}; + +export default MeasureEllipsis; diff --git a/src/ellipsisText/useEllipsisTextStyle.ts b/src/ellipsisText/useEllipsisTextStyle.ts new file mode 100644 index 000000000..754a4c7c4 --- /dev/null +++ b/src/ellipsisText/useEllipsisTextStyle.ts @@ -0,0 +1,56 @@ +import { CSSProperties, RefObject, useLayoutEffect, useReducer, useRef } from 'react'; + +import { + DEFAULT_MAX_WIDTH, + getEllipsisCursorStyle, + getRangeWidth, + getStyle, + getTextContainerWidth, +} from './utils'; + +const forceUpdateReducer = (num: number): number => (num + 1) % 1_000_000; +export default function useEllipsisTextStyle( + value: T, + maxWidth?: string | number +): [RefObject, boolean, CSSProperties, () => void] { + const [, forceUpdate] = useReducer(forceUpdateReducer, 0); + + const textStyle = useRef({ maxWidth: DEFAULT_MAX_WIDTH, cursor: 'default' }); + const isOverflow = useRef(false); + + const textRef = useRef(null); + + useLayoutEffect(() => { + updateTextStyle(); + }, [value, maxWidth]); + + /** + * @description: 计算文本是否溢出,并据此更新宽度与 hover 手势 + */ + const updateTextStyle = () => { + const textNode = textRef.current; + if (!textNode) return; + + const prevStyle = textStyle.current; + const prevOverflow = isOverflow.current; + + const textContainerWidth = getTextContainerWidth(textNode, maxWidth); + + // 先更新溢出状态,cursor 解析依赖最新的溢出结果 + isOverflow.current = getRangeWidth(textNode) > textContainerWidth; + const cursor = getEllipsisCursorStyle( + getStyle(textNode.parentElement, 'cursor'), + isOverflow.current + ); + textStyle.current = { ...textStyle.current, maxWidth: textContainerWidth, cursor }; + + // 未发生实际变更时跳过强制渲染,避免随窗口 resize 触发无意义的更新 + const hasChanged = + isOverflow.current !== prevOverflow || + textStyle.current.maxWidth !== prevStyle.maxWidth || + textStyle.current.cursor !== prevStyle.cursor; + if (hasChanged) forceUpdate(); + }; + + return [textRef, isOverflow.current, textStyle.current, updateTextStyle]; +} diff --git a/src/ellipsisText/utils.ts b/src/ellipsisText/utils.ts new file mode 100644 index 000000000..2dc0ef893 --- /dev/null +++ b/src/ellipsisText/utils.ts @@ -0,0 +1,175 @@ +export interface NewHTMLElement extends HTMLElement { + currentStyle?: CSSStyleDeclaration; +} +type Nullable = T | undefined | null; + +export const MIN_WIDTH = 0; +export const DEFAULT_MAX_WIDTH = 120; + +/** + * @description: 根据属性名,获取 dom 的属性值 + * @param {NewHTMLElement} dom + * @param {string} attr + * @return {*} + */ +export const getStyle = (dom: Nullable, attr: string) => { + if (!dom) { + return null; + } + // Compatible width IE8 + // @ts-ignore + return window.getComputedStyle(dom)?.[attr] || dom.currentStyle?.[attr]; +}; + +/** + * @description: 根据属性名,获取dom的属性值为number的属性。如: height、width。。。 + * @param {NewHTMLElement} dom + * @param {string} attr + * @return {*} + */ +export const getNumTypeStyleValue = (dom: NewHTMLElement, attr: string) => { + return parseInt(getStyle(dom, attr)); +}; + +/** + * @description: 10 -> 10, + * @description: 10px -> 10, + * @description: 90% -> ele.width * 0.9 + * @description: calc(100% - 32px) -> ele.width - 32 + * @param {*} ele + * @param {string & number} maxWidth + * @return {*} + */ +export const transitionWidth = (ele: HTMLElement, maxWidth: string | number) => { + const eleWidth = getActualWidth(ele); + + if (typeof maxWidth === 'number') { + return maxWidth > eleWidth ? eleWidth : maxWidth; // 如果父元素的宽度小于传入的最大宽度,返回父元素的宽度 + } + + const numMatch = maxWidth.match(/^(\d+)(px)?$/); + if (numMatch) { + return +numMatch[1] > eleWidth ? eleWidth : +numMatch[1]; // 如果父元素的宽度小于传入的最大宽度,返回父元素的宽度 + } + + const percentMatch = maxWidth.match(/^(\d+)%$/); + if (percentMatch) { + return eleWidth * (parseInt(percentMatch[1]) / 100); + } + + const relativeMatch = maxWidth.match(/^calc\(100% - (\d+)px\)$/); + if (relativeMatch) { + return eleWidth - parseInt(relativeMatch[1]); + } + + return eleWidth; +}; + +/** + * @description: 获取 dom 元素的内容宽度 + * @param {HTMLElement} ele + * @return {*} + */ +export const getRangeWidth = (ele: HTMLElement): number => { + const range = document.createRange(); + range.selectNodeContents(ele); + const rangeWidth = range.getBoundingClientRect().width; + + return rangeWidth; +}; + +/** + * @description: 获取元素不包括 padding 的宽度 + * @param {HTMLElement} ele + * @return {*} + */ +export const getActualWidth = (ele: HTMLElement) => { + /** + * 优先使用 getBoundingClientRect().width 获取精确的渲染宽度; + * 当其为 0 时回退用 offsetWidth 兜底。getBoundingClientRect 会反映 CSS transform, + * 而 offsetWidth 不会——因此在 Select 下拉展开/收起的 transform 收缩动画期间 + * (rect 被缩放为 0、元素仍占据布局宽度)能兜底取到真实宽度。 + * 注意:display:none 时 offsetWidth 同样为 0,该兜底对此场景无效。 + */ + const rectWidth = ele.getBoundingClientRect().width; + const width = rectWidth > 0 ? rectWidth : ele.offsetWidth; + const paddingLeft = getNumTypeStyleValue(ele, 'paddingLeft'); + const paddingRight = getNumTypeStyleValue(ele, 'paddingRight'); + + return width - paddingLeft - paddingRight; +}; + +/** + * @description: 获取 dom 的可用宽度 + * @param {HTMLElement} ele + * @return {*} + */ +export const getAvailableWidth = (ele: HTMLElement) => { + const width = getActualWidth(ele); + const contentWidth = getRangeWidth(ele); + const ellipsisWidth = width - contentWidth; + + return ellipsisWidth; +}; + +/** + * @description 获取/向上获取有效的父元素(非行内元素) + * @param {Nullable} ele + * @returns {Nullable} + */ +export const getValidContainerElement = (ele: Nullable): Nullable => { + if (!ele) return ele; + + const { scrollWidth, parentElement } = ele; + + // 如果是行内元素,获取不到宽度,则向上寻找父元素 + if (scrollWidth === 0) { + return getValidContainerElement(parentElement!); + } + + return ele; +}; + +/** + * @description: 计算容纳文本的容器宽度,结果会做 MIN_WIDTH 下限钳制 + * @param {HTMLElement} textNode 文本节点(测量期间会临时隐藏,避免影响父元素宽度计算) + * @param {Nullable} maxWidth 传入的最大宽度,缺省时按父元素可用宽度测量 + * @return {number} + */ +export const getTextContainerWidth = ( + textNode: HTMLElement, + maxWidth?: string | number +): number => { + const container = getValidContainerElement(textNode.parentElement); + + if (!container) return DEFAULT_MAX_WIDTH; + + let containerWidth: number; + + if (maxWidth) { + containerWidth = transitionWidth(container, maxWidth); + } else { + // 获取 ref 元素占的宽度前,需要把 ref 元素隐藏,以免影响父级宽度计算 + textNode.style.display = 'none'; + containerWidth = getAvailableWidth(container); + textNode.style.display = 'inline-block'; + } + + return Math.max(MIN_WIDTH, containerWidth); +}; + +/** + * @description: 解析容器应使用的 cursor。优先继承父元素手势; + * 父元素为默认手势且文本溢出时切换为 pointer + * @param {Nullable} parentCursor 父元素 cursor + * @param {boolean} isOverflow 文本是否溢出 + * @return {string} + */ +export const getEllipsisCursorStyle = ( + parentCursor: string | null, + isOverflow: boolean +): string => { + if (parentCursor && parentCursor !== 'default') return parentCursor; + + return isOverflow ? 'pointer' : 'default'; +}; diff --git a/tests/setupTests.js b/tests/setupTests.js index 99bbaa78c..e826e67f1 100644 --- a/tests/setupTests.js +++ b/tests/setupTests.js @@ -32,6 +32,7 @@ Object.defineProperty(window, 'IntersectionObserver', { window.ResizeObserver = class ResizeObserver { constructor() {} observe() {} + unobserve() {} disconnect() {} };