From 334bf7c4ba29e466decc6cc8fc520f5dbb295cf3 Mon Sep 17 00:00:00 2001 From: Andrew Datsenko Date: Thu, 22 Jan 2026 14:10:31 -0800 Subject: [PATCH 1/4] Support getBoundingClientRect for TextShadowNode Summary: When `getBoundingClientRect()` is called on a nested `` component (TextShadowNode), return the parent paragraph's bounding rect instead of empty/invalid metrics. TextShadowNode is a virtual node that doesn't have its own layout metrics. This matches web behavior where inline elements return their container's rect. Use `getClientRects()` (added in a follow-up diff) to get the individual fragment rects for text that spans multiple lines. Differential Revision: D91087220 --- .../ReactCommon/react/renderer/dom/DOM.cpp | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/react-native/ReactCommon/react/renderer/dom/DOM.cpp b/packages/react-native/ReactCommon/react/renderer/dom/DOM.cpp index 0cec80aaec44d2..acb090744fc76c 100644 --- a/packages/react-native/ReactCommon/react/renderer/dom/DOM.cpp +++ b/packages/react-native/ReactCommon/react/renderer/dom/DOM.cpp @@ -6,7 +6,9 @@ */ #include "DOM.h" +#include #include +#include #include #include #include @@ -280,6 +282,49 @@ DOMRect getBoundingClientRect( return DOMRect{}; } + // Check if this is a TextShadowNode (virtual text node nested in a paragraph) + auto textShadowNode = dynamic_cast(&shadowNode); + if (textShadowNode != nullptr) { + // TextShadowNode is a virtual node that doesn't have its own layout metrics + // For getBoundingClientRect, we return the parent paragraph's bounding rect + // (matching web behavior where inline elements return their container's + // rect) Use getClientRects() to get the individual fragment rects + auto ancestors = shadowNode.getFamily().getAncestors(*currentRevision); + if (ancestors.empty()) { + return DOMRect{}; + } + + // Find the ParagraphShadowNode in the ancestors + const ParagraphShadowNode* paragraphNode = nullptr; + for (const auto& pair : ancestors) { + paragraphNode = + dynamic_cast(&pair.first.get()); + if (paragraphNode != nullptr) { + break; + } + } + + if (paragraphNode == nullptr) { + return DOMRect{}; + } + + // Return the paragraph's bounding rect + auto paragraphLayoutMetrics = getLayoutMetricsFromRoot( + *currentRevision, + *paragraphNode, + {.includeTransform = includeTransform, .includeViewportOffset = true}); + if (paragraphLayoutMetrics == EmptyLayoutMetrics) { + return DOMRect{}; + } + + auto frame = paragraphLayoutMetrics.frame; + return DOMRect{ + .x = frame.origin.x, + .y = frame.origin.y, + .width = frame.size.width, + .height = frame.size.height}; + } + auto layoutMetrics = getLayoutMetricsFromRoot( *currentRevision, shadowNode, From 1f5ac6eafb73c6bbe4864ee2cd4fd02c27bab908 Mon Sep 17 00:00:00 2001 From: Andrew Datsenko Date: Thu, 22 Jan 2026 14:10:31 -0800 Subject: [PATCH 2/4] Add text fragment rect measurement APIs Summary: Adds Android-specific implementation for measuring bounding rectangles of text fragments that belong to a specific React tag. This enables getting the visual boundaries of nested `` components within a paragraph. The implementation provides two methods: 1. `getFragmentRectsForReactTag` - Uses PreparedLayout for efficient measurement when the enablePreparedTextLayout feature flag is enabled 2. `getFragmentRectsFromAttributedString` - Fallback that creates a layout on-demand when PreparedLayout is not available Key features: - Handles multi-line text fragments by returning a rect for each line - Correctly handles RTL text direction - Converts coordinates between pixels and DIPs These methods are used by the DOM getClientRects() API to provide accurate text fragment boundaries for accessibility and layout inspection. Differential Revision: D91087221 --- .../react/fabric/FabricUIManager.java | 54 +++++ .../react/views/text/TextLayoutManager.kt | 228 ++++++++++++++++++ .../textlayoutmanager/TextLayoutManager.cpp | 89 +++++++ .../textlayoutmanager/TextLayoutManager.h | 20 ++ 4 files changed, 391 insertions(+) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index a4cb1c2dd5ed47..dcc1dfb98cb28e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -705,6 +705,60 @@ public float[] measurePreparedLayout( getYogaMeasureMode(minHeight, maxHeight)); } + /** + * Returns the bounding rectangles for all text fragments that belong to the specified react tag. + * This is useful for getting the visual boundaries of nested {@code } components within a + * paragraph. + * + * @param preparedLayout The prepared text layout containing the layout and react tags + * @param targetReactTag The react tag of the TextShadowNode to get rects for + * @return A FloatArray containing [x, y, width, height] for each fragment rect, or empty array if + * no fragments match the tag + */ + @AnyThread + @ThreadConfined(ANY) + @UnstableReactNativeAPI + public float[] getFragmentRectsForReactTag(PreparedLayout preparedLayout, int targetReactTag) { + return TextLayoutManager.getFragmentRectsForReactTag(preparedLayout, targetReactTag); + } + + /** + * Returns the bounding rectangles for all text fragments that belong to the specified react tag + * by creating a layout on-demand from the AttributedString. This is used as a fallback when + * PreparedLayout is not available (e.g., when enablePreparedTextLayout feature flag is disabled). + * + * @param surfaceId The surface ID to get context from + * @param attributedString The attributed string containing the text fragments + * @param paragraphAttributes The paragraph attributes for layout + * @param width The layout width constraint + * @param height The layout height constraint + * @param targetReactTag The react tag of the TextShadowNode to get rects for + * @return A FloatArray containing [x, y, width, height] for each fragment rect, or empty array if + * no fragments match the tag + */ + @AnyThread + @ThreadConfined(ANY) + @UnstableReactNativeAPI + public float[] getFragmentRectsFromAttributedString( + int surfaceId, + ReadableMapBuffer attributedString, + ReadableMapBuffer paragraphAttributes, + float width, + float height, + int targetReactTag) { + SurfaceMountingManager surfaceMountingManager = mMountingManager.getSurfaceManager(surfaceId); + Context context = surfaceMountingManager != null ? surfaceMountingManager.getContext() : null; + if (context == null) { + FLog.w( + TAG, + "Couldn't get context for surfaceId %d in getFragmentRectsFromAttributedString", + surfaceId); + return new float[0]; + } + return TextLayoutManager.getFragmentRectsFromAttributedString( + context, attributedString, paragraphAttributes, width, height, targetReactTag); + } + /** * @param surfaceId {@link int} surface ID * @param defaultTextInputPadding {@link float[]} output parameter will contain the default theme diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index d3ca40183401bb..117c5427c1ff48 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -1195,6 +1195,234 @@ internal object TextLayoutManager { return ret } + /** + * Returns the bounding rectangles for all text fragments that belong to the specified react tag. + * This is useful for getting the visual boundaries of nested components within a + * paragraph. + * + * @param preparedLayout The prepared text layout containing the layout and react tags + * @param targetReactTag The react tag of the TextShadowNode to get rects for + * @return A FloatArray containing [x, y, width, height] for each fragment rect, or empty array if + * no fragments match the tag + */ + @JvmStatic + fun getFragmentRectsForReactTag( + preparedLayout: PreparedLayout, + targetReactTag: Int, + ): FloatArray { + val layout = preparedLayout.layout + val text = layout.text as? Spanned ?: return floatArrayOf() + val reactTags = preparedLayout.reactTags + val verticalOffset = preparedLayout.verticalOffset + val maximumNumberOfLines = preparedLayout.maximumNumberOfLines + + val calculatedLineCount = calculateLineCount(layout, maximumNumberOfLines) + val retList = ArrayList() + + // Find all fragments with the matching react tag + val fragmentIndexSpans = text.getSpans(0, text.length, ReactFragmentIndexSpan::class.java) + + for (span in fragmentIndexSpans) { + val fragmentIndex = span.fragmentIndex + if ( + fragmentIndex >= 0 && + fragmentIndex < reactTags.size && + reactTags[fragmentIndex] == targetReactTag + ) { + // This fragment belongs to the target TextShadowNode + val start = text.getSpanStart(span) + val end = text.getSpanEnd(span) + + addRectsForSpanRange(layout, text, start, end, verticalOffset, calculatedLineCount, retList) + } + } + + val ret = FloatArray(retList.size) + for (i in retList.indices) { + ret[i] = retList[i] + } + return ret + } + + /** + * Returns the bounding rectangles for all text fragments that belong to the specified react tag. + * This method works with the legacy ReactTagSpan used when enablePreparedTextLayout is disabled. + * + * @param layout The Android text layout + * @param targetReactTag The react tag of the TextShadowNode to get rects for + * @param verticalOffset Vertical offset to apply to the rects + * @param maximumNumberOfLines Maximum number of lines to consider + * @return A FloatArray containing [x, y, width, height] for each fragment rect, or empty array if + * no fragments match the tag + */ + @JvmStatic + fun getFragmentRectsForReactTagFromLayout( + layout: Layout, + targetReactTag: Int, + verticalOffset: Float, + maximumNumberOfLines: Int, + ): FloatArray { + val text = layout.text as? Spanned ?: return floatArrayOf() + val calculatedLineCount = calculateLineCount(layout, maximumNumberOfLines) + val retList = ArrayList() + + // Find all ReactTagSpan spans with the matching react tag + val tagSpans = text.getSpans(0, text.length, ReactTagSpan::class.java) + + for (span in tagSpans) { + if (span.reactTag == targetReactTag) { + val start = text.getSpanStart(span) + val end = text.getSpanEnd(span) + + addRectsForSpanRange(layout, text, start, end, verticalOffset, calculatedLineCount, retList) + } + } + + val ret = FloatArray(retList.size) + for (i in retList.indices) { + ret[i] = retList[i] + } + return ret + } + + /** + * Returns the bounding rectangles for all text fragments that belong to the specified react tag + * by creating a layout on-demand from the AttributedString. This is used as a fallback when + * PreparedLayout is not available (e.g., when enablePreparedTextLayout feature flag is disabled). + * + * @param context The Android context + * @param attributedString The attributed string containing the text fragments + * @param paragraphAttributes The paragraph attributes for layout + * @param width The layout width constraint + * @param height The layout height constraint + * @param targetReactTag The react tag of the TextShadowNode to get rects for + * @return A FloatArray containing [x, y, width, height] for each fragment rect, or empty array if + * no fragments match the tag + */ + @JvmStatic + fun getFragmentRectsFromAttributedString( + context: Context, + attributedString: ReadableMapBuffer, + paragraphAttributes: ReadableMapBuffer, + width: Float, + height: Float, + targetReactTag: Int, + ): FloatArray { + val fragments = attributedString.getMapBuffer(AS_KEY_FRAGMENTS) + // Pass null for outputReactTags since we'll use ReactTagSpan directly + val text = + createSpannableFromAttributedString( + context, + fragments, + null, // reactTextViewManagerCallback + null, // outputReactTags - this ensures ReactTagSpan is used + ) + + val baseTextAttributes = + TextAttributeProps.fromMapBuffer(attributedString.getMapBuffer(AS_KEY_BASE_ATTRIBUTES)) + + // Width and height from C++ are in DIPs, but createLayout expects pixels + // Convert to pixels for correct text wrapping + val widthInPx = width.dpToPx() + val heightInPx = height.dpToPx() + + val layout = + createLayout( + text, + newPaintWithAttributes(baseTextAttributes, context), + attributedString, + paragraphAttributes, + widthInPx, + YogaMeasureMode.EXACTLY, + heightInPx, + YogaMeasureMode.UNDEFINED, + ) + + val maximumNumberOfLines = + if (paragraphAttributes.contains(PA_KEY_MAX_NUMBER_OF_LINES)) + paragraphAttributes.getInt(PA_KEY_MAX_NUMBER_OF_LINES) + else ReactConstants.UNSET + + val verticalOffset = + getVerticalOffset( + layout, + paragraphAttributes, + heightInPx, + YogaMeasureMode.UNDEFINED, + maximumNumberOfLines, + ) + + return getFragmentRectsForReactTagFromLayout( + layout, + targetReactTag, + verticalOffset, + maximumNumberOfLines, + ) + } + + private fun addRectsForSpanRange( + layout: Layout, + text: Spanned, + start: Int, + end: Int, + verticalOffset: Float, + calculatedLineCount: Int, + retList: ArrayList, + ) { + if (start < 0 || end < 0 || start >= end) { + return + } + + // Get the bounding rect for this text range + // We need to handle multi-line text fragments by getting rects for each line + val startLine = layout.getLineForOffset(start) + val endLine = layout.getLineForOffset(end - 1) + + for (line in startLine..min(endLine, calculatedLineCount - 1)) { + val lineStart = layout.getLineStart(line) + val lineEnd = layout.getLineEnd(line) + + // Calculate the portion of this fragment on this line + val fragmentStartOnLine = max(start, lineStart) + val fragmentEndOnLine = min(end, lineEnd) + + if (fragmentStartOnLine >= fragmentEndOnLine) { + continue + } + + // Get the horizontal bounds + // For the left position, use getPrimaryHorizontal at the fragment start + val left = layout.getPrimaryHorizontal(fragmentStartOnLine) + + // For the right position, we need to handle the case where the fragment + // ends at a line break. In this case, getPrimaryHorizontal at the end + // returns the same as at the start of the next line (or line start). + // Instead, we should use the line's right bound or the position just before + // the line end character. + val right: Float + if (fragmentEndOnLine >= lineEnd && lineEnd > lineStart) { + // Fragment goes to the end of the line - use the position before the newline + // or use getLineRight for the actual right edge of text on this line + right = layout.getLineRight(line) + } else { + right = layout.getPrimaryHorizontal(fragmentEndOnLine) + } + + // Get the vertical bounds + val top = layout.getLineTop(line) + verticalOffset + val bottom = layout.getLineBottom(line) + verticalOffset + + // Ensure left is less than right (RTL text handling) + val rectLeft = min(left, right) + val rectRight = max(left, right) + + retList.add(rectLeft.pxToDp()) + retList.add(top.pxToDp()) + retList.add((rectRight - rectLeft).pxToDp()) + retList.add((bottom - top).pxToDp()) + } + } + private fun getVerticalOffset( layout: Layout, paragraphAttributes: ReadableMapBuffer, diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.cpp b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.cpp index cbcfbbedd0248e..a6d7282ff92bda 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.cpp +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.cpp @@ -437,4 +437,93 @@ TextMeasurement TextLayoutManager::measurePreparedLayout( return textMeasurement; } +std::vector TextLayoutManager::getFragmentRectsForReactTag( + const PreparedLayout& preparedLayout, + Tag targetReactTag) const { + const auto& fabricUIManager = + contextContainer_->at>("FabricUIManager"); + + static auto getFragmentRectsForReactTagMethod = + jni::findClassStatic("com/facebook/react/fabric/FabricUIManager") + ->getMethod( + "getFragmentRectsForReactTag"); + + auto rectsArr = getFragmentRectsForReactTagMethod( + fabricUIManager, preparedLayout.get(), targetReactTag); + + std::vector result; + if (rectsArr == nullptr || rectsArr->size() == 0) { + return result; + } + + auto rects = rectsArr->getRegion(0, static_cast(rectsArr->size())); + + // The array contains [x, y, width, height] for each rect + react_native_assert(rectsArr->size() % 4 == 0); + result.reserve(rectsArr->size() / 4); + + for (size_t i = 0; i < rectsArr->size(); i += 4) { + result.push_back( + Rect{ + .origin = {.x = rects[i], .y = rects[i + 1]}, + .size = {.width = rects[i + 2], .height = rects[i + 3]}}); + } + + return result; +} + +std::vector TextLayoutManager::getFragmentRectsFromAttributedString( + Tag surfaceId, + const AttributedString& attributedString, + const ParagraphAttributes& paragraphAttributes, + const LayoutConstraints& layoutConstraints, + Tag targetReactTag) const { + const auto& fabricUIManager = + contextContainer_->at>("FabricUIManager"); + + static auto getFragmentRectsFromAttributedStringMethod = + jni::findClassStatic("com/facebook/react/fabric/FabricUIManager") + ->getMethod("getFragmentRectsFromAttributedString"); + + auto attributedStringMB = + JReadableMapBuffer::createWithContents(toMapBuffer(attributedString)); + auto paragraphAttributesMB = + JReadableMapBuffer::createWithContents(toMapBuffer(paragraphAttributes)); + + auto rectsArr = getFragmentRectsFromAttributedStringMethod( + fabricUIManager, + surfaceId, + attributedStringMB.get(), + paragraphAttributesMB.get(), + layoutConstraints.maximumSize.width, + layoutConstraints.maximumSize.height, + targetReactTag); + + std::vector result; + if (rectsArr == nullptr || rectsArr->size() == 0) { + return result; + } + + auto rects = rectsArr->getRegion(0, static_cast(rectsArr->size())); + + // The array contains [x, y, width, height] for each rect + react_native_assert(rectsArr->size() % 4 == 0); + result.reserve(rectsArr->size() / 4); + + for (size_t i = 0; i < rectsArr->size(); i += 4) { + result.push_back( + Rect{ + .origin = {.x = rects[i], .y = rects[i + 1]}, + .size = {.width = rects[i + 2], .height = rects[i + 3]}}); + } + + return result; +} + } // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.h index eec36fb06b88af..3e4489117d3f46 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.h @@ -91,6 +91,26 @@ class TextLayoutManager { const TextLayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const; + /** + * Get the bounding rects of all text fragments that belong to the given + * react tag within a PreparedLayout. This is useful for getting the visual + * boundaries of nested components within a text paragraph. + */ + std::vector getFragmentRectsForReactTag(const PreparedLayout &layout, Tag targetReactTag) const; + + /** + * Get the bounding rects of all text fragments that belong to the given + * react tag by creating a layout on-demand from the AttributedString. + * This is used as a fallback when PreparedLayout is not available + * (e.g., when enablePreparedTextLayout feature flag is disabled). + */ + std::vector getFragmentRectsFromAttributedString( + Tag surfaceId, + const AttributedString &attributedString, + const ParagraphAttributes ¶graphAttributes, + const LayoutConstraints &layoutConstraints, + Tag targetReactTag) const; + private: std::shared_ptr contextContainer_; TextMeasureCache textMeasureCache_; From f85c416006fbc547b217a26d31f5980f6bf120b3 Mon Sep 17 00:00:00 2001 From: Andrew Datsenko Date: Thu, 22 Jan 2026 14:10:31 -0800 Subject: [PATCH 3/4] Implement getClientRects for text fragment measurement Summary: Adds the core DOM implementation for `getClientRects()` which returns the bounding rectangles of all text fragments belonging to a TextShadowNode within its parent ParagraphShadowNode. This is useful for: - Getting the visual boundaries of nested `` components within a paragraph - Accessibility tools that need per-line text boundaries - Layout inspection for text that spans multiple lines On Android, the implementation uses the platform's TextLayoutManager to get accurate fragment rects. On other platforms, a simplified fallback returns the paragraph's frame for each fragment. Differential Revision: D91087222 --- .../react/renderer/dom/CMakeLists.txt | 22 +++++- .../ReactCommon/react/renderer/dom/DOM.cpp | 42 +++++++++++ .../ReactCommon/react/renderer/dom/DOM.h | 7 ++ .../react/renderer/dom/DOMPlatform.cpp | 74 +++++++++++++++++++ .../android/react/renderer/dom/DOMPlatform.h | 23 ++++++ .../cxx/react/renderer/dom/DOMPlatform.cpp | 38 ++++++++++ .../cxx/react/renderer/dom/DOMPlatform.h | 23 ++++++ 7 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 packages/react-native/ReactCommon/react/renderer/dom/platform/android/react/renderer/dom/DOMPlatform.cpp create mode 100644 packages/react-native/ReactCommon/react/renderer/dom/platform/android/react/renderer/dom/DOMPlatform.h create mode 100644 packages/react-native/ReactCommon/react/renderer/dom/platform/cxx/react/renderer/dom/DOMPlatform.cpp create mode 100644 packages/react-native/ReactCommon/react/renderer/dom/platform/cxx/react/renderer/dom/DOMPlatform.h diff --git a/packages/react-native/ReactCommon/react/renderer/dom/CMakeLists.txt b/packages/react-native/ReactCommon/react/renderer/dom/CMakeLists.txt index b55103de39eae7..91bad9229a14c8 100644 --- a/packages/react-native/ReactCommon/react/renderer/dom/CMakeLists.txt +++ b/packages/react-native/ReactCommon/react/renderer/dom/CMakeLists.txt @@ -6,16 +6,34 @@ cmake_minimum_required(VERSION 3.13) set(CMAKE_VERBOSE_MAKEFILE on) +include(${REACT_COMMON_DIR}/cmake-utils/internal/react-native-platform-selector.cmake) include(${REACT_COMMON_DIR}/cmake-utils/react-native-flags.cmake) -file(GLOB react_renderer_dom_SRC CONFIGURE_DEPENDS *.cpp) +react_native_android_selector(platform_SRC + platform/android/react/renderer/dom/*.cpp + platform/cxx/react/renderer/dom/*.cpp +) +file(GLOB react_renderer_dom_SRC CONFIGURE_DEPENDS *.cpp ${platform_SRC}) + add_library(react_renderer_dom OBJECT ${react_renderer_dom_SRC}) -target_include_directories(react_renderer_dom PUBLIC ${REACT_COMMON_DIR}) +react_native_android_selector(platform_DIR + ${CMAKE_CURRENT_SOURCE_DIR}/platform/android/ + ${CMAKE_CURRENT_SOURCE_DIR}/platform/cxx/) +target_include_directories(react_renderer_dom PUBLIC + ${REACT_COMMON_DIR} + ${platform_DIR}) + +react_native_android_selector(platform_DIR_PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/platform/android/react/renderer/dom/ + ${CMAKE_CURRENT_SOURCE_DIR}/platform/cxx/react/renderer/dom/) +target_include_directories(react_renderer_dom PRIVATE + ${platform_DIR_PRIVATE}) target_link_libraries(react_renderer_dom react_renderer_core react_renderer_graphics + react_renderer_textlayoutmanager rrc_root rrc_text) target_compile_reactnative_options(react_renderer_dom PRIVATE) diff --git a/packages/react-native/ReactCommon/react/renderer/dom/DOM.cpp b/packages/react-native/ReactCommon/react/renderer/dom/DOM.cpp index acb090744fc76c..1e37c1e64b9b54 100644 --- a/packages/react-native/ReactCommon/react/renderer/dom/DOM.cpp +++ b/packages/react-native/ReactCommon/react/renderer/dom/DOM.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -631,4 +632,45 @@ std::optional measureLayout( }; } +std::vector getClientRects( + const RootShadowNode::Shared& currentRevision, + const ShadowNode& shadowNode) { + auto shadowNodeInCurrentRevision = + getShadowNodeInRevision(currentRevision, shadowNode); + if (shadowNodeInCurrentRevision == nullptr) { + return {}; + } + + auto ancestors = shadowNode.getFamily().getAncestors(*currentRevision); + if (ancestors.empty()) { + return {}; + } + + const ParagraphShadowNode* paragraphNode = nullptr; + for (const auto& pair : ancestors) { + paragraphNode = dynamic_cast(&pair.first.get()); + if (paragraphNode != nullptr) { + break; + } + } + + if (paragraphNode == nullptr) { + return {}; + } + + auto paragraphLayoutMetrics = getLayoutMetricsFromRoot( + *currentRevision, + *paragraphNode, + {.includeTransform = true, .includeViewportOffset = true}); + if (paragraphLayoutMetrics == EmptyLayoutMetrics) { + return {}; + } + + return getClientRectsForTextNode( + *paragraphNode, + paragraphLayoutMetrics, + shadowNode.getTag(), + shadowNode.getSurfaceId()); +} + } // namespace facebook::react::dom diff --git a/packages/react-native/ReactCommon/react/renderer/dom/DOM.h b/packages/react-native/ReactCommon/react/renderer/dom/DOM.h index 8cdb424a8eb590..38929fecdc2f7e 100644 --- a/packages/react-native/ReactCommon/react/renderer/dom/DOM.h +++ b/packages/react-native/ReactCommon/react/renderer/dom/DOM.h @@ -111,4 +111,11 @@ std::optional measureLayout( const ShadowNode &shadowNode, const ShadowNode &relativeToShadowNode); +// Returns the bounding rects of all text fragments that belong to the given +// shadow node within its parent Paragraph component. This is useful for getting +// the visual boundaries of nested components within a text paragraph. +// Returns an empty vector if the node is not a Text node or if it's not part +// of a Paragraph. +std::vector getClientRects(const RootShadowNode::Shared ¤tRevision, const ShadowNode &shadowNode); + } // namespace facebook::react::dom diff --git a/packages/react-native/ReactCommon/react/renderer/dom/platform/android/react/renderer/dom/DOMPlatform.cpp b/packages/react-native/ReactCommon/react/renderer/dom/platform/android/react/renderer/dom/DOMPlatform.cpp new file mode 100644 index 00000000000000..95b4c015bfaab6 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/dom/platform/android/react/renderer/dom/DOMPlatform.cpp @@ -0,0 +1,74 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "DOMPlatform.h" +#include + +namespace facebook::react::dom { + +std::vector getClientRectsForTextNode( + const ParagraphShadowNode& paragraphNode, + const LayoutMetrics& paragraphLayoutMetrics, + Tag targetTag, + SurfaceId surfaceId) { + std::vector result; + + const auto& state = paragraphNode.getStateData(); + auto layoutManager = state.layoutManager.lock(); + if (layoutManager == nullptr) { + return result; + } + + if constexpr (TextLayoutManagerExtended::supportsPreparedLayout()) { + const auto& preparedLayout = state.measuredLayout.preparedLayout; + if (preparedLayout.get() != nullptr) { + auto fragmentRects = + layoutManager->getFragmentRectsForReactTag(preparedLayout, targetTag); + result.reserve(fragmentRects.size()); + auto contentOriginX = paragraphLayoutMetrics.frame.origin.x + + paragraphLayoutMetrics.contentInsets.left; + auto contentOriginY = paragraphLayoutMetrics.frame.origin.y + + paragraphLayoutMetrics.contentInsets.top; + for (const auto& rect : fragmentRects) { + result.push_back( + DOMRect{ + .x = contentOriginX + rect.origin.x, + .y = contentOriginY + rect.origin.y, + .width = rect.size.width, + .height = rect.size.height}); + } + return result; + } + } + + auto layoutConstraints = LayoutConstraints{ + .minimumSize = {0, 0}, + .maximumSize = paragraphLayoutMetrics.frame.size, + .layoutDirection = paragraphLayoutMetrics.layoutDirection}; + + auto fragmentRects = layoutManager->getFragmentRectsFromAttributedString( + surfaceId, + state.attributedString, + state.paragraphAttributes, + layoutConstraints, + targetTag); + + result.reserve(fragmentRects.size()); + auto originX = paragraphLayoutMetrics.frame.origin.x; + auto originY = paragraphLayoutMetrics.frame.origin.y; + for (const auto& rect : fragmentRects) { + result.push_back( + DOMRect{ + .x = originX + rect.origin.x, + .y = originY + rect.origin.y, + .width = rect.size.width, + .height = rect.size.height}); + } + return result; +} + +} // namespace facebook::react::dom diff --git a/packages/react-native/ReactCommon/react/renderer/dom/platform/android/react/renderer/dom/DOMPlatform.h b/packages/react-native/ReactCommon/react/renderer/dom/platform/android/react/renderer/dom/DOMPlatform.h new file mode 100644 index 00000000000000..e7c3b86dace579 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/dom/platform/android/react/renderer/dom/DOMPlatform.h @@ -0,0 +1,23 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include +#include + +namespace facebook::react::dom { + +std::vector getClientRectsForTextNode( + const ParagraphShadowNode ¶graphNode, + const LayoutMetrics ¶graphLayoutMetrics, + Tag targetTag, + SurfaceId surfaceId); + +} // namespace facebook::react::dom diff --git a/packages/react-native/ReactCommon/react/renderer/dom/platform/cxx/react/renderer/dom/DOMPlatform.cpp b/packages/react-native/ReactCommon/react/renderer/dom/platform/cxx/react/renderer/dom/DOMPlatform.cpp new file mode 100644 index 00000000000000..eae59006784fa0 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/dom/platform/cxx/react/renderer/dom/DOMPlatform.cpp @@ -0,0 +1,38 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "DOMPlatform.h" + +namespace facebook::react::dom { + +std::vector getClientRectsForTextNode( + const ParagraphShadowNode& paragraphNode, + const LayoutMetrics& paragraphLayoutMetrics, + Tag targetTag, + SurfaceId /*surfaceId*/) { + std::vector result; + + const auto& state = paragraphNode.getStateData(); + const auto& attributedString = state.attributedString; + const auto& fragments = attributedString.getFragments(); + auto paragraphFrame = paragraphLayoutMetrics.frame; + + for (const auto& fragment : fragments) { + if (fragment.parentShadowView.tag == targetTag && + !fragment.isAttachment()) { + result.push_back( + DOMRect{ + .x = paragraphFrame.origin.x, + .y = paragraphFrame.origin.y, + .width = paragraphFrame.size.width, + .height = paragraphFrame.size.height}); + } + } + return result; +} + +} // namespace facebook::react::dom diff --git a/packages/react-native/ReactCommon/react/renderer/dom/platform/cxx/react/renderer/dom/DOMPlatform.h b/packages/react-native/ReactCommon/react/renderer/dom/platform/cxx/react/renderer/dom/DOMPlatform.h new file mode 100644 index 00000000000000..e7c3b86dace579 --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/dom/platform/cxx/react/renderer/dom/DOMPlatform.h @@ -0,0 +1,23 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include +#include + +namespace facebook::react::dom { + +std::vector getClientRectsForTextNode( + const ParagraphShadowNode ¶graphNode, + const LayoutMetrics ¶graphLayoutMetrics, + Tag targetTag, + SurfaceId surfaceId); + +} // namespace facebook::react::dom From e4bd88488ac55357982a15a5872fb17c09d4975a Mon Sep 17 00:00:00 2001 From: Andrew Datsenko Date: Thu, 22 Jan 2026 14:10:31 -0800 Subject: [PATCH 4/4] Add getClientRects JS API and native module binding Summary: Adds the JavaScript API `getClientRects()` to the Element interface, matching the Web DOM API (https://developer.mozilla.org/en-US/docs/Web/API/Element/getClientRects). For most elements, this returns an array with a single rect matching `getBoundingClientRect()`. For text elements (TextShadowNode), this returns an array of rects representing each line/fragment of the text. This is useful for getting the visual boundaries of nested `` components within a text paragraph, where text may span multiple lines. Changes: - ReadOnlyElement.js: Add getClientRects() method - NativeDOM.js: Add getClientRects spec - NativeDOM.cpp/h: Add native module binding that calls dom::getClientRects Differential Revision: D91087227 --- .../react/nativemodule/dom/NativeDOM.cpp | 24 +++++++++++++++ .../react/nativemodule/dom/NativeDOM.h | 7 +++++ .../webapis/dom/nodes/ReadOnlyElement.js | 12 ++++++++ .../webapis/dom/nodes/specs/NativeDOM.js | 30 +++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp b/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp index da40ea3b7899ea..c5ecc7ce8081ca 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.cpp @@ -274,6 +274,30 @@ NativeDOM::getBoundingClientRect( return std::tuple{domRect.x, domRect.y, domRect.width, domRect.height}; } +std::vector> +NativeDOM::getClientRects( + jsi::Runtime& rt, + std::shared_ptr shadowNode) { + auto currentRevision = + getCurrentShadowTreeRevision(rt, shadowNode->getSurfaceId()); + if (currentRevision == nullptr) { + return {}; + } + + auto domRects = dom::getClientRects(currentRevision, *shadowNode); + + std::vector> result; + result.reserve(domRects.size()); + for (const auto& rect : domRects) { + result.emplace_back(rect.x, rect.y, rect.width, rect.height); + } + return result; +} + std::tuple NativeDOM::getInnerSize( jsi::Runtime& rt, std::shared_ptr shadowNode) { diff --git a/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.h b/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.h index 19157a9cd9da8e..8de0b1f41860db 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.h +++ b/packages/react-native/ReactCommon/react/nativemodule/dom/NativeDOM.h @@ -58,6 +58,13 @@ class NativeDOM : public NativeDOMCxxSpec { /* height: */ double> getBoundingClientRect(jsi::Runtime &rt, std::shared_ptr shadowNode, bool includeTransform); + std::vector> + getClientRects(jsi::Runtime &rt, std::shared_ptr shadowNode); + std::tuple getInnerSize( jsi::Runtime &rt, std::shared_ptr shadowNode); diff --git a/packages/react-native/src/private/webapis/dom/nodes/ReadOnlyElement.js b/packages/react-native/src/private/webapis/dom/nodes/ReadOnlyElement.js index ce816d9ed5fe85..989fffab24525d 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/ReadOnlyElement.js +++ b/packages/react-native/src/private/webapis/dom/nodes/ReadOnlyElement.js @@ -193,6 +193,18 @@ export default class ReadOnlyElement extends ReadOnlyNode { return getBoundingClientRect(this, {includeTransform: true}); } + getClientRects(): $ReadOnlyArray { + const node = getNativeElementReference(this); + + if (node != null) { + const rects = NativeDOM.getClientRects(node); + return rects.map(rect => new DOMRect(rect[0], rect[1], rect[2], rect[3])); + } + + // Empty array if any of the above failed + return []; + } + /** * Pointer Capture APIs */ diff --git a/packages/react-native/src/private/webapis/dom/nodes/specs/NativeDOM.js b/packages/react-native/src/private/webapis/dom/nodes/specs/NativeDOM.js index 28f9e4419d3ab2..54bf9c7902eef4 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/specs/NativeDOM.js +++ b/packages/react-native/src/private/webapis/dom/nodes/specs/NativeDOM.js @@ -86,6 +86,12 @@ export interface Spec extends TurboModule { includeTransform: boolean, ) => ReadonlyArray /* [x: number, y: number, width: number, height: number] */; + +getClientRects: ( + nativeElementReference: unknown /* NativeElementReference */, + ) => ReadonlyArray< + ReadonlyArray, + > /* Array<[x: number, y: number, width: number, height: number]> */; + +getInnerSize: ( nativeElementReference: unknown /* NativeElementReference */, ) => ReadonlyArray /* [width: number, height: number] */; @@ -279,6 +285,30 @@ export interface RefinedSpec { ], >; + /** + * This is a React Native implementation of `Element.prototype.getClientRects` + * (see https://developer.mozilla.org/en-US/docs/Web/API/Element/getClientRects). + * + * For most elements, this returns an array with a single rect matching + * getBoundingClientRect. For text elements (TextShadowNode), this returns + * an array of rects representing each line/fragment of the text. + * + * This is useful for getting the visual boundaries of nested + * components within a text paragraph, where text may span multiple lines. + */ + +getClientRects: ( + nativeElementReference: NativeElementReference, + ) => ReadonlyArray< + Readonly< + [ + /* x: */ number, + /* y: */ number, + /* width: */ number, + /* height: */ number, + ], + >, + >; + /** * This is a method to access the inner size of a shadow node, to implement * these methods: