Skip to content

Commit 4ac82f6

Browse files
authored
[web] Correct getPositionForOffset for multi-line paragraphs (flutter#16206)
1 parent 48eadbb commit 4ac82f6

File tree

3 files changed

+195
-8
lines changed

3 files changed

+195
-8
lines changed

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,10 @@ class DomTextMeasurementService extends TextMeasurementService {
373373
@override
374374
ui.TextPosition getTextPositionForOffset(EngineParagraph paragraph,
375375
ui.ParagraphConstraints constraints, ui.Offset offset) {
376-
assert(paragraph._plainText == null, 'should only be called for multispan');
376+
assert(
377+
paragraph._measurementResult.lines == null,
378+
'should only be called when the faster lines-based approach is not possible',
379+
);
377380

378381
final ParagraphGeometricStyle style = paragraph._geometricStyle;
379382
final ParagraphRuler ruler =

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

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -378,17 +378,53 @@ class EngineParagraph implements ui.Paragraph {
378378

379379
@override
380380
ui.TextPosition getPositionForOffset(ui.Offset offset) {
381-
if (_plainText == null) {
381+
final List<EngineLineMetrics> lines = _measurementResult.lines;
382+
if (lines == null) {
382383
return getPositionForMultiSpanOffset(offset);
383384
}
385+
386+
final int lineNumber = offset.dy ~/ _measurementResult.lineHeight;
387+
388+
// [offset] is below all the lines.
389+
if (lineNumber >= lines.length) {
390+
return ui.TextPosition(
391+
offset: _plainText.length,
392+
affinity: ui.TextAffinity.upstream,
393+
);
394+
}
395+
396+
final EngineLineMetrics lineMetrics = lines[lineNumber];
397+
final double lineLeft = lineMetrics.left;
398+
final double lineRight = lineLeft + lineMetrics.width;
399+
400+
// [offset] is to the left of the line.
401+
if (offset.dx <= lineLeft) {
402+
return ui.TextPosition(
403+
offset: lineMetrics.startIndex,
404+
affinity: ui.TextAffinity.downstream,
405+
);
406+
}
407+
408+
// [offset] is to the right of the line.
409+
if (offset.dx >= lineRight) {
410+
return ui.TextPosition(
411+
offset: lineMetrics.endIndex,
412+
affinity: ui.TextAffinity.upstream,
413+
);
414+
}
415+
416+
// If we reach here, it means the [offset] is somewhere within the line. The
417+
// code below will do a binary search to find where exactly the [offset]
418+
// falls within the line.
419+
384420
final double dx = offset.dx - _alignOffset;
385421
final TextMeasurementService instance = _measurementService;
386422

387-
int low = 0;
388-
int high = _plainText.length;
423+
int low = lineMetrics.startIndex;
424+
int high = lineMetrics.endIndex;
389425
do {
390426
final int current = (low + high) ~/ 2;
391-
final double width = instance.measureSubstringWidth(this, 0, current);
427+
final double width = instance.measureSubstringWidth(this, lineMetrics.startIndex, current);
392428
if (width < dx) {
393429
low = current;
394430
} else if (width > dx) {
@@ -403,8 +439,8 @@ class EngineParagraph implements ui.Paragraph {
403439
return ui.TextPosition(offset: high, affinity: ui.TextAffinity.upstream);
404440
}
405441

406-
final double lowWidth = instance.measureSubstringWidth(this, 0, low);
407-
final double highWidth = instance.measureSubstringWidth(this, 0, high);
442+
final double lowWidth = instance.measureSubstringWidth(this, lineMetrics.startIndex, low);
443+
final double highWidth = instance.measureSubstringWidth(this, lineMetrics.startIndex, high);
408444

409445
if (dx - lowWidth < highWidth - dx) {
410446
// The offset is closer to the low index.

lib/web_ui/test/paragraph_test.dart

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import 'package:ui/ui.dart';
88
import 'package:test/test.dart';
99

1010
void testEachMeasurement(String description, VoidCallback body, {bool skip}) {
11-
test(description, () async {
11+
test('$description (dom measurement)', () async {
1212
try {
1313
TextMeasurementService.initialize(rulerCacheCapacity: 2);
1414
return body();
@@ -130,6 +130,154 @@ void main() async {
130130
}, // TODO(nurhan): https://github.com/flutter/flutter/issues/46638
131131
skip: (browserEngine == BrowserEngine.firefox));
132132

133+
testEachMeasurement('getPositionForOffset single-line', () {
134+
final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle(
135+
fontFamily: 'Ahem',
136+
fontStyle: FontStyle.normal,
137+
fontWeight: FontWeight.normal,
138+
fontSize: 10,
139+
textDirection: TextDirection.ltr,
140+
));
141+
builder.addText('abcd efg');
142+
final Paragraph paragraph = builder.build();
143+
paragraph.layout(const ParagraphConstraints(width: 1000));
144+
145+
// At the beginning of the line.
146+
expect(
147+
paragraph.getPositionForOffset(Offset(0, 5)),
148+
TextPosition(offset: 0, affinity: TextAffinity.downstream),
149+
);
150+
// Below the line.
151+
expect(
152+
paragraph.getPositionForOffset(Offset(0, 12)),
153+
TextPosition(offset: 8, affinity: TextAffinity.upstream),
154+
);
155+
// Above the line.
156+
expect(
157+
paragraph.getPositionForOffset(Offset(0, -5)),
158+
TextPosition(offset: 0, affinity: TextAffinity.downstream),
159+
);
160+
// At the end of the line.
161+
expect(
162+
paragraph.getPositionForOffset(Offset(80, 5)),
163+
TextPosition(offset: 8, affinity: TextAffinity.upstream),
164+
);
165+
// On the left side of "b".
166+
expect(
167+
paragraph.getPositionForOffset(Offset(14, 5)),
168+
TextPosition(offset: 1, affinity: TextAffinity.downstream),
169+
);
170+
// On the right side of "b".
171+
expect(
172+
paragraph.getPositionForOffset(Offset(16, 5)),
173+
TextPosition(offset: 2, affinity: TextAffinity.upstream),
174+
);
175+
});
176+
177+
test('getPositionForOffset multi-line', () {
178+
// [Paragraph.getPositionForOffset] for multi-line text doesn't work well
179+
// with dom-based measurement.
180+
TextMeasurementService.enableExperimentalCanvasImplementation = true;
181+
TextMeasurementService.initialize(rulerCacheCapacity: 2);
182+
183+
final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle(
184+
fontFamily: 'Ahem',
185+
fontStyle: FontStyle.normal,
186+
fontWeight: FontWeight.normal,
187+
fontSize: 10,
188+
textDirection: TextDirection.ltr,
189+
));
190+
builder.addText('abcd\n');
191+
builder.addText('abcdefg\n');
192+
builder.addText('ab');
193+
final Paragraph paragraph = builder.build();
194+
paragraph.layout(const ParagraphConstraints(width: 1000));
195+
196+
// First line: "abcd\n"
197+
198+
// At the beginning of the first line.
199+
expect(
200+
paragraph.getPositionForOffset(Offset(0, 5)),
201+
TextPosition(offset: 0, affinity: TextAffinity.downstream),
202+
);
203+
// Above the first line.
204+
expect(
205+
paragraph.getPositionForOffset(Offset(0, -5)),
206+
TextPosition(offset: 0, affinity: TextAffinity.downstream),
207+
);
208+
// At the end of the first line.
209+
expect(
210+
paragraph.getPositionForOffset(Offset(50, 5)),
211+
TextPosition(offset: 5, affinity: TextAffinity.upstream),
212+
);
213+
// On the left side of "b" in the first line.
214+
expect(
215+
paragraph.getPositionForOffset(Offset(14, 5)),
216+
TextPosition(offset: 1, affinity: TextAffinity.downstream),
217+
);
218+
// On the right side of "b" in the first line.
219+
expect(
220+
paragraph.getPositionForOffset(Offset(16, 5)),
221+
TextPosition(offset: 2, affinity: TextAffinity.upstream),
222+
);
223+
224+
225+
// Second line: "abcdefg\n"
226+
227+
// At the beginning of the second line.
228+
expect(
229+
paragraph.getPositionForOffset(Offset(0, 15)),
230+
TextPosition(offset: 5, affinity: TextAffinity.downstream),
231+
);
232+
// At the end of the second line.
233+
expect(
234+
paragraph.getPositionForOffset(Offset(100, 15)),
235+
TextPosition(offset: 13, affinity: TextAffinity.upstream),
236+
);
237+
// On the left side of "e" in the second line.
238+
expect(
239+
paragraph.getPositionForOffset(Offset(44, 15)),
240+
TextPosition(offset: 9, affinity: TextAffinity.downstream),
241+
);
242+
// On the right side of "e" in the second line.
243+
expect(
244+
paragraph.getPositionForOffset(Offset(46, 15)),
245+
TextPosition(offset: 10, affinity: TextAffinity.upstream),
246+
);
247+
248+
249+
// Last (third) line: "ab"
250+
251+
// At the beginning of the last line.
252+
expect(
253+
paragraph.getPositionForOffset(Offset(0, 25)),
254+
TextPosition(offset: 13, affinity: TextAffinity.downstream),
255+
);
256+
// At the end of the last line.
257+
expect(
258+
paragraph.getPositionForOffset(Offset(40, 25)),
259+
TextPosition(offset: 15, affinity: TextAffinity.upstream),
260+
);
261+
// Below the last line.
262+
expect(
263+
paragraph.getPositionForOffset(Offset(0, 32)),
264+
TextPosition(offset: 15, affinity: TextAffinity.upstream),
265+
);
266+
// On the left side of "b" in the last line.
267+
expect(
268+
paragraph.getPositionForOffset(Offset(12, 25)),
269+
TextPosition(offset: 14, affinity: TextAffinity.downstream),
270+
);
271+
// On the right side of "a" in the last line.
272+
expect(
273+
paragraph.getPositionForOffset(Offset(9, 25)),
274+
TextPosition(offset: 14, affinity: TextAffinity.upstream),
275+
);
276+
277+
TextMeasurementService.clearCache();
278+
TextMeasurementService.enableExperimentalCanvasImplementation = false;
279+
});
280+
133281
testEachMeasurement('getBoxesForRange returns a box', () {
134282
final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle(
135283
fontFamily: 'Ahem',

0 commit comments

Comments
 (0)