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