From 527f9c4e3b37c752c170cf36eed20ab8e57a6e33 Mon Sep 17 00:00:00 2001 From: Bint <2393714045@qq.com> Date: Thu, 11 Jun 2026 00:23:18 +0800 Subject: [PATCH] feat: add Text onTextLayout support --- .../src/exports/Text/__tests__/index-test.js | 218 +++++++++++++++++ .../src/exports/Text/index.js | 3 + .../src/exports/Text/types.js | 20 ++ .../src/modules/useTextLayout/index.js | 230 ++++++++++++++++++ 4 files changed, 471 insertions(+) create mode 100644 packages/react-native-web/src/modules/useTextLayout/index.js diff --git a/packages/react-native-web/src/exports/Text/__tests__/index-test.js b/packages/react-native-web/src/exports/Text/__tests__/index-test.js index 7c1bd9deb0..32e78c1d7e 100644 --- a/packages/react-native-web/src/exports/Text/__tests__/index-test.js +++ b/packages/react-native-web/src/exports/Text/__tests__/index-test.js @@ -281,6 +281,224 @@ describe('components/Text', () => { }); }); + describe('prop "onTextLayout"', () => { + let createRange; + let getBoundingClientRect; + let ResizeObserver; + let resizeObserverObserve; + + const rect = ({ bottom, height, left, right, top, width }) => ({ + bottom, + height, + left, + right, + top, + width + }); + + beforeEach(() => { + const range = { + detach: jest.fn(), + getClientRects: jest.fn(() => [ + rect({ + bottom: 30, + height: 10, + left: 20, + right: 70, + top: 20, + width: 50 + }), + rect({ + bottom: 30, + height: 10, + left: 75, + right: 95, + top: 20, + width: 20 + }), + rect({ + bottom: 45, + height: 10, + left: 20, + right: 60, + top: 35, + width: 40 + }) + ]), + selectNodeContents: jest.fn() + }; + createRange = jest + .spyOn(document, 'createRange') + .mockImplementation(() => range); + getBoundingClientRect = jest + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(() => + rect({ + bottom: 60, + height: 40, + left: 20, + right: 100, + top: 20, + width: 80 + }) + ); + resizeObserverObserve = jest.fn(); + ResizeObserver = window.ResizeObserver; + window.ResizeObserver = jest.fn(function (callback) { + this.callback = callback; + this.disconnect = jest.fn(); + this.observe = resizeObserverObserve; + this.unobserve = jest.fn(); + }); + }); + + afterEach(() => { + createRange.mockRestore(); + getBoundingClientRect.mockRestore(); + window.ResizeObserver = ResizeObserver; + }); + + test('is called with text line measurements', () => { + const onTextLayout = jest.fn(); + act(() => { + render(Hello world); + }); + + expect(onTextLayout).toBeCalledTimes(1); + expect(onTextLayout.mock.calls[0][0].nativeEvent.lines).toEqual([ + { + ascender: 10, + capHeight: 10, + descender: 0, + height: 10, + width: 75, + x: 0, + xHeight: 10, + y: 0 + }, + { + ascender: 10, + capHeight: 10, + descender: 0, + height: 10, + width: 40, + x: 0, + xHeight: 10, + y: 15 + } + ]); + }); + + test('does not measure text layout without a handler', () => { + act(() => { + render(Hello world); + }); + + expect(createRange).not.toBeCalled(); + }); + + test('does not measure again after a render without layout changes', () => { + const onTextLayout = jest.fn(); + let rerender; + act(() => { + ({ rerender } = render( + + Hello world + + )); + }); + + expect(createRange).toBeCalledTimes(1); + + act(() => { + rerender( + + Hello world + + ); + }); + + expect(createRange).toBeCalledTimes(1); + }); + + test('groups fragments with vertical overlap into the same line', () => { + document.createRange().getClientRects.mockImplementationOnce(() => [ + rect({ + bottom: 40, + height: 20, + left: 20, + right: 60, + top: 20, + width: 40 + }), + rect({ + bottom: 35, + height: 10, + left: 62, + right: 100, + top: 25, + width: 38 + }), + rect({ + bottom: 60, + height: 10, + left: 20, + right: 70, + top: 50, + width: 50 + }) + ]); + const onTextLayout = jest.fn(); + act(() => { + render( + + Big + small + {'\nNext'} + + ); + }); + + expect(onTextLayout.mock.calls[0][0].nativeEvent.lines).toEqual([ + { + ascender: 20, + capHeight: 20, + descender: 0, + height: 20, + width: 80, + x: 0, + xHeight: 20, + y: 0 + }, + { + ascender: 10, + capHeight: 10, + descender: 0, + height: 10, + width: 50, + x: 0, + xHeight: 10, + y: 30 + } + ]); + }); + + test('observes the parent element for layout changes', () => { + let container; + act(() => { + ({ container } = render( +
+ {}}>Hello world +
+ )); + }); + + const textNode = container.firstChild.firstChild; + expect(resizeObserverObserve).toBeCalledWith(textNode); + expect(resizeObserverObserve).toBeCalledWith(textNode.parentElement); + }); + }); + describe('prop "ref"', () => { test('value is set', () => { const ref = jest.fn(); diff --git a/packages/react-native-web/src/exports/Text/index.js b/packages/react-native-web/src/exports/Text/index.js index f27ccec2e9..253bfe5b58 100644 --- a/packages/react-native-web/src/exports/Text/index.js +++ b/packages/react-native-web/src/exports/Text/index.js @@ -21,6 +21,7 @@ import useElementLayout from '../../modules/useElementLayout'; import useMergeRefs from '../../modules/useMergeRefs'; import usePlatformMethods from '../../modules/usePlatformMethods'; import useResponderEvents from '../../modules/useResponderEvents'; +import useTextLayout from '../../modules/useTextLayout'; import StyleSheet from '../StyleSheet'; import TextAncestorContext from './TextAncestorContext'; import { useLocaleContext, getLocaleDirection } from '../../modules/useLocale'; @@ -69,6 +70,7 @@ const Text: React.AbstractComponent = onSelectionChangeShouldSetResponderCapture, onStartShouldSetResponder, onStartShouldSetResponderCapture, + onTextLayout, selectable, ...rest } = props; @@ -87,6 +89,7 @@ const Text: React.AbstractComponent = const { direction: contextDirection } = useLocaleContext(); useElementLayout(hostRef, onLayout); + useTextLayout(hostRef, onTextLayout); useResponderEvents(hostRef, { onMoveShouldSetResponder, onMoveShouldSetResponderCapture, diff --git a/packages/react-native-web/src/exports/Text/types.js b/packages/react-native-web/src/exports/Text/types.js index ddf391c8b1..c95d328a16 100644 --- a/packages/react-native-web/src/exports/Text/types.js +++ b/packages/react-native-web/src/exports/Text/types.js @@ -26,6 +26,25 @@ type FontWeightValue = type NumberOrString = number | string; +export type TextLayout = {| + ascender: number, + capHeight: number, + descender: number, + height: number, + width: number, + x: number, + xHeight: number, + y: number +|}; + +export type TextLayoutEvent = { + nativeEvent: { + lines: Array, + target?: any + }, + timeStamp: number +}; + export type TextStyle = { ...ViewStyle, color?: ?ColorValue, @@ -116,6 +135,7 @@ export type TextProps = { | 'listitem' | 'none' | 'text', + onTextLayout?: (e: TextLayoutEvent) => mixed, onPress?: (e: any) => void, selectable?: boolean }; diff --git a/packages/react-native-web/src/modules/useTextLayout/index.js b/packages/react-native-web/src/modules/useTextLayout/index.js new file mode 100644 index 0000000000..ebeddef3df --- /dev/null +++ b/packages/react-native-web/src/modules/useTextLayout/index.js @@ -0,0 +1,230 @@ +/** + * Copyright (c) Nicolas Gallagher. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type { ElementRef } from 'react'; + +import * as React from 'react'; +import canUseDOM from '../canUseDom'; +import useLayoutEffect from '../useLayoutEffect'; + +type TextLayoutLine = {| + ascender: number, + capHeight: number, + descender: number, + height: number, + width: number, + x: number, + xHeight: number, + y: number +|}; + +type TextLayoutEvent = { + nativeEvent: { + lines: Array, + target?: any + }, + timeStamp: number +}; + +type TextLayoutHandler = (e: TextLayoutEvent) => mixed; + +type LineRect = {| + bottom: number, + left: number, + right: number, + top: number +|}; + +const emptyArray = []; +const minimumLineOverlapRatio = 0.5; + +function round(value) { + return Math.round(value * 1000) / 1000; +} + +function getRectList(node): Array { + if (!canUseDOM || typeof document.createRange !== 'function') { + return emptyArray; + } + + const range = document.createRange(); + range.selectNodeContents(node); + const rects = Array.prototype.slice.call(range.getClientRects()); + + if (typeof range.detach === 'function') { + range.detach(); + } + + return rects; +} + +function groupLineRects(rects: Array): Array { + const lines: Array = []; + + rects.forEach((rect) => { + if (rect.width === 0 && rect.height === 0) { + return; + } + + let overlappingIndex = -1; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + const overlap = + Math.min(line.bottom, rect.bottom) - Math.max(line.top, rect.top); + const minHeight = Math.min( + line.bottom - line.top, + rect.bottom - rect.top + ); + if (overlap >= minHeight * minimumLineOverlapRatio) { + overlappingIndex = i; + break; + } + } + + if (overlappingIndex > -1) { + const line = lines[overlappingIndex]; + lines[overlappingIndex] = { + bottom: Math.max(line.bottom, rect.bottom), + left: Math.min(line.left, rect.left), + right: Math.max(line.right, rect.right), + top: Math.min(line.top, rect.top) + }; + } else { + lines.push({ + bottom: rect.bottom, + left: rect.left, + right: rect.right, + top: rect.top + }); + } + }); + + return lines.sort((a, b) => a.top - b.top || a.left - b.left); +} + +function createTextLayoutLines(node): Array { + const nodeRect = node.getBoundingClientRect(); + const lineRects = groupLineRects(getRectList(node)); + + return lineRects.map((rect) => { + const height = round(rect.bottom - rect.top); + return { + ascender: height, + capHeight: height, + descender: 0, + height, + width: round(rect.right - rect.left), + x: round(rect.left - nodeRect.left), + xHeight: height, + y: round(rect.top - nodeRect.top) + }; + }); +} + +function areLinesEqual( + first: ?Array, + second: Array +): boolean { + if (first == null || first.length !== second.length) { + return false; + } + + for (let i = 0; i < first.length; i += 1) { + const a = first[i]; + const b = second[i]; + if ( + a.ascender !== b.ascender || + a.capHeight !== b.capHeight || + a.descender !== b.descender || + a.height !== b.height || + a.width !== b.width || + a.x !== b.x || + a.xHeight !== b.xHeight || + a.y !== b.y + ) { + return false; + } + } + + return true; +} + +export default function useTextLayout( + ref: ElementRef, + onTextLayout?: ?TextLayoutHandler +) { + const callbackRef = React.useRef(onTextLayout); + const lastLinesRef = React.useRef(null); + const isEnabledRef = React.useRef(false); + const isEnabled = typeof onTextLayout === 'function'; + + callbackRef.current = onTextLayout; + + const measure = React.useCallback(() => { + const node = ref.current; + const callback = callbackRef.current; + + if (node == null || typeof callback !== 'function') { + return; + } + + const lines = createTextLayoutLines(node); + if (areLinesEqual(lastLinesRef.current, lines)) { + return; + } + + lastLinesRef.current = lines; + + const event: TextLayoutEvent = { + nativeEvent: { + lines + }, + timeStamp: Date.now() + }; + Object.defineProperty(event.nativeEvent, 'target', { + enumerable: true, + get: () => node + }); + callback(event); + }, [ref]); + + useLayoutEffect(() => { + if (!isEnabled) { + isEnabledRef.current = false; + lastLinesRef.current = null; + return; + } + if (!isEnabledRef.current) { + lastLinesRef.current = null; + isEnabledRef.current = true; + measure(); + } + }, [isEnabled, measure]); + + useLayoutEffect(() => { + const node = ref.current; + if ( + node == null || + !isEnabled || + !canUseDOM || + typeof window.ResizeObserver === 'undefined' + ) { + return; + } + + const observer = new window.ResizeObserver(measure); + observer.observe(node); + if (node.parentElement != null) { + observer.observe(node.parentElement); + } + return () => { + observer.disconnect(); + }; + }, [isEnabled, measure, ref]); +}