Skip to content

Commit 7fb7109

Browse files
andrewdacenkofacebook-github-bot
authored andcommitted
Add text fragment rect measurement APIs (facebook#55279)
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 `<Text>` 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
1 parent a0f5910 commit 7fb7109

File tree

4 files changed

+391
-0
lines changed

4 files changed

+391
-0
lines changed

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,60 @@ public float[] measurePreparedLayout(
705705
getYogaMeasureMode(minHeight, maxHeight));
706706
}
707707

708+
/**
709+
* Returns the bounding rectangles for all text fragments that belong to the specified react tag.
710+
* This is useful for getting the visual boundaries of nested {@code <Text>} components within a
711+
* paragraph.
712+
*
713+
* @param preparedLayout The prepared text layout containing the layout and react tags
714+
* @param targetReactTag The react tag of the TextShadowNode to get rects for
715+
* @return A FloatArray containing [x, y, width, height] for each fragment rect, or empty array if
716+
* no fragments match the tag
717+
*/
718+
@AnyThread
719+
@ThreadConfined(ANY)
720+
@UnstableReactNativeAPI
721+
public float[] getFragmentRectsForReactTag(PreparedLayout preparedLayout, int targetReactTag) {
722+
return TextLayoutManager.getFragmentRectsForReactTag(preparedLayout, targetReactTag);
723+
}
724+
725+
/**
726+
* Returns the bounding rectangles for all text fragments that belong to the specified react tag
727+
* by creating a layout on-demand from the AttributedString. This is used as a fallback when
728+
* PreparedLayout is not available (e.g., when enablePreparedTextLayout feature flag is disabled).
729+
*
730+
* @param surfaceId The surface ID to get context from
731+
* @param attributedString The attributed string containing the text fragments
732+
* @param paragraphAttributes The paragraph attributes for layout
733+
* @param width The layout width constraint
734+
* @param height The layout height constraint
735+
* @param targetReactTag The react tag of the TextShadowNode to get rects for
736+
* @return A FloatArray containing [x, y, width, height] for each fragment rect, or empty array if
737+
* no fragments match the tag
738+
*/
739+
@AnyThread
740+
@ThreadConfined(ANY)
741+
@UnstableReactNativeAPI
742+
public float[] getFragmentRectsFromAttributedString(
743+
int surfaceId,
744+
ReadableMapBuffer attributedString,
745+
ReadableMapBuffer paragraphAttributes,
746+
float width,
747+
float height,
748+
int targetReactTag) {
749+
SurfaceMountingManager surfaceMountingManager = mMountingManager.getSurfaceManager(surfaceId);
750+
Context context = surfaceMountingManager != null ? surfaceMountingManager.getContext() : null;
751+
if (context == null) {
752+
FLog.w(
753+
TAG,
754+
"Couldn't get context for surfaceId %d in getFragmentRectsFromAttributedString",
755+
surfaceId);
756+
return new float[0];
757+
}
758+
return TextLayoutManager.getFragmentRectsFromAttributedString(
759+
context, attributedString, paragraphAttributes, width, height, targetReactTag);
760+
}
761+
708762
/**
709763
* @param surfaceId {@link int} surface ID
710764
* @param defaultTextInputPadding {@link float[]} output parameter will contain the default theme

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,6 +1195,234 @@ internal object TextLayoutManager {
11951195
return ret
11961196
}
11971197

1198+
/**
1199+
* Returns the bounding rectangles for all text fragments that belong to the specified react tag.
1200+
* This is useful for getting the visual boundaries of nested <Text> components within a
1201+
* paragraph.
1202+
*
1203+
* @param preparedLayout The prepared text layout containing the layout and react tags
1204+
* @param targetReactTag The react tag of the TextShadowNode to get rects for
1205+
* @return A FloatArray containing [x, y, width, height] for each fragment rect, or empty array if
1206+
* no fragments match the tag
1207+
*/
1208+
@JvmStatic
1209+
fun getFragmentRectsForReactTag(
1210+
preparedLayout: PreparedLayout,
1211+
targetReactTag: Int,
1212+
): FloatArray {
1213+
val layout = preparedLayout.layout
1214+
val text = layout.text as? Spanned ?: return floatArrayOf()
1215+
val reactTags = preparedLayout.reactTags
1216+
val verticalOffset = preparedLayout.verticalOffset
1217+
val maximumNumberOfLines = preparedLayout.maximumNumberOfLines
1218+
1219+
val calculatedLineCount = calculateLineCount(layout, maximumNumberOfLines)
1220+
val retList = ArrayList<Float>()
1221+
1222+
// Find all fragments with the matching react tag
1223+
val fragmentIndexSpans = text.getSpans(0, text.length, ReactFragmentIndexSpan::class.java)
1224+
1225+
for (span in fragmentIndexSpans) {
1226+
val fragmentIndex = span.fragmentIndex
1227+
if (
1228+
fragmentIndex >= 0 &&
1229+
fragmentIndex < reactTags.size &&
1230+
reactTags[fragmentIndex] == targetReactTag
1231+
) {
1232+
// This fragment belongs to the target TextShadowNode
1233+
val start = text.getSpanStart(span)
1234+
val end = text.getSpanEnd(span)
1235+
1236+
addRectsForSpanRange(layout, text, start, end, verticalOffset, calculatedLineCount, retList)
1237+
}
1238+
}
1239+
1240+
val ret = FloatArray(retList.size)
1241+
for (i in retList.indices) {
1242+
ret[i] = retList[i]
1243+
}
1244+
return ret
1245+
}
1246+
1247+
/**
1248+
* Returns the bounding rectangles for all text fragments that belong to the specified react tag.
1249+
* This method works with the legacy ReactTagSpan used when enablePreparedTextLayout is disabled.
1250+
*
1251+
* @param layout The Android text layout
1252+
* @param targetReactTag The react tag of the TextShadowNode to get rects for
1253+
* @param verticalOffset Vertical offset to apply to the rects
1254+
* @param maximumNumberOfLines Maximum number of lines to consider
1255+
* @return A FloatArray containing [x, y, width, height] for each fragment rect, or empty array if
1256+
* no fragments match the tag
1257+
*/
1258+
@JvmStatic
1259+
fun getFragmentRectsForReactTagFromLayout(
1260+
layout: Layout,
1261+
targetReactTag: Int,
1262+
verticalOffset: Float,
1263+
maximumNumberOfLines: Int,
1264+
): FloatArray {
1265+
val text = layout.text as? Spanned ?: return floatArrayOf()
1266+
val calculatedLineCount = calculateLineCount(layout, maximumNumberOfLines)
1267+
val retList = ArrayList<Float>()
1268+
1269+
// Find all ReactTagSpan spans with the matching react tag
1270+
val tagSpans = text.getSpans(0, text.length, ReactTagSpan::class.java)
1271+
1272+
for (span in tagSpans) {
1273+
if (span.reactTag == targetReactTag) {
1274+
val start = text.getSpanStart(span)
1275+
val end = text.getSpanEnd(span)
1276+
1277+
addRectsForSpanRange(layout, text, start, end, verticalOffset, calculatedLineCount, retList)
1278+
}
1279+
}
1280+
1281+
val ret = FloatArray(retList.size)
1282+
for (i in retList.indices) {
1283+
ret[i] = retList[i]
1284+
}
1285+
return ret
1286+
}
1287+
1288+
/**
1289+
* Returns the bounding rectangles for all text fragments that belong to the specified react tag
1290+
* by creating a layout on-demand from the AttributedString. This is used as a fallback when
1291+
* PreparedLayout is not available (e.g., when enablePreparedTextLayout feature flag is disabled).
1292+
*
1293+
* @param context The Android context
1294+
* @param attributedString The attributed string containing the text fragments
1295+
* @param paragraphAttributes The paragraph attributes for layout
1296+
* @param width The layout width constraint
1297+
* @param height The layout height constraint
1298+
* @param targetReactTag The react tag of the TextShadowNode to get rects for
1299+
* @return A FloatArray containing [x, y, width, height] for each fragment rect, or empty array if
1300+
* no fragments match the tag
1301+
*/
1302+
@JvmStatic
1303+
fun getFragmentRectsFromAttributedString(
1304+
context: Context,
1305+
attributedString: ReadableMapBuffer,
1306+
paragraphAttributes: ReadableMapBuffer,
1307+
width: Float,
1308+
height: Float,
1309+
targetReactTag: Int,
1310+
): FloatArray {
1311+
val fragments = attributedString.getMapBuffer(AS_KEY_FRAGMENTS)
1312+
// Pass null for outputReactTags since we'll use ReactTagSpan directly
1313+
val text =
1314+
createSpannableFromAttributedString(
1315+
context,
1316+
fragments,
1317+
null, // reactTextViewManagerCallback
1318+
null, // outputReactTags - this ensures ReactTagSpan is used
1319+
)
1320+
1321+
val baseTextAttributes =
1322+
TextAttributeProps.fromMapBuffer(attributedString.getMapBuffer(AS_KEY_BASE_ATTRIBUTES))
1323+
1324+
// Width and height from C++ are in DIPs, but createLayout expects pixels
1325+
// Convert to pixels for correct text wrapping
1326+
val widthInPx = width.dpToPx()
1327+
val heightInPx = height.dpToPx()
1328+
1329+
val layout =
1330+
createLayout(
1331+
text,
1332+
newPaintWithAttributes(baseTextAttributes, context),
1333+
attributedString,
1334+
paragraphAttributes,
1335+
widthInPx,
1336+
YogaMeasureMode.EXACTLY,
1337+
heightInPx,
1338+
YogaMeasureMode.UNDEFINED,
1339+
)
1340+
1341+
val maximumNumberOfLines =
1342+
if (paragraphAttributes.contains(PA_KEY_MAX_NUMBER_OF_LINES))
1343+
paragraphAttributes.getInt(PA_KEY_MAX_NUMBER_OF_LINES)
1344+
else ReactConstants.UNSET
1345+
1346+
val verticalOffset =
1347+
getVerticalOffset(
1348+
layout,
1349+
paragraphAttributes,
1350+
heightInPx,
1351+
YogaMeasureMode.UNDEFINED,
1352+
maximumNumberOfLines,
1353+
)
1354+
1355+
return getFragmentRectsForReactTagFromLayout(
1356+
layout,
1357+
targetReactTag,
1358+
verticalOffset,
1359+
maximumNumberOfLines,
1360+
)
1361+
}
1362+
1363+
private fun addRectsForSpanRange(
1364+
layout: Layout,
1365+
text: Spanned,
1366+
start: Int,
1367+
end: Int,
1368+
verticalOffset: Float,
1369+
calculatedLineCount: Int,
1370+
retList: ArrayList<Float>,
1371+
) {
1372+
if (start < 0 || end < 0 || start >= end) {
1373+
return
1374+
}
1375+
1376+
// Get the bounding rect for this text range
1377+
// We need to handle multi-line text fragments by getting rects for each line
1378+
val startLine = layout.getLineForOffset(start)
1379+
val endLine = layout.getLineForOffset(end - 1)
1380+
1381+
for (line in startLine..min(endLine, calculatedLineCount - 1)) {
1382+
val lineStart = layout.getLineStart(line)
1383+
val lineEnd = layout.getLineEnd(line)
1384+
1385+
// Calculate the portion of this fragment on this line
1386+
val fragmentStartOnLine = max(start, lineStart)
1387+
val fragmentEndOnLine = min(end, lineEnd)
1388+
1389+
if (fragmentStartOnLine >= fragmentEndOnLine) {
1390+
continue
1391+
}
1392+
1393+
// Get the horizontal bounds
1394+
// For the left position, use getPrimaryHorizontal at the fragment start
1395+
val left = layout.getPrimaryHorizontal(fragmentStartOnLine)
1396+
1397+
// For the right position, we need to handle the case where the fragment
1398+
// ends at a line break. In this case, getPrimaryHorizontal at the end
1399+
// returns the same as at the start of the next line (or line start).
1400+
// Instead, we should use the line's right bound or the position just before
1401+
// the line end character.
1402+
val right: Float
1403+
if (fragmentEndOnLine >= lineEnd && lineEnd > lineStart) {
1404+
// Fragment goes to the end of the line - use the position before the newline
1405+
// or use getLineRight for the actual right edge of text on this line
1406+
right = layout.getLineRight(line)
1407+
} else {
1408+
right = layout.getPrimaryHorizontal(fragmentEndOnLine)
1409+
}
1410+
1411+
// Get the vertical bounds
1412+
val top = layout.getLineTop(line) + verticalOffset
1413+
val bottom = layout.getLineBottom(line) + verticalOffset
1414+
1415+
// Ensure left is less than right (RTL text handling)
1416+
val rectLeft = min(left, right)
1417+
val rectRight = max(left, right)
1418+
1419+
retList.add(rectLeft.pxToDp())
1420+
retList.add(top.pxToDp())
1421+
retList.add((rectRight - rectLeft).pxToDp())
1422+
retList.add((bottom - top).pxToDp())
1423+
}
1424+
}
1425+
11981426
private fun getVerticalOffset(
11991427
layout: Layout,
12001428
paragraphAttributes: ReadableMapBuffer,

packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.cpp

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,4 +437,93 @@ TextMeasurement TextLayoutManager::measurePreparedLayout(
437437
return textMeasurement;
438438
}
439439

440+
std::vector<Rect> TextLayoutManager::getFragmentRectsForReactTag(
441+
const PreparedLayout& preparedLayout,
442+
Tag targetReactTag) const {
443+
const auto& fabricUIManager =
444+
contextContainer_->at<jni::global_ref<jobject>>("FabricUIManager");
445+
446+
static auto getFragmentRectsForReactTagMethod =
447+
jni::findClassStatic("com/facebook/react/fabric/FabricUIManager")
448+
->getMethod<jni::JArrayFloat(JPreparedLayout::javaobject, jint)>(
449+
"getFragmentRectsForReactTag");
450+
451+
auto rectsArr = getFragmentRectsForReactTagMethod(
452+
fabricUIManager, preparedLayout.get(), targetReactTag);
453+
454+
std::vector<Rect> result;
455+
if (rectsArr == nullptr || rectsArr->size() == 0) {
456+
return result;
457+
}
458+
459+
auto rects = rectsArr->getRegion(0, static_cast<jsize>(rectsArr->size()));
460+
461+
// The array contains [x, y, width, height] for each rect
462+
react_native_assert(rectsArr->size() % 4 == 0);
463+
result.reserve(rectsArr->size() / 4);
464+
465+
for (size_t i = 0; i < rectsArr->size(); i += 4) {
466+
result.push_back(
467+
Rect{
468+
.origin = {.x = rects[i], .y = rects[i + 1]},
469+
.size = {.width = rects[i + 2], .height = rects[i + 3]}});
470+
}
471+
472+
return result;
473+
}
474+
475+
std::vector<Rect> TextLayoutManager::getFragmentRectsFromAttributedString(
476+
Tag surfaceId,
477+
const AttributedString& attributedString,
478+
const ParagraphAttributes& paragraphAttributes,
479+
const LayoutConstraints& layoutConstraints,
480+
Tag targetReactTag) const {
481+
const auto& fabricUIManager =
482+
contextContainer_->at<jni::global_ref<jobject>>("FabricUIManager");
483+
484+
static auto getFragmentRectsFromAttributedStringMethod =
485+
jni::findClassStatic("com/facebook/react/fabric/FabricUIManager")
486+
->getMethod<jni::JArrayFloat(
487+
jint,
488+
JReadableMapBuffer::javaobject,
489+
JReadableMapBuffer::javaobject,
490+
jfloat,
491+
jfloat,
492+
jint)>("getFragmentRectsFromAttributedString");
493+
494+
auto attributedStringMB =
495+
JReadableMapBuffer::createWithContents(toMapBuffer(attributedString));
496+
auto paragraphAttributesMB =
497+
JReadableMapBuffer::createWithContents(toMapBuffer(paragraphAttributes));
498+
499+
auto rectsArr = getFragmentRectsFromAttributedStringMethod(
500+
fabricUIManager,
501+
surfaceId,
502+
attributedStringMB.get(),
503+
paragraphAttributesMB.get(),
504+
layoutConstraints.maximumSize.width,
505+
layoutConstraints.maximumSize.height,
506+
targetReactTag);
507+
508+
std::vector<Rect> result;
509+
if (rectsArr == nullptr || rectsArr->size() == 0) {
510+
return result;
511+
}
512+
513+
auto rects = rectsArr->getRegion(0, static_cast<jsize>(rectsArr->size()));
514+
515+
// The array contains [x, y, width, height] for each rect
516+
react_native_assert(rectsArr->size() % 4 == 0);
517+
result.reserve(rectsArr->size() / 4);
518+
519+
for (size_t i = 0; i < rectsArr->size(); i += 4) {
520+
result.push_back(
521+
Rect{
522+
.origin = {.x = rects[i], .y = rects[i + 1]},
523+
.size = {.width = rects[i + 2], .height = rects[i + 3]}});
524+
}
525+
526+
return result;
527+
}
528+
440529
} // namespace facebook::react

0 commit comments

Comments
 (0)