diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/engine_canvas.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/engine_canvas.dart index 18593f3e5af..49942c15d9b 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/engine_canvas.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/engine_canvas.dart @@ -254,9 +254,6 @@ html.Element _drawParagraphElement( assert(paragraph.isLaidOut); final html.HtmlElement paragraphElement = paragraph.toDomElement(); - paragraphElement.style - ..height = '${paragraph.height}px' - ..width = '${paragraph.width}px'; if (transform != null) { setElementTransform( diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart index 66ff52ddf9a..af41960ec6c 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text/canvas_paragraph.dart @@ -96,6 +96,7 @@ class CanvasParagraph implements EngineParagraph { isLaidOut = true; _lastUsedConstraints = constraints; + _cachedDomElement = null; } // TODO(mdebbar): Returning true means we always require a bitmap canvas. Revisit @@ -115,6 +116,7 @@ class CanvasParagraph implements EngineParagraph { @override html.HtmlElement toDomElement() { + assert(isLaidOut); final html.HtmlElement? domElement = _cachedDomElement; if (domElement == null) { return _cachedDomElement ??= _createDomElement(); @@ -123,49 +125,73 @@ class CanvasParagraph implements EngineParagraph { } html.HtmlElement _createDomElement() { - final html.HtmlElement element = + final html.HtmlElement rootElement = domRenderer.createElement('p') as html.HtmlElement; // 1. Set paragraph-level styles. - _applyParagraphStyleToElement(element: element, style: paragraphStyle); - final html.CssStyleDeclaration cssStyle = element.style; + _applyParagraphStyleToElement(element: rootElement, style: paragraphStyle); + final html.CssStyleDeclaration cssStyle = rootElement.style; cssStyle ..position = 'absolute' - ..whiteSpace = 'pre-wrap' - ..overflowWrap = 'break-word'; + // Prevent the browser from doing any line breaks in the paragraph. We want + // to insert our own
breaks based on layout results. + ..whiteSpace = 'pre'; if (paragraphStyle._maxLines != null || paragraphStyle._ellipsis != null) { - cssStyle..overflowY = 'hidden'; + cssStyle + ..overflowY = 'hidden' + ..height = '${height}px'; } if (paragraphStyle._ellipsis != null && (paragraphStyle._maxLines == null || paragraphStyle._maxLines == 1)) { cssStyle + ..width = '${width}px' ..overflowX = 'hidden' - ..whiteSpace = 'pre' ..textOverflow = 'ellipsis'; } // 2. Append all spans to the paragraph. - for (final ParagraphSpan span in spans) { - if (span is FlatTextSpan) { - final html.HtmlElement spanElement = - domRenderer.createElement('span') as html.HtmlElement; - _applyTextStyleToElement( - element: spanElement, - style: span.style, - isSpan: true, - ); - domRenderer.appendText(spanElement, span.textOf(this)); - domRenderer.append(element, spanElement); - } else if (span is PlaceholderSpan) { - domRenderer.append( - element, - _createPlaceholderElement(placeholder: span), - ); + + ParagraphSpan? span; + late html.HtmlElement element; + final List lines = computeLineMetrics(); + + for (int i = 0; i < lines.length; i++) { + // Insert a
element before each line except the first line. + if (i > 0) { + domRenderer.append(element, domRenderer.createElement('br')); + } + + for (final RangeBox box in lines[i].boxes!) { + if (box is SpanBox) { + if (box.span != span) { + span = box.span; + element = domRenderer.createElement('span') as html.HtmlElement; + _applyTextStyleToElement( + element: element, + style: box.span.style, + isSpan: true + ); + domRenderer.append(rootElement, element); + } + domRenderer.appendText(element, box.toText()); + } else if (box is PlaceholderBox) { + span = box.placeholder; + // If there's a line-end after this placeholder, we want the
to + // be inserted in the root paragraph element. + element = rootElement; + domRenderer.append( + rootElement, + _createPlaceholderElement(placeholder: box.placeholder), + ); + } else { + throw UnimplementedError('Unknown box type: ${box.runtimeType}'); + } } } - return element; + + return rootElement; } @override diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_service.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_service.dart index b5a57755bd1..120b38c6d40 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_service.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_service.dart @@ -491,6 +491,13 @@ class SpanBox extends RangeBox { return startIndex < this.end.index && this.start.index < endIndex; } + /// Returns the substring of the paragraph that's represented by this box. + /// + /// Trailing newlines are omitted, if any. + String toText() { + return spanometer.paragraph.toPlainText().substring(start.index, end.indexWithoutTrailingNewlines); + } + /// Returns a [ui.TextBox] representing this range box in the given [line]. /// /// The coordinates of the resulting [ui.TextBox] are relative to the diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text/paragraph.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text/paragraph.dart index 67fec31b85d..3c4207b3719 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text/paragraph.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text/paragraph.dart @@ -480,6 +480,8 @@ class DomParagraph implements EngineParagraph { final html.CssStyleDeclaration paragraphStyle = paragraphElement.style; paragraphStyle + ..height = '${height}px' + ..width = '${width}px' ..position = 'absolute' ..whiteSpace = 'pre-wrap' ..overflowWrap = 'break-word' diff --git a/engine/src/flutter/lib/web_ui/test/golden_tests/engine/compositing_golden_test.dart b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/compositing_golden_test.dart index 0cfc7de5c3d..18fd6c77ef6 100644 --- a/engine/src/flutter/lib/web_ui/test/golden_tests/engine/compositing_golden_test.dart +++ b/engine/src/flutter/lib/web_ui/test/golden_tests/engine/compositing_golden_test.dart @@ -547,8 +547,8 @@ void _testCullRectComputation() { 'renders clipped text with high quality', () async { // To reproduce blurriness we need real clipping. - final Paragraph paragraph = - (ParagraphBuilder(ParagraphStyle(fontFamily: 'Roboto'))..addText('Am I blurry?')).build(); + final DomParagraph paragraph = + (DomParagraphBuilder(ParagraphStyle(fontFamily: 'Roboto'))..addText('Am I blurry?')).build(); paragraph.layout(const ParagraphConstraints(width: 1000)); final Rect canvasSize = Rect.fromLTRB( diff --git a/engine/src/flutter/lib/web_ui/test/text/canvas_paragraph_builder_test.dart b/engine/src/flutter/lib/web_ui/test/text/canvas_paragraph_builder_test.dart index 78db0b04141..929cdba0ddf 100644 --- a/engine/src/flutter/lib/web_ui/test/text/canvas_paragraph_builder_test.dart +++ b/engine/src/flutter/lib/web_ui/test/text/canvas_paragraph_builder_test.dart @@ -8,17 +8,30 @@ import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; import 'package:ui/ui.dart'; -const String paragraphStyle = - 'font-family: sans-serif; position: absolute; white-space: pre-wrap; overflow-wrap: break-word;'; +bool get isIosSafari => browserEngine == BrowserEngine.webkit && + operatingSystem == OperatingSystem.iOs; + +String get defaultFontFamily { + String fontFamily = canonicalizeFontFamily('Ahem')!; + if (browserEngine == BrowserEngine.firefox) { + fontFamily = fontFamily.replaceAll('"', '"'); + } else if (browserEngine == BrowserEngine.blink || browserEngine == BrowserEngine.webkit) { + fontFamily = fontFamily.replaceAll('"', ''); + } + return 'font-family: $fontFamily;'; +} const String defaultColor = 'color: rgb(255, 0, 0);'; -const String defaultFontFamily = 'font-family: sans-serif;'; const String defaultFontSize = 'font-size: 14px;'; +final String paragraphStyle = + '$defaultFontFamily position: absolute; white-space: pre;'; void main() { internalBootstrapBrowserTest(() => testMain); } -void testMain() { +void testMain() async { + await webOnlyInitializeTestDomRenderer(); + setUpAll(() { WebExperiments.ensureInitialized(); }); @@ -32,6 +45,9 @@ void testMain() { final CanvasParagraph paragraph = builder.build(); expect(paragraph.paragraphStyle, style); expect(paragraph.toPlainText(), 'Hello'); + expect(paragraph.spans, hasLength(1)); + + paragraph.layout(ParagraphConstraints(width: double.infinity)); expect( paragraph.toDomElement().outerHtml, '

' @@ -40,7 +56,17 @@ void testMain() { '' '

', ); - expect(paragraph.spans, hasLength(1)); + + // Should break "Hello" into "Hel" and "lo". + paragraph.layout(ParagraphConstraints(width: 39.0)); + expect( + paragraph.toDomElement().outerHtml, + '

' + '' + 'Hel
lo' + '
' + '

', + ); final ParagraphSpan span = paragraph.spans.single; expect(span, isA()); @@ -58,11 +84,13 @@ void testMain() { final CanvasParagraph paragraph = builder.build(); expect(paragraph.paragraphStyle, style); expect(paragraph.toPlainText(), 'Hello'); + expect(paragraph.spans, hasLength(1)); + + paragraph.layout(ParagraphConstraints(width: double.infinity)); expect( paragraph.toDomElement().outerHtml, '

Hello

', ); - expect(paragraph.spans, hasLength(1)); final FlatTextSpan textSpan = paragraph.spans.single as FlatTextSpan; expect(textSpan.style, styleWithDefaults()); @@ -77,9 +105,16 @@ void testMain() { final CanvasParagraph paragraph = builder.build(); expect(paragraph.paragraphStyle, style); expect(paragraph.toPlainText(), 'Hello'); + + double expectedHeight = 14.0; + if (isIosSafari) { + // On iOS Safari, the height measurement is one extra pixel. + expectedHeight++; + } + paragraph.layout(ParagraphConstraints(width: double.infinity)); expect( paragraph.toDomElement().outerHtml, - '

' + '

' '' 'Hello' '' @@ -96,13 +131,16 @@ void testMain() { final CanvasParagraph paragraph = builder.build(); expect(paragraph.paragraphStyle, style); expect(paragraph.toPlainText(), 'Hello'); - final String expectedParagraphStyle = paragraphStyle.replaceFirst( - 'white-space: pre-wrap', - 'white-space: pre', - ); + + double expectedHeight = 14.0; + if (isIosSafari) { + // On iOS Safari, the height measurement is one extra pixel. + expectedHeight++; + } + paragraph.layout(ParagraphConstraints(width: double.infinity)); expect( paragraph.toDomElement().outerHtml, - '

' + '

' '' 'Hello' '' @@ -125,6 +163,9 @@ void testMain() { final CanvasParagraph paragraph = builder.build(); expect(paragraph.toPlainText(), 'Hello'); + expect(paragraph.spans, hasLength(1)); + + paragraph.layout(ParagraphConstraints(width: double.infinity)); expect( paragraph.toDomElement().outerHtml, '

' @@ -133,7 +174,6 @@ void testMain() { '' '

', ); - expect(paragraph.spans, hasLength(1)); final FlatTextSpan span = paragraph.spans.single as FlatTextSpan; expect(span.textOf(paragraph), 'Hello'); @@ -161,6 +201,9 @@ void testMain() { final CanvasParagraph paragraph = builder.build(); expect(paragraph.toPlainText(), 'Hello world'); + expect(paragraph.spans, hasLength(2)); + + paragraph.layout(ParagraphConstraints(width: double.infinity)); expect( paragraph.toDomElement().outerHtml, '

' @@ -172,7 +215,20 @@ void testMain() { '' '

', ); - expect(paragraph.spans, hasLength(2)); + + // Should break "Hello world" into "Hello" and " world". + paragraph.layout(ParagraphConstraints(width: 70.0)); + expect( + paragraph.toDomElement().outerHtml, + '

' + '' + 'Hello' + '' + '' + '
world' + '
' + '

', + ); final FlatTextSpan hello = paragraph.spans.first as FlatTextSpan; expect(hello.textOf(paragraph), 'Hello'); @@ -210,6 +266,9 @@ void testMain() { final CanvasParagraph paragraph = builder.build(); expect(paragraph.toPlainText(), 'Hello world!'); + expect(paragraph.spans, hasLength(3)); + + paragraph.layout(ParagraphConstraints(width: double.infinity)); expect( paragraph.toDomElement().outerHtml, '

' @@ -224,7 +283,6 @@ void testMain() { '' '

', ); - expect(paragraph.spans, hasLength(3)); final FlatTextSpan hello = paragraph.spans[0] as FlatTextSpan; expect(hello.textOf(paragraph), 'Hello'); @@ -259,6 +317,48 @@ void testMain() { ), ); }); + + test('Paragraph with new lines generates correct DOM', () { + final EngineParagraphStyle style = EngineParagraphStyle(fontSize: 13.0); + final CanvasParagraphBuilder builder = CanvasParagraphBuilder(style); + + builder.addText('First\nSecond '); + builder.pushStyle(TextStyle(fontStyle: FontStyle.italic)); + builder.addText('ThirdLongLine'); + + final CanvasParagraph paragraph = builder.build(); + expect(paragraph.toPlainText(), 'First\nSecond ThirdLongLine'); + expect(paragraph.spans, hasLength(2)); + + // There's a new line between "First" and "Second", but "Second" and + // "ThirdLongLine" remain together since constraints are infinite. + paragraph.layout(ParagraphConstraints(width: double.infinity)); + expect( + paragraph.toDomElement().outerHtml, + '

' + '' + 'First
Second ' + '
' + '' + 'ThirdLongLine' + '' + '

', + ); + + // Should break the paragraph into "First", "Second" and "ThirdLongLine". + paragraph.layout(ParagraphConstraints(width: 180.0)); + expect( + paragraph.toDomElement().outerHtml, + '

' + '' + 'First
Second
' + '
' + '' + 'ThirdLongLine' + '' + '

', + ); + }); } TextStyle styleWithDefaults({