From 7a97c423b8e9aca33a38cf5b85fc5e4733bec18a Mon Sep 17 00:00:00 2001 From: jin-sir <942725119@qq.com> Date: Wed, 15 Jan 2025 16:40:06 +0800 Subject: [PATCH] fix: extract useTextResize hook --- src/ellipsisText/__tests__/useTextStyle.tsx | 125 +++++++++++++ src/ellipsisText/index.tsx | 196 +------------------- src/ellipsisText/useTextStyle.ts | 66 +++++++ src/ellipsisText/utils.ts | 142 ++++++++++++++ 4 files changed, 341 insertions(+), 188 deletions(-) create mode 100644 src/ellipsisText/__tests__/useTextStyle.tsx create mode 100644 src/ellipsisText/useTextStyle.ts create mode 100644 src/ellipsisText/utils.ts diff --git a/src/ellipsisText/__tests__/useTextStyle.tsx b/src/ellipsisText/__tests__/useTextStyle.tsx new file mode 100644 index 000000000..9e9d3503d --- /dev/null +++ b/src/ellipsisText/__tests__/useTextStyle.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { cleanup, render } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react-hooks'; + +import useTextStyle from '../useTextStyle'; +import { getContainerWidth, getRangeWidth, getStyle } from '../utils'; + +jest.mock('../utils', () => ({ + getContainerWidth: jest.fn(), + getRangeWidth: jest.fn(), + getStyle: jest.fn(), +})); + +describe('Test useTextStyle', () => { + const mockGetContainerWidth = getContainerWidth as jest.Mock; + const mockGetRangeWidth = getRangeWidth as jest.Mock; + const mockGetStyle = getStyle as jest.Mock; + + beforeEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + it('should return a ref, overflow state, style, and trigger function', () => { + const { result } = renderHook(() => useTextStyle('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(() => useTextStyle('Test Text')); + const [ref, , , updateTextStyle] = result.current; + + render(); + + mockGetRangeWidth.mockReturnValue(150); + mockGetContainerWidth.mockReturnValue(100); + + act(() => { + updateTextStyle(); + }); + + const [, isOverflow, style] = result.current; + + expect(mockGetRangeWidth).toHaveBeenCalled(); + expect(mockGetContainerWidth).toHaveBeenCalled(); + expect(isOverflow).toBe(true); + expect(style.maxWidth).toBe(100); + }); + + it('should not overflow if the container width is sufficient', () => { + const { result } = renderHook(() => useTextStyle('Test Text')); + const [ref, , , updateTextStyle] = result.current; + + render(); + + mockGetRangeWidth.mockReturnValue(80); + mockGetContainerWidth.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(() => useTextStyle('Test Text')); + const [ref, , , updateTextStyle] = result.current; + + render(); + + 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(() => useTextStyle('Test Text')); + const [ref, , , updateTextStyle] = result.current; + + render(); + + mockGetRangeWidth.mockReturnValue(80); + mockGetContainerWidth.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(() => useTextStyle('Test Text')); + const [ref, , , updateTextStyle] = result.current; + + render(); + + mockGetRangeWidth.mockReturnValue(150); + mockGetContainerWidth.mockReturnValue(100); + mockGetStyle.mockReturnValue('default'); + + act(() => { + updateTextStyle(); + }); + const [, isOverflow, style] = result.current; + + expect(isOverflow).toBe(true); + expect(style.cursor).toBe('pointer'); + }); +}); diff --git a/src/ellipsisText/index.tsx b/src/ellipsisText/index.tsx index 65662dfd4..6f0dc9b8c 100644 --- a/src/ellipsisText/index.tsx +++ b/src/ellipsisText/index.tsx @@ -1,23 +1,17 @@ -import React, { - CSSProperties, - ReactNode, - useCallback, - useLayoutEffect, - useRef, - useState, -} from 'react'; +import React, { ReactNode, useCallback } from 'react'; import { Tooltip } from 'antd'; import { AbstractTooltipProps, RenderFunction } from 'antd/lib/tooltip'; import classNames from 'classnames'; import Resize from '../resize'; +import useTextResize from './useTextStyle'; import './style.scss'; export interface IEllipsisTextProps extends AbstractTooltipProps { /** * 文本内容 */ - value: string | number | ReactNode | RenderFunction; + value: ReactNode | RenderFunction; /** * 提示内容 * @default value @@ -41,12 +35,6 @@ export interface IEllipsisTextProps extends AbstractTooltipProps { [propName: string]: any; } -export interface NewHTMLElement extends HTMLElement { - currentStyle?: CSSStyleDeclaration; -} - -const DEFAULT_MAX_WIDTH = 120; - const EllipsisText = (props: IEllipsisTextProps) => { const { value, @@ -56,190 +44,22 @@ const EllipsisText = (props: IEllipsisTextProps) => { watchParentSizeChange = false, ...otherProps } = props; + const [ref, isOverflow, style, onResize] = useTextResize(value, maxWidth); - 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) => { - // 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 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; - }; - - 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 = () => { - 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); - }; + watchParentSizeChange && ref.current?.parentElement ? ref.current?.parentElement : null; const renderText = useCallback(() => { - const style: CSSProperties = { - maxWidth: width, - cursor, - }; return ( - + {typeof value === 'function' ? value() : value} ); - }, [width, cursor, value]); + }, [style, value]); return ( - {visible ? ( + {isOverflow ? ( {renderText()} diff --git a/src/ellipsisText/useTextStyle.ts b/src/ellipsisText/useTextStyle.ts new file mode 100644 index 000000000..b1a33d3ae --- /dev/null +++ b/src/ellipsisText/useTextStyle.ts @@ -0,0 +1,66 @@ +import { CSSProperties, RefObject, useLayoutEffect, useReducer, useRef } from 'react'; + +import { getContainerWidth, getRangeWidth, getStyle } from './utils'; + +const DEFAULT_MAX_WIDTH = 120; +const updateReducer = (num: number): number => (num + 1) % 1_000_000; +export default function useTextStyle( + value: T, + maxWidth?: string | number +): [RefObject, boolean, CSSProperties, () => void] { + const [, update] = useReducer(updateReducer, 0); + + const style = useRef({ maxWidth: DEFAULT_MAX_WIDTH, cursor: 'default' }); + const isOverflow = useRef(false); + + const ref = useRef(null); + + const getTextContainerWidth = () => { + const textNode = ref.current!; + const parentElement = textNode.parentElement!; + // 这里是获取 ref 元素占的宽度,在计算时,需要把 ref 元素隐藏,以免计算时影响结果 + const oldDisplay = textNode.style.display; + textNode.style.display = 'none'; + const containerWidth = getContainerWidth(parentElement, DEFAULT_MAX_WIDTH, maxWidth); + textNode.style.display = oldDisplay; + + return containerWidth; + }; + + useLayoutEffect(() => { + updateTextStyle(); + }, [value, maxWidth]); + + const handleCursor = () => { + const textNode = ref.current!; + const parentElement = textNode.parentElement!; + const parentCursor = getStyle(parentElement, 'cursor'); + + if (parentCursor !== 'default') { + // 继承父元素的 hover 手势 + style.current = { ...style.current, cursor: parentCursor }; + } else { + // 截取文本时,则改变 hover 手势为 pointer + if (isOverflow.current) style.current = { ...style.current, cursor: 'pointer' }; + } + }; + + /** + * @description: 计算父元素的宽度是否满足内容的大小 + * @return {*} + */ + const updateTextStyle = () => { + const textNode = ref.current; + if (!textNode) return; + + const rangeWidth = getRangeWidth(textNode); + const containerWidth = getTextContainerWidth(); + + style.current = { ...style.current, maxWidth: containerWidth }; + isOverflow.current = rangeWidth > containerWidth; + handleCursor(); + update(); + }; + + return [ref, isOverflow.current, style.current, updateTextStyle]; +} diff --git a/src/ellipsisText/utils.ts b/src/ellipsisText/utils.ts new file mode 100644 index 000000000..1a4d840c7 --- /dev/null +++ b/src/ellipsisText/utils.ts @@ -0,0 +1,142 @@ +export interface NewHTMLElement extends HTMLElement { + currentStyle?: CSSStyleDeclaration; +} +type Nullable = T | undefined | null; + +/** + * @description: 根据属性名,获取 dom 的属性值 + * @param {NewHTMLElement} dom + * @param {string} attr + * @return {*} + */ +export const getStyle = (dom: NewHTMLElement, attr: string) => { + // 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): any => { + 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) => { + 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 {*} + */ +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 getNonInlineElementWidth = (ele: Nullable): Nullable => { + if (!ele) return ele; + + const { scrollWidth, parentElement } = ele; + + // 如果是行内元素,获取不到宽度,则向上寻找父元素 + if (scrollWidth === 0) { + return getNonInlineElementWidth(parentElement!); + } + + return ele; +}; + +/** + * @description: 获取能够得到宽度的最近父元素宽度。行内元素无法获得宽度,需向上查找父元素 + * @param {Nullable} ele + * @param {number} defaultWidth + * @param {string | number} maxWidth + * @returns + */ +export const getContainerWidth = ( + ele: Nullable, + defaultWidth: number, + maxWidth?: string | number +): number | string => { + const container = getNonInlineElementWidth(ele); + if (!container) return defaultWidth; + + // 如果设置了最大宽度,则直接返回宽度 + if (maxWidth) { + return transitionWidth(container, maxWidth); + } + + const availableWidth = getAvailableWidth(container); + + return availableWidth < 0 ? 0 : availableWidth; +};