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;
+};