Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Text>} 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Text> 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<Float>()

// 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<Float>()

// 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<Float>,
) {
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,
Expand Down
45 changes: 45 additions & 0 deletions packages/react-native/ReactCommon/react/renderer/dom/DOM.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
*/

#include "DOM.h"
#include <react/renderer/components/text/ParagraphShadowNode.h>
#include <react/renderer/components/text/RawTextShadowNode.h>
#include <react/renderer/components/text/TextShadowNode.h>
#include <react/renderer/core/LayoutMetrics.h>
#include <react/renderer/graphics/Point.h>
#include <react/renderer/graphics/Rect.h>
Expand Down Expand Up @@ -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<const TextShadowNode*>(&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<const ParagraphShadowNode*>(&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,
Expand Down
Loading
Loading