Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit ade75e0

Browse files
authored
[web] Rich paragraph getPositionForOffset (#23133)
1 parent 6e54f0d commit ade75e0

File tree

3 files changed

+359
-4
lines changed

3 files changed

+359
-4
lines changed

lib/web_ui/lib/src/engine/text/canvas_paragraph.dart

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,7 @@ class CanvasParagraph implements EngineParagraph {
197197

198198
@override
199199
ui.TextPosition getPositionForOffset(ui.Offset offset) {
200-
// TODO(mdebbar): After layout, each paragraph span should have info about
201-
// its position and dimensions. Use that information to find which span the
202-
// offset belongs to, then search within that span for the exact character.
203-
return const ui.TextPosition(offset: 0);
200+
return _layoutService.getPositionForOffset(offset);
204201
}
205202

206203
@override

lib/web_ui/lib/src/engine/text/layout_service.dart

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,64 @@ class TextLayoutService {
263263
}
264264
return boxes;
265265
}
266+
267+
ui.TextPosition getPositionForOffset(ui.Offset offset) {
268+
// After layout, each line has boxes that contain enough information to make
269+
// it possible to do hit testing. Once we find the box, we look inside that
270+
// box to find where exactly the `offset` is located.
271+
272+
// [offset] is above all the lines.
273+
if (offset.dy < 0) {
274+
return ui.TextPosition(offset: 0, affinity: ui.TextAffinity.downstream);
275+
}
276+
277+
// [offset] is below all the lines.
278+
if (offset.dy >= paragraph.height) {
279+
return ui.TextPosition(
280+
offset: paragraph.toPlainText().length,
281+
affinity: ui.TextAffinity.upstream,
282+
);
283+
}
284+
285+
final EngineLineMetrics line = _findLineForY(offset.dy);
286+
// [offset] is to the left of the line.
287+
if (offset.dx <= line.left) {
288+
return ui.TextPosition(
289+
offset: line.startIndex,
290+
affinity: ui.TextAffinity.downstream,
291+
);
292+
}
293+
294+
// [offset] is to the right of the line.
295+
if (offset.dx >= line.left + line.widthWithTrailingSpaces) {
296+
return ui.TextPosition(
297+
offset: line.endIndexWithoutNewlines,
298+
affinity: ui.TextAffinity.upstream,
299+
);
300+
}
301+
302+
final double dx = offset.dx - line.left;
303+
for (final RangeBox box in line.boxes!) {
304+
if (box.left <= dx && dx <= box.right) {
305+
return box.getPositionForX(dx);
306+
}
307+
}
308+
// Is this ever reachable?
309+
return ui.TextPosition(offset: line.startIndex);
310+
}
311+
312+
EngineLineMetrics _findLineForY(double y) {
313+
// We could do a binary search here but it's not worth it because the number
314+
// of line is typically low, and each iteration is a cheap comparison of
315+
// doubles.
316+
for (EngineLineMetrics line in lines) {
317+
if (y <= line.height) {
318+
return line;
319+
}
320+
y -= line.height;
321+
}
322+
return lines.last;
323+
}
266324
}
267325

268326
/// Represents a box inside [span] with the range of [start] to [end].
@@ -347,6 +405,61 @@ class RangeBox {
347405
direction,
348406
);
349407
}
408+
409+
/// Returns the text position within this box's range that's closest to the
410+
/// given [x] offset.
411+
///
412+
/// The [x] offset is expected to be relative to the left edge of the line,
413+
/// just like the coordinates of this box.
414+
ui.TextPosition getPositionForX(double x) {
415+
spanometer.currentSpan = span as FlatTextSpan;
416+
417+
// Make `x` relative to this box.
418+
x -= left;
419+
420+
final int startIndex = start.index;
421+
final int endIndex = end.indexWithoutTrailingNewlines;
422+
// The resulting `cutoff` is the index of the character where the `x` offset
423+
// falls. We should return the text position of either `cutoff` or
424+
// `cutoff + 1` depending on which one `x` is closer to.
425+
//
426+
// offset x
427+
// ↓
428+
// "A B C D E F"
429+
// ↑
430+
// cutoff
431+
final int cutoff = spanometer.forceBreak(
432+
startIndex,
433+
endIndex,
434+
availableWidth: x,
435+
allowEmpty: true,
436+
);
437+
438+
if (cutoff == endIndex) {
439+
return ui.TextPosition(
440+
offset: cutoff,
441+
affinity: ui.TextAffinity.upstream,
442+
);
443+
}
444+
445+
final double lowWidth = spanometer._measure(startIndex, cutoff);
446+
final double highWidth = spanometer._measure(startIndex, cutoff + 1);
447+
448+
// See if `x` is closer to `cutoff` or `cutoff + 1`.
449+
if (x - lowWidth < highWidth - x) {
450+
// The offset is closer to cutoff.
451+
return ui.TextPosition(
452+
offset: cutoff,
453+
affinity: ui.TextAffinity.downstream,
454+
);
455+
} else {
456+
// The offset is closer to cutoff + 1.
457+
return ui.TextPosition(
458+
offset: cutoff + 1,
459+
affinity: ui.TextAffinity.upstream,
460+
);
461+
}
462+
}
350463
}
351464

352465
/// Represents a segment in a line of a paragraph.

lib/web_ui/test/text/canvas_paragraph_test.dart

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,246 @@ void testMain() async {
307307
);
308308
});
309309
});
310+
311+
group('$CanvasParagraph.getPositionForOffset', () {
312+
test('handles single-line multi-span paragraphs', () {
313+
final CanvasParagraph paragraph = rich(ahemStyle, (builder) {
314+
builder.pushStyle(EngineTextStyle.only(color: blue));
315+
builder.addText('Lorem ');
316+
builder.pushStyle(EngineTextStyle.only(color: green));
317+
builder.addText('ipsum ');
318+
builder.pop();
319+
builder.addText('.');
320+
})
321+
..layout(constrain(double.infinity));
322+
323+
// Above the line.
324+
expect(
325+
paragraph.getPositionForOffset(ui.Offset(0, -5)),
326+
pos(0, ui.TextAffinity.downstream),
327+
);
328+
// At the top left corner of the line.
329+
expect(
330+
paragraph.getPositionForOffset(ui.Offset(0, 0)),
331+
pos(0, ui.TextAffinity.downstream),
332+
);
333+
// At the beginning of the line.
334+
expect(
335+
paragraph.getPositionForOffset(ui.Offset(0, 5)),
336+
pos(0, ui.TextAffinity.downstream),
337+
);
338+
// Below the line.
339+
expect(
340+
paragraph.getPositionForOffset(ui.Offset(0, 12)),
341+
pos(13, ui.TextAffinity.upstream),
342+
);
343+
// At the end of the line.
344+
expect(
345+
paragraph.getPositionForOffset(ui.Offset(130, 5)),
346+
pos(13, ui.TextAffinity.upstream),
347+
);
348+
// On the left half of "p" in "ipsum".
349+
expect(
350+
paragraph.getPositionForOffset(ui.Offset(74, 5)),
351+
pos(7, ui.TextAffinity.downstream),
352+
);
353+
// On the right half of "p" in "ipsum".
354+
expect(
355+
paragraph.getPositionForOffset(ui.Offset(76, 5)),
356+
pos(8, ui.TextAffinity.upstream),
357+
);
358+
// At the top of the line, on the left half of "p" in "ipsum".
359+
expect(
360+
paragraph.getPositionForOffset(ui.Offset(74, 0)),
361+
pos(7, ui.TextAffinity.downstream),
362+
);
363+
// At the top of the line, on the right half of "p" in "ipsum".
364+
expect(
365+
paragraph.getPositionForOffset(ui.Offset(76, 0)),
366+
pos(8, ui.TextAffinity.upstream),
367+
);
368+
});
369+
370+
test('handles multi-line single-span paragraphs', () {
371+
final CanvasParagraph paragraph = rich(ahemStyle, (builder) {
372+
builder.addText('Lorem ipsum dolor sit');
373+
})
374+
..layout(constrain(90.0));
375+
376+
// Lines:
377+
// "Lorem "
378+
// "ipsum "
379+
// "dolor sit"
380+
381+
// Above the first line.
382+
expect(
383+
paragraph.getPositionForOffset(ui.Offset(0, -5)),
384+
pos(0, ui.TextAffinity.downstream),
385+
);
386+
// At the top left corner of the line.
387+
expect(
388+
paragraph.getPositionForOffset(ui.Offset(0, 0)),
389+
pos(0, ui.TextAffinity.downstream),
390+
);
391+
// At the beginning of the first line.
392+
expect(
393+
paragraph.getPositionForOffset(ui.Offset(0, 5)),
394+
pos(0, ui.TextAffinity.downstream),
395+
);
396+
// At the end of the first line.
397+
expect(
398+
paragraph.getPositionForOffset(ui.Offset(60, 5)),
399+
pos(6, ui.TextAffinity.upstream),
400+
);
401+
// After the end of the first line to the right.
402+
expect(
403+
paragraph.getPositionForOffset(ui.Offset(70, 5)),
404+
pos(6, ui.TextAffinity.upstream),
405+
);
406+
// On the left half of " " in "Lorem ".
407+
expect(
408+
paragraph.getPositionForOffset(ui.Offset(54, 5)),
409+
pos(5, ui.TextAffinity.downstream),
410+
);
411+
// On the right half of " " in "Lorem ".
412+
expect(
413+
paragraph.getPositionForOffset(ui.Offset(56, 5)),
414+
pos(6, ui.TextAffinity.upstream),
415+
);
416+
417+
// At the beginning of the second line "ipsum ".
418+
expect(
419+
paragraph.getPositionForOffset(ui.Offset(0, 15)),
420+
pos(6, ui.TextAffinity.downstream),
421+
);
422+
// At the end of the second line.
423+
expect(
424+
paragraph.getPositionForOffset(ui.Offset(60, 15)),
425+
pos(12, ui.TextAffinity.upstream),
426+
);
427+
// After the end of the second line to the right.
428+
expect(
429+
paragraph.getPositionForOffset(ui.Offset(70, 15)),
430+
pos(12, ui.TextAffinity.upstream),
431+
);
432+
433+
// Below the third line "dolor sit".
434+
expect(
435+
paragraph.getPositionForOffset(ui.Offset(0, 40)),
436+
pos(21, ui.TextAffinity.upstream),
437+
);
438+
// At the end of the third line.
439+
expect(
440+
paragraph.getPositionForOffset(ui.Offset(90, 25)),
441+
pos(21, ui.TextAffinity.upstream),
442+
);
443+
// After the end of the third line to the right.
444+
expect(
445+
paragraph.getPositionForOffset(ui.Offset(100, 25)),
446+
pos(21, ui.TextAffinity.upstream),
447+
);
448+
// On the left half of " " in "dolor sit".
449+
expect(
450+
paragraph.getPositionForOffset(ui.Offset(54, 25)),
451+
pos(17, ui.TextAffinity.downstream),
452+
);
453+
// On the right half of " " in "dolor sit".
454+
expect(
455+
paragraph.getPositionForOffset(ui.Offset(56, 25)),
456+
pos(18, ui.TextAffinity.upstream),
457+
);
458+
});
459+
460+
test('handles multi-line multi-span paragraphs', () {
461+
final CanvasParagraph paragraph = rich(ahemStyle, (builder) {
462+
builder.pushStyle(EngineTextStyle.only(color: blue));
463+
builder.addText('Lorem ipsum ');
464+
builder.pushStyle(EngineTextStyle.only(color: green));
465+
builder.addText('dolor ');
466+
builder.pop();
467+
builder.addText('sit');
468+
})
469+
..layout(constrain(90.0));
470+
471+
// Lines:
472+
// "Lorem "
473+
// "ipsum "
474+
// "dolor sit"
475+
476+
// Above the first line.
477+
expect(
478+
paragraph.getPositionForOffset(ui.Offset(0, -5)),
479+
pos(0, ui.TextAffinity.downstream),
480+
);
481+
// At the beginning of the first line.
482+
expect(
483+
paragraph.getPositionForOffset(ui.Offset(0, 5)),
484+
pos(0, ui.TextAffinity.downstream),
485+
);
486+
// At the end of the first line.
487+
expect(
488+
paragraph.getPositionForOffset(ui.Offset(60, 5)),
489+
pos(6, ui.TextAffinity.upstream),
490+
);
491+
// After the end of the first line to the right.
492+
expect(
493+
paragraph.getPositionForOffset(ui.Offset(70, 5)),
494+
pos(6, ui.TextAffinity.upstream),
495+
);
496+
// On the left half of " " in "Lorem ".
497+
expect(
498+
paragraph.getPositionForOffset(ui.Offset(54, 5)),
499+
pos(5, ui.TextAffinity.downstream),
500+
);
501+
// On the right half of " " in "Lorem ".
502+
expect(
503+
paragraph.getPositionForOffset(ui.Offset(56, 5)),
504+
pos(6, ui.TextAffinity.upstream),
505+
);
506+
507+
// At the beginning of the second line "ipsum ".
508+
expect(
509+
paragraph.getPositionForOffset(ui.Offset(0, 15)),
510+
pos(6, ui.TextAffinity.downstream),
511+
);
512+
// At the end of the second line.
513+
expect(
514+
paragraph.getPositionForOffset(ui.Offset(60, 15)),
515+
pos(12, ui.TextAffinity.upstream),
516+
);
517+
// After the end of the second line to the right.
518+
expect(
519+
paragraph.getPositionForOffset(ui.Offset(70, 15)),
520+
pos(12, ui.TextAffinity.upstream),
521+
);
522+
523+
// Below the third line "dolor sit".
524+
expect(
525+
paragraph.getPositionForOffset(ui.Offset(0, 40)),
526+
pos(21, ui.TextAffinity.upstream),
527+
);
528+
// At the end of the third line.
529+
expect(
530+
paragraph.getPositionForOffset(ui.Offset(90, 25)),
531+
pos(21, ui.TextAffinity.upstream),
532+
);
533+
// After the end of the third line to the right.
534+
expect(
535+
paragraph.getPositionForOffset(ui.Offset(100, 25)),
536+
pos(21, ui.TextAffinity.upstream),
537+
);
538+
// On the left half of " " in "dolor sit".
539+
expect(
540+
paragraph.getPositionForOffset(ui.Offset(54, 25)),
541+
pos(17, ui.TextAffinity.downstream),
542+
);
543+
// On the right half of " " in "dolor sit".
544+
expect(
545+
paragraph.getPositionForOffset(ui.Offset(56, 25)),
546+
pos(18, ui.TextAffinity.upstream),
547+
);
548+
});
549+
});
310550
}
311551

312552
/// Shortcut to create a [ui.TextBox] with an optional [ui.TextDirection].
@@ -319,3 +559,8 @@ ui.TextBox box(
319559
]) {
320560
return ui.TextBox.fromLTRBD(left, top, right, bottom, direction);
321561
}
562+
563+
/// Shortcut to create a [ui.TextPosition].
564+
ui.TextPosition pos(int offset, ui.TextAffinity affinity) {
565+
return ui.TextPosition(offset: offset, affinity: affinity);
566+
}

0 commit comments

Comments
 (0)