Skip to content

Commit

Permalink
fix: extract useTextResize hook
Browse files Browse the repository at this point in the history
  • Loading branch information
jin-sir committed Jan 16, 2025
1 parent 7a40fef commit 7a97c42
Show file tree
Hide file tree
Showing 4 changed files with 341 additions and 188 deletions.
125 changes: 125 additions & 0 deletions src/ellipsisText/__tests__/useTextStyle.tsx
Original file line number Diff line number Diff line change
@@ -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(<span ref={ref}></span>);

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(<span ref={ref}></span>);

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(<span ref={ref}></span>);

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(<span ref={ref}></span>);

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(<span ref={ref}></span>);

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');
});
});
196 changes: 8 additions & 188 deletions src/ellipsisText/index.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -56,190 +44,22 @@ const EllipsisText = (props: IEllipsisTextProps) => {
watchParentSizeChange = false,
...otherProps
} = props;
const [ref, isOverflow, style, onResize] = useTextResize(value, maxWidth);

const ellipsisRef = useRef<HTMLSpanElement>(null);
const observerEle =
watchParentSizeChange && ellipsisRef.current?.parentElement
? ellipsisRef.current?.parentElement
: null;

const [visible, setVisible] = useState(false);
const [width, setWidth] = useState<number | string>(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 (
<span
ref={ellipsisRef}
className={classNames('dtc-ellipsis-text', className)}
style={style}
>
<span ref={ref} className={classNames('dtc-ellipsis-text', className)} style={style}>
{typeof value === 'function' ? value() : value}
</span>
);
}, [width, cursor, value]);
}, [style, value]);

return (
<Resize onResize={onResize} observerEle={observerEle}>
{visible ? (
{isOverflow ? (
<Tooltip title={title} mouseEnterDelay={0} mouseLeaveDelay={0} {...otherProps}>
{renderText()}
</Tooltip>
Expand Down
Loading

0 comments on commit 7a97c42

Please sign in to comment.