| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:html' as html; |
| import 'dart:js_util' as js_util; |
| import 'dart:math' as math; |
| |
| import 'package:ui/ui.dart' as ui; |
| |
| import '../browser_detection.dart'; |
| import '../dom_renderer.dart'; |
| import '../html/bitmap_canvas.dart'; |
| import '../html/painting.dart'; |
| import '../profiler.dart'; |
| import '../util.dart'; |
| import 'canvas_paragraph.dart'; |
| import 'layout_service.dart'; |
| import 'measurement.dart'; |
| import 'ruler.dart'; |
| import 'word_breaker.dart'; |
| |
| const ui.Color defaultTextColor = ui.Color(0xFFFF0000); |
| |
| const String placeholderClass = 'paragraph-placeholder'; |
| |
| class EngineLineMetrics implements ui.LineMetrics { |
| EngineLineMetrics({ |
| required this.hardBreak, |
| required this.ascent, |
| required this.descent, |
| required this.unscaledAscent, |
| required this.height, |
| required this.width, |
| required this.left, |
| required this.baseline, |
| required this.lineNumber, |
| }) : displayText = null, |
| ellipsis = null, |
| startIndex = -1, |
| endIndex = -1, |
| endIndexWithoutNewlines = -1, |
| widthWithTrailingSpaces = width, |
| boxes = null; |
| |
| EngineLineMetrics.withText( |
| String this.displayText, { |
| required this.startIndex, |
| required this.endIndex, |
| required this.endIndexWithoutNewlines, |
| required this.hardBreak, |
| required this.width, |
| required this.widthWithTrailingSpaces, |
| required this.left, |
| required this.lineNumber, |
| }) : assert(displayText != null), // ignore: unnecessary_null_comparison, |
| assert(startIndex != null), // ignore: unnecessary_null_comparison |
| assert(endIndex != null), // ignore: unnecessary_null_comparison |
| assert(endIndexWithoutNewlines != null), // ignore: unnecessary_null_comparison |
| assert(hardBreak != null), // ignore: unnecessary_null_comparison |
| assert(width != null), // ignore: unnecessary_null_comparison |
| assert(left != null), // ignore: unnecessary_null_comparison |
| assert(lineNumber != null && lineNumber >= 0), // ignore: unnecessary_null_comparison |
| ellipsis = null, |
| ascent = double.infinity, |
| descent = double.infinity, |
| unscaledAscent = double.infinity, |
| height = double.infinity, |
| baseline = double.infinity, |
| boxes = null; |
| |
| EngineLineMetrics.rich( |
| this.lineNumber, { |
| required this.ellipsis, |
| required this.startIndex, |
| required this.endIndex, |
| required this.endIndexWithoutNewlines, |
| required this.hardBreak, |
| required this.width, |
| required this.widthWithTrailingSpaces, |
| required this.left, |
| required this.height, |
| required this.baseline, |
| required this.ascent, |
| required this.descent, |
| // Didn't use `this.boxes` because we want it to be non-null in this |
| // constructor. |
| required List<RangeBox> boxes, |
| }) : displayText = null, |
| unscaledAscent = double.infinity, |
| this.boxes = boxes; // ignore: unnecessary_this |
| |
| /// The text to be rendered on the screen representing this line. |
| final String? displayText; |
| |
| /// The string to be displayed as an overflow indicator. |
| /// |
| /// When the value is non-null, it means this line is overflowing and the |
| /// [ellipsis] needs to be displayed at the end of it. |
| final String? ellipsis; |
| |
| /// The index (inclusive) in the text where this line begins. |
| final int startIndex; |
| |
| /// The index (exclusive) in the text where this line ends. |
| /// |
| /// When the line contains an overflow, then [endIndex] goes until the end of |
| /// the text and doesn't stop at the overflow cutoff. |
| final int endIndex; |
| |
| /// The index (exclusive) in the text where this line ends, ignoring newline |
| /// characters. |
| final int endIndexWithoutNewlines; |
| |
| /// The list of boxes representing the entire line, possibly across multiple |
| /// spans. |
| final List<RangeBox>? boxes; |
| |
| @override |
| final bool hardBreak; |
| |
| @override |
| final double ascent; |
| |
| @override |
| final double descent; |
| |
| @override |
| final double unscaledAscent; |
| |
| @override |
| final double height; |
| |
| @override |
| final double width; |
| |
| /// The full width of the line including all trailing space but not new lines. |
| /// |
| /// The difference between [width] and [widthWithTrailingSpaces] is that |
| /// [widthWithTrailingSpaces] includes trailing spaces in the width |
| /// calculation while [width] doesn't. |
| /// |
| /// For alignment purposes for example, the [width] property is the right one |
| /// to use because trailing spaces shouldn't affect the centering of text. |
| /// But for placing cursors in text fields, we do care about trailing |
| /// spaces so [widthWithTrailingSpaces] is more suitable. |
| final double widthWithTrailingSpaces; |
| |
| @override |
| final double left; |
| |
| @override |
| final double baseline; |
| |
| @override |
| final int lineNumber; |
| |
| bool overlapsWith(int startIndex, int endIndex) { |
| return startIndex < this.endIndex && this.startIndex < endIndex; |
| } |
| |
| @override |
| int get hashCode => ui.hashValues( |
| displayText, |
| startIndex, |
| endIndex, |
| hardBreak, |
| ascent, |
| descent, |
| unscaledAscent, |
| height, |
| width, |
| left, |
| baseline, |
| lineNumber, |
| ); |
| |
| @override |
| bool operator ==(Object other) { |
| if (identical(this, other)) { |
| return true; |
| } |
| if (other.runtimeType != runtimeType) { |
| return false; |
| } |
| return other is EngineLineMetrics && |
| other.displayText == displayText && |
| other.startIndex == startIndex && |
| other.endIndex == endIndex && |
| other.hardBreak == hardBreak && |
| other.ascent == ascent && |
| other.descent == descent && |
| other.unscaledAscent == unscaledAscent && |
| other.height == height && |
| other.width == width && |
| other.left == left && |
| other.baseline == baseline && |
| other.lineNumber == lineNumber; |
| } |
| |
| @override |
| String toString() { |
| if (assertionsEnabled) { |
| return 'LineMetrics(hardBreak: $hardBreak, ' |
| 'ascent: $ascent, ' |
| 'descent: $descent, ' |
| 'unscaledAscent: $unscaledAscent, ' |
| 'height: $height, ' |
| 'width: $width, ' |
| 'left: $left, ' |
| 'baseline: $baseline, ' |
| 'lineNumber: $lineNumber)'; |
| } else { |
| return super.toString(); |
| } |
| } |
| } |
| |
| /// Common interface for all the implementations of [ui.Paragraph] in the web |
| /// engine. |
| abstract class EngineParagraph implements ui.Paragraph { |
| /// Whether this paragraph has been laid out or not. |
| bool get isLaidOut; |
| |
| /// Whether this paragraph can be drawn on a bitmap canvas. |
| bool get drawOnCanvas; |
| |
| /// Whether this paragraph is doing arbitrary paint operations that require |
| /// a bitmap canvas, and can't be expressed in a DOM canvas. |
| bool get hasArbitraryPaint; |
| |
| void paint(BitmapCanvas canvas, ui.Offset offset); |
| |
| /// Generates a flat string computed from all the spans of the paragraph. |
| String toPlainText(); |
| |
| /// Returns a DOM element that represents the entire paragraph and its |
| /// children. |
| /// |
| /// Generates a new DOM element on every invocation. |
| html.HtmlElement toDomElement(); |
| } |
| |
| /// Uses the DOM and hierarchical <span> elements to represent the span of the |
| /// paragraph. |
| /// |
| /// This implementation will go away once the new [CanvasParagraph] is |
| /// complete and turned on by default. |
| class DomParagraph implements EngineParagraph { |
| /// This class is created by the engine, and should not be instantiated |
| /// or extended directly. |
| /// |
| /// To create a [DomParagraph] object, use a [DomParagraphBuilder]. |
| DomParagraph({ |
| required html.HtmlElement paragraphElement, |
| required ParagraphGeometricStyle geometricStyle, |
| required String? plainText, |
| required ui.Paint? paint, |
| required ui.TextAlign textAlign, |
| required ui.TextDirection textDirection, |
| required ui.Paint? background, |
| required this.placeholderCount, |
| }) : assert((plainText == null && paint == null) || |
| (plainText != null && paint != null)), |
| _paragraphElement = paragraphElement, |
| _geometricStyle = geometricStyle, |
| _plainText = plainText, |
| _textAlign = textAlign, |
| _textDirection = textDirection, |
| _paint = paint as SurfacePaint?, |
| _background = background as SurfacePaint?; |
| |
| final html.HtmlElement _paragraphElement; |
| final ParagraphGeometricStyle _geometricStyle; |
| final String? _plainText; |
| final SurfacePaint? _paint; |
| final ui.TextAlign _textAlign; |
| final ui.TextDirection _textDirection; |
| final SurfacePaint? _background; |
| |
| final int placeholderCount; |
| |
| String? get plainText => _plainText; |
| |
| html.HtmlElement get paragraphElement => _paragraphElement; |
| |
| ui.TextAlign get textAlign => _textAlign; |
| ui.TextDirection get textDirection => _textDirection; |
| |
| ParagraphGeometricStyle get geometricStyle => _geometricStyle; |
| |
| /// The instance of [TextMeasurementService] to be used to measure this |
| /// paragraph. |
| TextMeasurementService get _measurementService => |
| TextMeasurementService.forParagraph(this); |
| |
| /// The measurement result of the last layout operation. |
| MeasurementResult? get measurementResult => _measurementResult; |
| MeasurementResult? _measurementResult; |
| |
| bool get _hasLineMetrics => _measurementResult?.lines != null; |
| |
| // Defaulting to -1 for non-laid-out paragraphs like the native engine does. |
| @override |
| double get width => _measurementResult?.width ?? -1; |
| |
| @override |
| double get height => _measurementResult?.height ?? 0; |
| |
| /// {@template dart.ui.paragraph.naturalHeight} |
| /// The amount of vertical space the paragraph occupies while ignoring the |
| /// [ParagraphGeometricStyle.maxLines] constraint. |
| /// {@endtemplate} |
| /// |
| /// Valid only after [layout] has been called. |
| double get _naturalHeight => _measurementResult?.naturalHeight ?? 0; |
| |
| /// The amount of vertical space one line of this paragraph occupies. |
| /// |
| /// Valid only after [layout] has been called. |
| double get _lineHeight => _measurementResult?.lineHeight ?? 0; |
| |
| @override |
| double get longestLine { |
| if (_hasLineMetrics) { |
| double maxWidth = 0.0; |
| for (final ui.LineMetrics metrics in _measurementResult!.lines!) { |
| if (maxWidth < metrics.width) { |
| maxWidth = metrics.width; |
| } |
| } |
| return maxWidth; |
| } |
| |
| // If we don't have any line metrics information, there's no way to know the |
| // longest line in a multi-line paragraph. |
| return 0.0; |
| } |
| |
| @override |
| double get minIntrinsicWidth => _measurementResult?.minIntrinsicWidth ?? 0; |
| |
| @override |
| double get maxIntrinsicWidth => _measurementResult?.maxIntrinsicWidth ?? 0; |
| |
| @override |
| double get alphabeticBaseline => _measurementResult?.alphabeticBaseline ?? -1; |
| |
| @override |
| double get ideographicBaseline => |
| _measurementResult?.ideographicBaseline ?? -1; |
| |
| @override |
| bool get didExceedMaxLines => _didExceedMaxLines; |
| bool _didExceedMaxLines = false; |
| |
| ui.ParagraphConstraints? _lastUsedConstraints; |
| |
| /// Returns horizontal alignment offset for single line text when rendering |
| /// directly into a canvas without css text alignment styling. |
| double _alignOffset = 0.0; |
| |
| @override |
| void layout(ui.ParagraphConstraints constraints) { |
| // When constraint width has a decimal place, we floor it to avoid getting |
| // a layout width that's higher than the constraint width. |
| // |
| // For example, if constraint width is `30.8` and the text has a width of |
| // `30.5` then the TextPainter in the framework will ceil the `30.5` width |
| // which will result in a width of `40.0` that's higher than the constraint |
| // width. |
| constraints = ui.ParagraphConstraints( |
| width: constraints.width.floorToDouble(), |
| ); |
| |
| if (constraints == _lastUsedConstraints) { |
| return; |
| } |
| |
| late Stopwatch stopwatch; |
| if (Profiler.isBenchmarkMode) { |
| stopwatch = Stopwatch()..start(); |
| } |
| _measurementResult = _measurementService.measure(this, constraints); |
| if (Profiler.isBenchmarkMode) { |
| stopwatch.stop(); |
| Profiler.instance |
| .benchmark('text_layout', stopwatch.elapsedMicroseconds.toDouble()); |
| } |
| |
| _lastUsedConstraints = constraints; |
| |
| if (_geometricStyle.maxLines != null) { |
| _didExceedMaxLines = _naturalHeight > height; |
| } else { |
| _didExceedMaxLines = false; |
| } |
| |
| if (_measurementResult!.isSingleLine) { |
| switch (_textAlign) { |
| case ui.TextAlign.center: |
| _alignOffset = (constraints.width - maxIntrinsicWidth) / 2.0; |
| break; |
| case ui.TextAlign.right: |
| _alignOffset = constraints.width - maxIntrinsicWidth; |
| break; |
| case ui.TextAlign.start: |
| _alignOffset = _textDirection == ui.TextDirection.rtl |
| ? constraints.width - maxIntrinsicWidth |
| : 0.0; |
| break; |
| case ui.TextAlign.end: |
| _alignOffset = _textDirection == ui.TextDirection.ltr |
| ? constraints.width - maxIntrinsicWidth |
| : 0.0; |
| break; |
| default: |
| _alignOffset = 0.0; |
| break; |
| } |
| } |
| } |
| |
| @override |
| bool get hasArbitraryPaint => _geometricStyle.ellipsis != null; |
| |
| @override |
| void paint(BitmapCanvas canvas, ui.Offset offset) { |
| assert(drawOnCanvas); |
| assert(isLaidOut); |
| |
| // Paint the background first. |
| final SurfacePaint? background = _background; |
| if (background != null) { |
| final ui.Rect rect = |
| ui.Rect.fromLTWH(offset.dx, offset.dy, width, height); |
| canvas.drawRect(rect, background.paintData); |
| } |
| |
| final List<EngineLineMetrics> lines = _measurementResult!.lines!; |
| canvas.setCssFont(_geometricStyle.cssFontString); |
| |
| // Then paint the text. |
| canvas.setUpPaint(_paint!.paintData, null); |
| double y = offset.dy + alphabeticBaseline; |
| final int len = lines.length; |
| for (int i = 0; i < len; i++) { |
| _paintLine(canvas, lines[i], offset.dx, y); |
| y += _lineHeight; |
| } |
| canvas.tearDownPaint(); |
| } |
| |
| void _paintLine( |
| BitmapCanvas canvas, |
| EngineLineMetrics line, |
| double x, |
| double y, |
| ) { |
| x += line.left; |
| final double? letterSpacing = _geometricStyle.letterSpacing; |
| if (letterSpacing == null || letterSpacing == 0.0) { |
| canvas.fillText(line.displayText!, x, y); |
| } else { |
| // When letter-spacing is set, we go through a more expensive code path |
| // that renders each character separately with the correct spacing |
| // between them. |
| // |
| // We are drawing letter spacing like the web does it, by adding the |
| // spacing after each letter. This is different from Flutter which puts |
| // the spacing around each letter i.e. for a 10px letter spacing, Flutter |
| // would put 5px before each letter and 5px after it, but on the web, we |
| // put no spacing before the letter and 10px after it. This is how the DOM |
| // does it. |
| // |
| // TODO(mdebbar): Implement letter-spacing on canvas more efficiently: |
| // https://github.com/flutter/flutter/issues/51234 |
| final int len = line.displayText!.length; |
| for (int i = 0; i < len; i++) { |
| final String char = line.displayText![i]; |
| canvas.fillText(char, x, y); |
| x += letterSpacing + canvas.measureText(char).width!; |
| } |
| } |
| } |
| |
| @override |
| String toPlainText() { |
| return _plainText ?? |
| js_util.getProperty(_paragraphElement, 'textContent') as String; |
| } |
| |
| @override |
| html.HtmlElement toDomElement() { |
| assert(isLaidOut); |
| |
| final html.HtmlElement paragraphElement = |
| _paragraphElement.clone(true) as html.HtmlElement; |
| |
| final html.CssStyleDeclaration paragraphStyle = paragraphElement.style; |
| paragraphStyle |
| ..height = '${height}px' |
| ..width = '${width}px' |
| ..position = 'absolute' |
| ..whiteSpace = 'pre-wrap' |
| ..overflowWrap = 'break-word' |
| ..overflow = 'hidden'; |
| |
| final ParagraphGeometricStyle style = _geometricStyle; |
| |
| // TODO(flutter_web): https://github.com/flutter/flutter/issues/33223 |
| if (style.ellipsis != null && |
| (style.maxLines == null || style.maxLines == 1)) { |
| paragraphStyle |
| ..whiteSpace = 'pre' |
| ..textOverflow = 'ellipsis'; |
| } |
| return paragraphElement; |
| } |
| |
| @override |
| List<ui.TextBox> getBoxesForPlaceholders() { |
| assert(isLaidOut); |
| return _measurementResult!.placeholderBoxes; |
| } |
| |
| /// Returns `true` if this paragraph can be directly painted to the canvas. |
| /// |
| /// |
| /// Examples of paragraphs that can't be drawn directly on the canvas: |
| /// |
| /// - Rich text where there are multiple pieces of text that have different |
| /// styles. |
| /// - Paragraphs that contain decorations. |
| /// - Paragraphs that have a non-null word-spacing. |
| /// - Paragraphs with a background. |
| @override |
| bool get drawOnCanvas { |
| if (!_hasLineMetrics) { |
| return false; |
| } |
| |
| bool canDrawTextOnCanvas; |
| if (_measurementService.isCanvas) { |
| canDrawTextOnCanvas = true; |
| } else { |
| canDrawTextOnCanvas = _geometricStyle.ellipsis == null; |
| } |
| |
| return canDrawTextOnCanvas && |
| _geometricStyle.decoration == null && |
| _geometricStyle.wordSpacing == null && |
| _geometricStyle.shadows == null; |
| } |
| |
| /// Whether this paragraph has been laid out. |
| @override |
| bool get isLaidOut => _measurementResult != null; |
| |
| /// Asserts that the properties used to measure paragraph layout are the same |
| /// as the properties of this paragraphs root style. |
| /// |
| /// Ignores properties that do not affect layout, such as |
| /// [ParagraphStyle.textAlign]. |
| bool debugHasSameRootStyle(ParagraphGeometricStyle style) { |
| assert(() { |
| if (style != _geometricStyle) { |
| throw Exception('Attempted to measure a paragraph whose style is ' |
| 'different from the style of the ruler used to measure it.'); |
| } |
| return true; |
| }()); |
| return true; |
| } |
| |
| @override |
| List<ui.TextBox> getBoxesForRange( |
| int start, |
| int end, { |
| ui.BoxHeightStyle boxHeightStyle = ui.BoxHeightStyle.tight, |
| ui.BoxWidthStyle boxWidthStyle = ui.BoxWidthStyle.tight, |
| }) { |
| assert(boxHeightStyle != null); // ignore: unnecessary_null_comparison |
| assert(boxWidthStyle != null); // ignore: unnecessary_null_comparison |
| // Zero-length ranges and invalid ranges return an empty list. |
| if (start == end || start < 0 || end < 0) { |
| return <ui.TextBox>[]; |
| } |
| |
| // For rich text, we can't measure the boxes. So for now, we'll just return |
| // a placeholder box to stop exceptions from being thrown in the framework. |
| // https://github.com/flutter/flutter/issues/55587 |
| if (_plainText == null) { |
| return <ui.TextBox>[ |
| ui.TextBox.fromLTRBD(0, 0, 0, _lineHeight, _textDirection), |
| ]; |
| } |
| |
| final int length = _plainText!.length; |
| // Ranges that are out of bounds should return an empty list. |
| if (start > length || end > length) { |
| return <ui.TextBox>[]; |
| } |
| |
| // Fallback to the old, DOM-based box measurements when there's no line |
| // metrics. |
| if (!_hasLineMetrics) { |
| return _measurementService.measureBoxesForRange( |
| this, |
| _lastUsedConstraints!, |
| start: start, |
| end: end, |
| alignOffset: _alignOffset, |
| textDirection: _textDirection, |
| ); |
| } |
| |
| final List<EngineLineMetrics> lines = _measurementResult!.lines!; |
| if (start >= lines.last.endIndex) { |
| return <ui.TextBox>[]; |
| } |
| |
| final EngineLineMetrics startLine = _getLineForIndex(start); |
| EngineLineMetrics endLine = _getLineForIndex(end); |
| |
| // If the range end is exactly at the beginning of a line, we shouldn't |
| // include any boxes from that line. |
| if (end == endLine.startIndex) { |
| endLine = lines[endLine.lineNumber - 1]; |
| } |
| |
| final List<ui.TextBox> boxes = <ui.TextBox>[]; |
| for (int i = startLine.lineNumber; i <= endLine.lineNumber; i++) { |
| boxes.add(_getBoxForLine(lines[i], start, end)); |
| } |
| return boxes; |
| } |
| |
| ui.TextBox _getBoxForLine(EngineLineMetrics line, int start, int end) { |
| final double widthBeforeBox = start <= line.startIndex |
| ? 0.0 |
| : _measurementService.measureSubstringWidth( |
| this, line.startIndex, start); |
| final double widthAfterBox = end >= line.endIndexWithoutNewlines |
| ? 0.0 |
| : _measurementService.measureSubstringWidth( |
| this, end, line.endIndexWithoutNewlines); |
| |
| final double top = line.lineNumber * _lineHeight; |
| |
| // |<------------------ line.width ------------------>| |
| // |-------------|------------------|-------------|-----------------| |
| // |<-line.left->|<-widthBeforeBox->|<-box width->|<-widthAfterBox->| |
| // |-------------|------------------|-------------|-----------------| |
| // |
| // ^^^^^^^^^^^^^ |
| // This is the box we want to return. |
| return ui.TextBox.fromLTRBD( |
| line.left + widthBeforeBox, |
| top, |
| line.left + line.widthWithTrailingSpaces - widthAfterBox, |
| top + _lineHeight, |
| _textDirection, |
| ); |
| } |
| |
| ui.Paragraph cloneWithText(String plainText) { |
| return DomParagraph( |
| plainText: plainText, |
| paragraphElement: _paragraphElement.clone(true) as html.HtmlElement, |
| geometricStyle: _geometricStyle, |
| paint: _paint, |
| textAlign: _textAlign, |
| textDirection: _textDirection, |
| background: _background, |
| placeholderCount: placeholderCount, |
| ); |
| } |
| |
| @override |
| ui.TextPosition getPositionForOffset(ui.Offset offset) { |
| final List<EngineLineMetrics>? lines = _measurementResult!.lines; |
| if (!_hasLineMetrics) { |
| return getPositionForMultiSpanOffset(offset); |
| } |
| |
| // [offset] is above all the lines. |
| if (offset.dy < 0) { |
| return const ui.TextPosition( |
| offset: 0, |
| affinity: ui.TextAffinity.downstream, |
| ); |
| } |
| |
| final int lineNumber = offset.dy ~/ _measurementResult!.lineHeight!; |
| |
| // [offset] is below all the lines. |
| if (lineNumber >= lines!.length) { |
| return ui.TextPosition( |
| offset: _plainText!.length, |
| affinity: ui.TextAffinity.upstream, |
| ); |
| } |
| |
| final EngineLineMetrics lineMetrics = lines[lineNumber]; |
| final double lineLeft = lineMetrics.left; |
| final double lineRight = lineLeft + lineMetrics.width; |
| |
| // [offset] is to the left of the line. |
| if (offset.dx <= lineLeft) { |
| return ui.TextPosition( |
| offset: lineMetrics.startIndex, |
| affinity: ui.TextAffinity.downstream, |
| ); |
| } |
| |
| // [offset] is to the right of the line. |
| if (offset.dx >= lineRight) { |
| return ui.TextPosition( |
| offset: lineMetrics.endIndexWithoutNewlines, |
| affinity: ui.TextAffinity.upstream, |
| ); |
| } |
| |
| // If we reach here, it means the [offset] is somewhere within the line. The |
| // code below will do a binary search to find where exactly the [offset] |
| // falls within the line. |
| |
| final double dx = offset.dx - lineMetrics.left; |
| final TextMeasurementService instance = _measurementService; |
| |
| int low = lineMetrics.startIndex; |
| int high = lineMetrics.endIndexWithoutNewlines; |
| do { |
| final int current = (low + high) ~/ 2; |
| final double width = |
| instance.measureSubstringWidth(this, lineMetrics.startIndex, current); |
| if (width < dx) { |
| low = current; |
| } else if (width > dx) { |
| high = current; |
| } else { |
| low = high = current; |
| } |
| } while (high - low > 1); |
| |
| if (low == high) { |
| // The offset falls exactly in between the two letters. |
| return ui.TextPosition(offset: high, affinity: ui.TextAffinity.upstream); |
| } |
| |
| final double lowWidth = |
| instance.measureSubstringWidth(this, lineMetrics.startIndex, low); |
| final double highWidth = |
| instance.measureSubstringWidth(this, lineMetrics.startIndex, high); |
| |
| if (dx - lowWidth < highWidth - dx) { |
| // The offset is closer to the low index. |
| return ui.TextPosition(offset: low, affinity: ui.TextAffinity.downstream); |
| } else { |
| // The offset is closer to high index. |
| return ui.TextPosition(offset: high, affinity: ui.TextAffinity.upstream); |
| } |
| } |
| |
| ui.TextPosition getPositionForMultiSpanOffset(ui.Offset offset) { |
| assert(_lastUsedConstraints != null, |
| 'missing call to paragraph layout before reading text position'); |
| final TextMeasurementService instance = _measurementService; |
| return instance.getTextPositionForOffset( |
| this, _lastUsedConstraints, offset); |
| } |
| |
| @override |
| ui.TextRange getWordBoundary(ui.TextPosition position) { |
| final ui.TextPosition textPosition = position; |
| final String? text = _plainText; |
| if (text == null) { |
| return ui.TextRange(start: textPosition.offset, end: textPosition.offset); |
| } |
| |
| final int start = WordBreaker.prevBreakIndex(text, textPosition.offset + 1); |
| final int end = WordBreaker.nextBreakIndex(text, textPosition.offset); |
| return ui.TextRange(start: start, end: end); |
| } |
| |
| EngineLineMetrics _getLineForIndex(int index) { |
| assert(_hasLineMetrics); |
| final List<EngineLineMetrics> lines = _measurementResult!.lines!; |
| assert(index >= 0); |
| |
| for (int i = 0; i < lines.length; i++) { |
| final EngineLineMetrics line = lines[i]; |
| if (index >= line.startIndex && index < line.endIndex) { |
| return line; |
| } |
| } |
| |
| return lines.last; |
| } |
| |
| @override |
| ui.TextRange getLineBoundary(ui.TextPosition position) { |
| if (_hasLineMetrics) { |
| final EngineLineMetrics line = _getLineForIndex(position.offset); |
| return ui.TextRange(start: line.startIndex, end: line.endIndex); |
| } |
| return ui.TextRange.empty; |
| } |
| |
| @override |
| List<ui.LineMetrics> computeLineMetrics() { |
| return _measurementResult!.lines!; |
| } |
| } |
| |
| /// The web implementation of [ui.ParagraphStyle]. |
| class EngineParagraphStyle implements ui.ParagraphStyle { |
| /// Creates a new instance of [EngineParagraphStyle]. |
| EngineParagraphStyle({ |
| this.textAlign, |
| this.textDirection, |
| this.maxLines, |
| this.fontFamily, |
| this.fontSize, |
| this.height, |
| ui.TextHeightBehavior? textHeightBehavior, |
| this.fontWeight, |
| this.fontStyle, |
| ui.StrutStyle? strutStyle, |
| this.ellipsis, |
| this.locale, |
| }) : _textHeightBehavior = textHeightBehavior, |
| // TODO(b/128317744): add support for strut style. |
| _strutStyle = strutStyle as EngineStrutStyle?; |
| |
| final ui.TextAlign? textAlign; |
| final ui.TextDirection? textDirection; |
| final ui.FontWeight? fontWeight; |
| final ui.FontStyle? fontStyle; |
| final int? maxLines; |
| final String? fontFamily; |
| final double? fontSize; |
| final double? height; |
| final ui.TextHeightBehavior? _textHeightBehavior; |
| final EngineStrutStyle? _strutStyle; |
| final String? ellipsis; |
| final ui.Locale? locale; |
| |
| // The effective style attributes should be consistent with paragraph_style.h. |
| ui.TextAlign get effectiveTextAlign => textAlign ?? ui.TextAlign.start; |
| ui.TextDirection get effectiveTextDirection => textDirection ?? ui.TextDirection.ltr; |
| |
| String get _effectiveFontFamily { |
| if (assertionsEnabled) { |
| // In the flutter tester environment, we use a predictable-size font |
| // "Ahem". This makes widget tests predictable and less flaky. |
| if (ui.debugEmulateFlutterTesterEnvironment) { |
| return 'Ahem'; |
| } |
| } |
| final String? fontFamily = this.fontFamily; |
| if (fontFamily == null || fontFamily.isEmpty) { |
| return DomRenderer.defaultFontFamily; |
| } |
| return fontFamily; |
| } |
| |
| double? get lineHeight { |
| // TODO(mdebbar): Implement proper support for strut styles. |
| // https://github.com/flutter/flutter/issues/32243 |
| if (_strutStyle == null || |
| _strutStyle!._height == null || |
| _strutStyle!._height == 0) { |
| // When there's no strut height, always use paragraph style height. |
| return height; |
| } |
| if (_strutStyle!._forceStrutHeight == true) { |
| // When strut height is forced, ignore paragraph style height. |
| return _strutStyle!._height; |
| } |
| // In this case, strut height acts as a minimum height for all parts of the |
| // paragraph. So we take the max of strut height and paragraph style height. |
| return math.max(_strutStyle!._height!, height ?? 0.0); |
| } |
| |
| @override |
| bool operator ==(Object other) { |
| if (identical(this, other)) { |
| return true; |
| } |
| if (other.runtimeType != runtimeType) { |
| return false; |
| } |
| return other is EngineParagraphStyle && |
| other.textAlign == textAlign && |
| other.textDirection == textDirection && |
| other.fontWeight == fontWeight && |
| other.fontStyle == fontStyle && |
| other.maxLines == maxLines && |
| other.fontFamily == fontFamily && |
| other.fontSize == fontSize && |
| other.height == height && |
| other._textHeightBehavior == _textHeightBehavior && |
| other.ellipsis == ellipsis && |
| other.locale == locale; |
| } |
| |
| @override |
| int get hashCode { |
| return ui.hashValues( |
| textAlign, |
| textDirection, |
| fontWeight, |
| fontStyle, |
| maxLines, |
| fontFamily, |
| fontSize, |
| height, |
| _textHeightBehavior, |
| ellipsis, |
| locale); |
| } |
| |
| @override |
| String toString() { |
| if (assertionsEnabled) { |
| return 'ParagraphStyle(' |
| 'textAlign: ${textAlign ?? "unspecified"}, ' |
| 'textDirection: ${textDirection ?? "unspecified"}, ' |
| 'fontWeight: ${fontWeight ?? "unspecified"}, ' |
| 'fontStyle: ${fontStyle ?? "unspecified"}, ' |
| 'maxLines: ${maxLines ?? "unspecified"}, ' |
| 'textHeightBehavior: ${_textHeightBehavior ?? "unspecified"}, ' |
| 'fontFamily: ${fontFamily ?? "unspecified"}, ' |
| 'fontSize: ${fontSize != null ? fontSize!.toStringAsFixed(1) : "unspecified"}, ' |
| 'height: ${height != null ? "${height!.toStringAsFixed(1)}x" : "unspecified"}, ' |
| 'ellipsis: ${ellipsis != null ? "\"$ellipsis\"" : "unspecified"}, ' |
| 'locale: ${locale ?? "unspecified"}' |
| ')'; |
| } else { |
| return super.toString(); |
| } |
| } |
| } |
| |
| /// The web implementation of [ui.TextStyle]. |
| class EngineTextStyle implements ui.TextStyle { |
| /// Constructs an [EngineTextStyle] with all properties being required. |
| /// |
| /// This is good for call sites that need to be updated whenever a new |
| /// property is added to [EngineTextStyle]. Non-updated call sites will fail |
| /// the build otherwise. |
| factory EngineTextStyle({ |
| required ui.Color? color, |
| required ui.TextDecoration? decoration, |
| required ui.Color? decorationColor, |
| required ui.TextDecorationStyle? decorationStyle, |
| required double? decorationThickness, |
| required ui.FontWeight? fontWeight, |
| required ui.FontStyle? fontStyle, |
| required ui.TextBaseline? textBaseline, |
| required String? fontFamily, |
| required List<String>? fontFamilyFallback, |
| required double? fontSize, |
| required double? letterSpacing, |
| required double? wordSpacing, |
| required double? height, |
| required ui.Locale? locale, |
| required ui.Paint? background, |
| required ui.Paint? foreground, |
| required List<ui.Shadow>? shadows, |
| required List<ui.FontFeature>? fontFeatures, |
| }) = EngineTextStyle.only; |
| |
| /// Constructs an [EngineTextStyle] with only the given properties. |
| /// |
| /// This constructor should be used sparingly in tests, for example. Or when |
| /// we know for sure that not all properties are needed. |
| EngineTextStyle.only({ |
| this.color, |
| this.decoration, |
| this.decorationColor, |
| this.decorationStyle, |
| this.decorationThickness, |
| this.fontWeight, |
| this.fontStyle, |
| this.textBaseline, |
| String? fontFamily, |
| this.fontFamilyFallback, |
| this.fontSize, |
| this.letterSpacing, |
| this.wordSpacing, |
| this.height, |
| this.locale, |
| this.background, |
| this.foreground, |
| this.shadows, |
| this.fontFeatures, |
| }) : assert( |
| color == null || foreground == null, |
| 'Cannot provide both a color and a foreground\n' |
| 'The color argument is just a shorthand for "foreground: new Paint()..color = color".'), |
| isFontFamilyProvided = fontFamily != null, |
| fontFamily = fontFamily ?? ''; |
| |
| /// Constructs an [EngineTextStyle] by reading properties from an |
| /// [EngineParagraphStyle]. |
| factory EngineTextStyle.fromParagraphStyle( |
| EngineParagraphStyle paragraphStyle, |
| ) { |
| return EngineTextStyle.only( |
| fontWeight: paragraphStyle.fontWeight, |
| fontStyle: paragraphStyle.fontStyle, |
| fontFamily: paragraphStyle.fontFamily, |
| fontSize: paragraphStyle.fontSize, |
| height: paragraphStyle.height, |
| locale: paragraphStyle.locale, |
| ); |
| } |
| |
| final ui.Color? color; |
| final ui.TextDecoration? decoration; |
| final ui.Color? decorationColor; |
| final ui.TextDecorationStyle? decorationStyle; |
| final double? decorationThickness; |
| final ui.FontWeight? fontWeight; |
| final ui.FontStyle? fontStyle; |
| final ui.TextBaseline? textBaseline; |
| final bool isFontFamilyProvided; |
| final String fontFamily; |
| final List<String>? fontFamilyFallback; |
| final List<ui.FontFeature>? fontFeatures; |
| final double? fontSize; |
| final double? letterSpacing; |
| final double? wordSpacing; |
| final double? height; |
| final ui.Locale? locale; |
| final ui.Paint? background; |
| final ui.Paint? foreground; |
| final List<ui.Shadow>? shadows; |
| |
| String get effectiveFontFamily { |
| if (assertionsEnabled) { |
| // In the flutter tester environment, we use a predictable-size font |
| // "Ahem". This makes widget tests predictable and less flaky. |
| if (ui.debugEmulateFlutterTesterEnvironment) { |
| return 'Ahem'; |
| } |
| } |
| if (fontFamily.isEmpty) { |
| return DomRenderer.defaultFontFamily; |
| } |
| return fontFamily; |
| } |
| |
| String? _cssFontString; |
| |
| /// Font string to be used in CSS. |
| /// |
| /// See <https://developer.mozilla.org/en-US/docs/Web/CSS/font>. |
| String get cssFontString { |
| return _cssFontString ??= buildCssFontString( |
| fontStyle: fontStyle, |
| fontWeight: fontWeight, |
| fontSize: fontSize, |
| fontFamily: effectiveFontFamily, |
| ); |
| } |
| |
| late final TextHeightStyle heightStyle = _createHeightStyle(); |
| |
| TextHeightStyle _createHeightStyle() { |
| return TextHeightStyle( |
| fontFamily: effectiveFontFamily, |
| fontSize: fontSize ?? DomRenderer.defaultFontSize, |
| height: height, |
| // TODO(mdebbar): Pass the actual value when font features become supported |
| // https://github.com/flutter/flutter/issues/64595 |
| fontFeatures: null, |
| ); |
| } |
| |
| @override |
| bool operator ==(Object other) { |
| if (identical(this, other)) { |
| return true; |
| } |
| if (other.runtimeType != runtimeType) { |
| return false; |
| } |
| return other is EngineTextStyle && |
| other.color == color && |
| other.decoration == decoration && |
| other.decorationColor == decorationColor && |
| other.decorationStyle == decorationStyle && |
| other.fontWeight == fontWeight && |
| other.fontStyle == fontStyle && |
| other.textBaseline == textBaseline && |
| other.fontFamily == fontFamily && |
| other.fontSize == fontSize && |
| other.letterSpacing == letterSpacing && |
| other.wordSpacing == wordSpacing && |
| other.height == height && |
| other.locale == locale && |
| other.background == background && |
| other.foreground == foreground && |
| listEquals<ui.Shadow>(other.shadows, shadows) && |
| listEquals<String>(other.fontFamilyFallback, fontFamilyFallback); |
| } |
| |
| @override |
| int get hashCode => ui.hashValues( |
| color, |
| decoration, |
| decorationColor, |
| decorationStyle, |
| decorationThickness, |
| fontWeight, |
| fontStyle, |
| textBaseline, |
| fontFamily, |
| fontFamilyFallback, |
| fontSize, |
| letterSpacing, |
| wordSpacing, |
| height, |
| locale, |
| background, |
| foreground, |
| shadows, |
| ); |
| |
| @override |
| String toString() { |
| if (assertionsEnabled) { |
| return 'TextStyle(' |
| 'color: ${color ?? "unspecified"}, ' |
| 'decoration: ${decoration ?? "unspecified"}, ' |
| 'decorationColor: ${decorationColor ?? "unspecified"}, ' |
| 'decorationStyle: ${decorationStyle ?? "unspecified"}, ' |
| 'decorationThickness: ${decorationThickness ?? "unspecified"}, ' |
| 'fontWeight: ${fontWeight ?? "unspecified"}, ' |
| 'fontStyle: ${fontStyle ?? "unspecified"}, ' |
| 'textBaseline: ${textBaseline ?? "unspecified"}, ' |
| 'fontFamily: ${isFontFamilyProvided && fontFamily != '' ? fontFamily : "unspecified"}, ' |
| 'fontFamilyFallback: ${isFontFamilyProvided && fontFamilyFallback != null && fontFamilyFallback!.isNotEmpty ? fontFamilyFallback : "unspecified"}, ' |
| 'fontSize: ${fontSize != null ? fontSize!.toStringAsFixed(1) : "unspecified"}, ' |
| 'letterSpacing: ${letterSpacing != null ? "${letterSpacing}x" : "unspecified"}, ' |
| 'wordSpacing: ${wordSpacing != null ? "${wordSpacing}x" : "unspecified"}, ' |
| 'height: ${height != null ? "${height!.toStringAsFixed(1)}x" : "unspecified"}, ' |
| 'locale: ${locale ?? "unspecified"}, ' |
| 'background: ${background ?? "unspecified"}, ' |
| 'foreground: ${foreground ?? "unspecified"}, ' |
| 'shadows: ${shadows ?? "unspecified"}, ' |
| 'fontFeatures: ${fontFeatures ?? "unspecified"}' |
| ')'; |
| } else { |
| return super.toString(); |
| } |
| } |
| } |
| |
| /// The web implementation of [ui.StrutStyle]. |
| class EngineStrutStyle implements ui.StrutStyle { |
| EngineStrutStyle({ |
| String? fontFamily, |
| List<String>? fontFamilyFallback, |
| double? fontSize, |
| double? height, |
| ui.TextLeadingDistribution? leadingDistribution, |
| double? leading, |
| ui.FontWeight? fontWeight, |
| ui.FontStyle? fontStyle, |
| bool? forceStrutHeight, |
| }) : _fontFamily = fontFamily, |
| _fontFamilyFallback = fontFamilyFallback, |
| _fontSize = fontSize, |
| _height = height, |
| _leadingDistribution = leadingDistribution, |
| _leading = leading, |
| _fontWeight = fontWeight, |
| _fontStyle = fontStyle, |
| _forceStrutHeight = forceStrutHeight; |
| |
| final String? _fontFamily; |
| final List<String>? _fontFamilyFallback; |
| final double? _fontSize; |
| final double? _height; |
| final double? _leading; |
| final ui.FontWeight? _fontWeight; |
| final ui.FontStyle? _fontStyle; |
| final bool? _forceStrutHeight; |
| final ui.TextLeadingDistribution? _leadingDistribution; |
| |
| @override |
| bool operator ==(Object other) { |
| if (identical(this, other)) { |
| return true; |
| } |
| if (other.runtimeType != runtimeType) { |
| return false; |
| } |
| return other is EngineStrutStyle && |
| other._fontFamily == _fontFamily && |
| other._fontSize == _fontSize && |
| other._height == _height && |
| other._leading == _leading && |
| other._leadingDistribution == _leadingDistribution && |
| other._fontWeight == _fontWeight && |
| other._fontStyle == _fontStyle && |
| other._forceStrutHeight == _forceStrutHeight && |
| listEquals<String>(other._fontFamilyFallback, _fontFamilyFallback); |
| } |
| |
| @override |
| int get hashCode => ui.hashValues( |
| _fontFamily, |
| _fontFamilyFallback, |
| _fontSize, |
| _height, |
| _leading, |
| _leadingDistribution, |
| _fontWeight, |
| _fontStyle, |
| _forceStrutHeight, |
| ); |
| } |
| |
| /// The web implementation of [ui.ParagraphBuilder]. |
| class DomParagraphBuilder implements ui.ParagraphBuilder { |
| /// Marks a call to the [pop] method in the [_ops] list. |
| static final Object _paragraphBuilderPop = Object(); |
| |
| final html.HtmlElement _paragraphElement = |
| domRenderer.createElement('p') as html.HtmlElement; |
| final EngineParagraphStyle _paragraphStyle; |
| final List<dynamic> _ops = <dynamic>[]; |
| |
| /// Creates a [DomParagraphBuilder] object, which is used to create a |
| /// [DomParagraph]. |
| DomParagraphBuilder(EngineParagraphStyle style) : _paragraphStyle = style { |
| // TODO(b/128317744): Implement support for strut font families. |
| List<String?> strutFontFamilies; |
| if (style._strutStyle != null) { |
| strutFontFamilies = <String?>[]; |
| if (style._strutStyle!._fontFamily != null) { |
| strutFontFamilies.add(style._strutStyle!._fontFamily); |
| } |
| if (style._strutStyle!._fontFamilyFallback != null) { |
| strutFontFamilies.addAll(style._strutStyle!._fontFamilyFallback!); |
| } |
| } |
| _applyParagraphStyleToElement( |
| element: _paragraphElement, style: _paragraphStyle); |
| } |
| |
| /// Applies the given style to the added text until [pop] is called. |
| /// |
| /// See [pop] for details. |
| @override |
| void pushStyle(ui.TextStyle style) { |
| _ops.add(style); |
| } |
| |
| @override |
| int get placeholderCount => _placeholderCount; |
| int _placeholderCount = 0; |
| |
| @override |
| List<double> get placeholderScales => _placeholderScales; |
| final List<double> _placeholderScales = <double>[]; |
| |
| @override |
| void addPlaceholder( |
| double width, |
| double height, |
| ui.PlaceholderAlignment alignment, { |
| double scale = 1.0, |
| double? baselineOffset, |
| ui.TextBaseline? baseline, |
| }) { |
| // Require a baseline to be specified if using a baseline-based alignment. |
| assert(!(alignment == ui.PlaceholderAlignment.aboveBaseline || |
| alignment == ui.PlaceholderAlignment.belowBaseline || |
| alignment == ui.PlaceholderAlignment.baseline) || baseline != null); |
| |
| _placeholderCount++; |
| _placeholderScales.add(scale); |
| _ops.add(ParagraphPlaceholder( |
| width * scale, |
| height * scale, |
| alignment, |
| baselineOffset: (baselineOffset ?? height) * scale, |
| baseline: baseline ?? ui.TextBaseline.alphabetic, |
| )); |
| } |
| |
| // TODO(yjbanov): do we need to do this? |
| // static String _encodeLocale(Locale locale) => locale?.toString() ?? ''; |
| |
| /// Ends the effect of the most recent call to [pushStyle]. |
| /// |
| /// Internally, the paragraph builder maintains a stack of text styles. Text |
| /// added to the paragraph is affected by all the styles in the stack. Calling |
| /// [pop] removes the topmost style in the stack, leaving the remaining styles |
| /// in effect. |
| @override |
| void pop() { |
| _ops.add(_paragraphBuilderPop); |
| } |
| |
| /// Adds the given text to the paragraph. |
| /// |
| /// The text will be styled according to the current stack of text styles. |
| @override |
| void addText(String text) { |
| _ops.add(text); |
| } |
| |
| /// Applies the given paragraph style and returns a [Paragraph] containing the |
| /// added text and associated styling. |
| /// |
| /// After calling this function, the paragraph builder object is invalid and |
| /// cannot be used further. |
| @override |
| EngineParagraph build() { |
| return _tryBuildPlainText() ?? _buildRichText(); |
| } |
| |
| /// Attempts to build a [Paragraph] assuming it is plain text. |
| /// |
| /// A paragraph is considered plain if it is built using the following |
| /// sequence of ops: |
| /// |
| /// * Zero-or-more calls to [pushStyle]. |
| /// * One-or-more calls to [addText]. |
| /// * Zero-or-more calls to [pop]. |
| /// |
| /// Any other sequence will result in `null` and should be treated as rich |
| /// text. |
| /// |
| /// Plain text is not the same as not having style. The text may be styled |
| /// arbitrarily. However, it may not mix multiple styles in the same |
| /// paragraph. Plain text is more efficient to lay out and measure than rich |
| /// text. |
| EngineParagraph? _tryBuildPlainText() { |
| ui.Color? color; |
| ui.TextDecoration? decoration; |
| ui.Color? decorationColor; |
| ui.TextDecorationStyle? decorationStyle; |
| double? decorationThickness; |
| ui.FontWeight? fontWeight = _paragraphStyle.fontWeight; |
| ui.FontStyle? fontStyle = _paragraphStyle.fontStyle; |
| ui.TextBaseline? textBaseline; |
| String fontFamily = |
| _paragraphStyle.fontFamily ?? DomRenderer.defaultFontFamily; |
| List<String>? fontFamilyFallback; |
| List<ui.FontFeature>? fontFeatures; |
| double fontSize = _paragraphStyle.fontSize ?? DomRenderer.defaultFontSize; |
| final ui.TextAlign textAlign = _paragraphStyle.effectiveTextAlign; |
| final ui.TextDirection textDirection = _paragraphStyle.effectiveTextDirection; |
| double? letterSpacing; |
| double? wordSpacing; |
| double? height; |
| ui.Locale? locale = _paragraphStyle.locale; |
| ui.Paint? background; |
| ui.Paint? foreground; |
| List<ui.Shadow>? shadows; |
| |
| int i = 0; |
| |
| // This loop looks expensive. However, in reality most of plain text |
| // paragraphs will have no calls to [pushStyle], skipping this loop |
| // entirely. Occasionally there will be one [pushStyle], which causes this |
| // loop to run once then move on to aggregating text. |
| while (i < _ops.length && _ops[i] is EngineTextStyle) { |
| final EngineTextStyle style = _ops[i]; |
| if (style.color != null) { |
| color = style.color!; |
| } |
| if (style.decoration != null) { |
| decoration = style.decoration; |
| } |
| if (style.decorationColor != null) { |
| decorationColor = style.decorationColor; |
| } |
| if (style.decorationStyle != null) { |
| decorationStyle = style.decorationStyle; |
| } |
| if (style.decorationThickness != null) { |
| decorationThickness = style.decorationThickness; |
| } |
| if (style.fontWeight != null) { |
| fontWeight = style.fontWeight; |
| } |
| if (style.fontStyle != null) { |
| fontStyle = style.fontStyle; |
| } |
| if (style.textBaseline != null) { |
| textBaseline = style.textBaseline; |
| } |
| fontFamily = style.fontFamily; |
| if (style.fontFamilyFallback != null) { |
| fontFamilyFallback = style.fontFamilyFallback; |
| } |
| if (style.fontFeatures != null) { |
| fontFeatures = style.fontFeatures; |
| } |
| if (style.fontSize != null) { |
| fontSize = style.fontSize!; |
| } |
| if (style.letterSpacing != null) { |
| letterSpacing = style.letterSpacing; |
| } |
| if (style.wordSpacing != null) { |
| wordSpacing = style.wordSpacing; |
| } |
| if (style.height != null) { |
| height = style.height; |
| } |
| if (style.locale != null) { |
| locale = style.locale; |
| } |
| if (style.background != null) { |
| background = style.background; |
| } |
| if (style.foreground != null) { |
| foreground = style.foreground; |
| } |
| if (style.shadows != null) { |
| shadows = style.shadows; |
| } |
| i++; |
| } |
| |
| if (color == null && foreground == null) { |
| color = defaultTextColor; |
| } |
| |
| final EngineTextStyle cumulativeStyle = EngineTextStyle( |
| color: color, |
| decoration: decoration, |
| decorationColor: decorationColor, |
| decorationStyle: decorationStyle, |
| decorationThickness: decorationThickness, |
| fontWeight: fontWeight, |
| fontStyle: fontStyle, |
| textBaseline: textBaseline, |
| fontFamily: fontFamily, |
| fontFamilyFallback: fontFamilyFallback, |
| fontFeatures: fontFeatures, |
| fontSize: fontSize, |
| letterSpacing: letterSpacing, |
| wordSpacing: wordSpacing, |
| height: height, |
| locale: locale, |
| background: background, |
| foreground: foreground, |
| shadows: shadows, |
| ); |
| |
| ui.Paint paint; |
| if (foreground != null) { |
| paint = foreground; |
| } else { |
| paint = ui.Paint(); |
| paint.color = color!; |
| } |
| |
| if (i >= _ops.length) { |
| // Empty paragraph. |
| applyTextStyleToElement( |
| element: _paragraphElement, style: cumulativeStyle); |
| return DomParagraph( |
| paragraphElement: _paragraphElement, |
| geometricStyle: ParagraphGeometricStyle( |
| textDirection: _paragraphStyle.effectiveTextDirection, |
| textAlign: _paragraphStyle.effectiveTextAlign, |
| fontFamily: fontFamily, |
| fontWeight: fontWeight, |
| fontStyle: fontStyle, |
| fontSize: fontSize, |
| lineHeight: height, |
| maxLines: _paragraphStyle.maxLines, |
| letterSpacing: letterSpacing, |
| wordSpacing: wordSpacing, |
| decoration: _textDecorationToCssString(decoration, decorationStyle), |
| ellipsis: _paragraphStyle.ellipsis, |
| shadows: shadows, |
| ), |
| plainText: '', |
| paint: paint, |
| textAlign: textAlign, |
| textDirection: textDirection, |
| background: cumulativeStyle.background, |
| placeholderCount: placeholderCount, |
| ); |
| } |
| |
| if (_ops[i] is! String) { |
| // After a series of [EngineTextStyle] ops there must be at least one text op. |
| // Otherwise, treat it as rich text. |
| return null; |
| } |
| |
| // Accumulate text into one contiguous string. |
| final StringBuffer plainTextBuffer = StringBuffer(); |
| while (i < _ops.length && _ops[i] is String) { |
| plainTextBuffer.write(_ops[i]); |
| i++; |
| } |
| |
| // After a series of [addText] ops there should only be a tail of [pop]s and |
| // nothing else. Otherwise it's rich text and we return null; |
| for (; i < _ops.length; i++) { |
| if (_ops[i] != _paragraphBuilderPop) { |
| return null; |
| } |
| } |
| |
| final String plainText = plainTextBuffer.toString(); |
| domRenderer.appendText(_paragraphElement, plainText); |
| applyTextStyleToElement( |
| element: _paragraphElement, style: cumulativeStyle); |
| // Since this is a plain paragraph apply background color to paragraph tag |
| // instead of individual spans. |
| if (cumulativeStyle.background != null) { |
| _applyTextBackgroundToElement( |
| element: _paragraphElement, style: cumulativeStyle); |
| } |
| return DomParagraph( |
| paragraphElement: _paragraphElement, |
| geometricStyle: ParagraphGeometricStyle( |
| textDirection: _paragraphStyle.effectiveTextDirection, |
| textAlign: _paragraphStyle.effectiveTextAlign, |
| fontFamily: fontFamily, |
| fontWeight: fontWeight, |
| fontStyle: fontStyle, |
| fontSize: fontSize, |
| lineHeight: height, |
| maxLines: _paragraphStyle.maxLines, |
| letterSpacing: letterSpacing, |
| wordSpacing: wordSpacing, |
| decoration: _textDecorationToCssString(decoration, decorationStyle), |
| ellipsis: _paragraphStyle.ellipsis, |
| shadows: shadows, |
| ), |
| plainText: plainText, |
| paint: paint, |
| textAlign: textAlign, |
| textDirection: textDirection, |
| background: cumulativeStyle.background, |
| placeholderCount: placeholderCount, |
| ); |
| } |
| |
| /// Builds a [Paragraph] as rich text. |
| EngineParagraph _buildRichText() { |
| final List<dynamic> elementStack = <dynamic>[]; |
| dynamic currentElement() => |
| elementStack.isNotEmpty ? elementStack.last : _paragraphElement; |
| |
| for (int i = 0; i < _ops.length; i++) { |
| final dynamic op = _ops[i]; |
| if (op is EngineTextStyle) { |
| final html.SpanElement span = domRenderer.createElement('span') as html.SpanElement; |
| applyTextStyleToElement(element: span, style: op, isSpan: true); |
| if (op.background != null) { |
| _applyTextBackgroundToElement(element: span, style: op); |
| } |
| domRenderer.append(currentElement(), span); |
| elementStack.add(span); |
| } else if (op is String) { |
| domRenderer.appendText(currentElement(), op); |
| } else if (op is ParagraphPlaceholder) { |
| domRenderer.append( |
| currentElement(), |
| createPlaceholderElement(placeholder: op), |
| ); |
| } else if (identical(op, _paragraphBuilderPop)) { |
| elementStack.removeLast(); |
| } else { |
| throw UnsupportedError('Unsupported ParagraphBuilder operation: $op'); |
| } |
| } |
| |
| return DomParagraph( |
| paragraphElement: _paragraphElement, |
| geometricStyle: ParagraphGeometricStyle( |
| textDirection: _paragraphStyle.effectiveTextDirection, |
| textAlign: _paragraphStyle.effectiveTextAlign, |
| fontFamily: _paragraphStyle.fontFamily, |
| fontWeight: _paragraphStyle.fontWeight, |
| fontStyle: _paragraphStyle.fontStyle, |
| fontSize: _paragraphStyle.fontSize, |
| lineHeight: _paragraphStyle.height, |
| maxLines: _paragraphStyle.maxLines, |
| ellipsis: _paragraphStyle.ellipsis, |
| ), |
| plainText: null, |
| paint: null, |
| textAlign: _paragraphStyle.effectiveTextAlign, |
| textDirection: _paragraphStyle.effectiveTextDirection, |
| background: null, |
| placeholderCount: placeholderCount, |
| ); |
| } |
| } |
| |
| /// Holds information for a placeholder in a paragraph. |
| /// |
| /// [width], [height] and [baselineOffset] are expected to be already scaled. |
| class ParagraphPlaceholder { |
| ParagraphPlaceholder( |
| this.width, |
| this.height, |
| this.alignment, { |
| required this.baselineOffset, |
| required this.baseline, |
| }); |
| |
| /// The scaled width of the placeholder. |
| final double width; |
| |
| /// The scaled height of the placeholder. |
| final double height; |
| |
| /// Specifies how the placeholder rectangle will be vertically aligned with |
| /// the surrounding text. |
| final ui.PlaceholderAlignment alignment; |
| |
| /// When the [alignment] value is [ui.PlaceholderAlignment.baseline], the |
| /// [baselineOffset] indicates the distance from the baseline to the top of |
| /// the placeholder rectangle. |
| final double baselineOffset; |
| |
| /// Dictates whether to use alphabetic or ideographic baseline. |
| final ui.TextBaseline baseline; |
| } |
| |
| /// Converts [fontWeight] to its CSS equivalent value. |
| String? fontWeightToCss(ui.FontWeight? fontWeight) { |
| if (fontWeight == null) { |
| return null; |
| } |
| return fontWeightIndexToCss(fontWeightIndex: fontWeight.index); |
| } |
| |
| String fontWeightIndexToCss({int fontWeightIndex = 3}) { |
| switch (fontWeightIndex) { |
| case 0: |
| return '100'; |
| case 1: |
| return '200'; |
| case 2: |
| return '300'; |
| case 3: |
| return 'normal'; |
| case 4: |
| return '500'; |
| case 5: |
| return '600'; |
| case 6: |
| return 'bold'; |
| case 7: |
| return '800'; |
| case 8: |
| return '900'; |
| } |
| |
| assert(() { |
| throw AssertionError( |
| 'Failed to convert font weight $fontWeightIndex to CSS.', |
| ); |
| }()); |
| |
| return ''; |
| } |
| |
| /// Applies a paragraph [style] to an [element], translating the properties to |
| /// their corresponding CSS equivalents. |
| void _applyParagraphStyleToElement({ |
| required html.HtmlElement element, |
| required EngineParagraphStyle style, |
| }) { |
| assert(element != null); // ignore: unnecessary_null_comparison |
| assert(style != null); // ignore: unnecessary_null_comparison |
| // TODO(yjbanov): What do we do about ParagraphStyle._locale and ellipsis? |
| final html.CssStyleDeclaration cssStyle = element.style; |
| |
| if (style.textAlign != null) { |
| cssStyle.textAlign = textAlignToCssValue( |
| style.textAlign, style.textDirection ?? ui.TextDirection.ltr); |
| } |
| if (style.lineHeight != null) { |
| cssStyle.lineHeight = '${style.lineHeight}'; |
| } |
| if (style.textDirection != null) { |
| cssStyle.direction = textDirectionToCss(style.textDirection); |
| } |
| if (style.fontSize != null) { |
| cssStyle.fontSize = '${style.fontSize!.floor()}px'; |
| } |
| if (style.fontWeight != null) { |
| cssStyle.fontWeight = fontWeightToCss(style.fontWeight); |
| } |
| if (style.fontStyle != null) { |
| cssStyle.fontStyle = |
| style.fontStyle == ui.FontStyle.normal ? 'normal' : 'italic'; |
| } |
| cssStyle.fontFamily = canonicalizeFontFamily(style._effectiveFontFamily); |
| } |
| |
| /// Applies a text [style] to an [element], translating the properties to their |
| /// corresponding CSS equivalents. |
| /// |
| /// If [isSpan] is true, the text element is a span within richtext and |
| /// should not assign effectiveFontFamily if fontFamily was not specified. |
| void applyTextStyleToElement({ |
| required html.HtmlElement element, |
| required EngineTextStyle style, |
| bool isSpan = false, |
| }) { |
| assert(element != null); // ignore: unnecessary_null_comparison |
| assert(style != null); // ignore: unnecessary_null_comparison |
| bool updateDecoration = false; |
| final html.CssStyleDeclaration cssStyle = element.style; |
| |
| final ui.Color? color = style.foreground?.color ?? style.color; |
| if (color != null) { |
| cssStyle.color = colorToCssString(color); |
| } |
| final ui.Color? background = style.background?.color; |
| if (background != null) { |
| cssStyle.backgroundColor = colorToCssString(background); |
| } |
| if (style.height != null) { |
| cssStyle.lineHeight = '${style.height}'; |
| } |
| if (style.fontSize != null) { |
| cssStyle.fontSize = '${style.fontSize!.floor()}px'; |
| } |
| if (style.fontWeight != null) { |
| cssStyle.fontWeight = fontWeightToCss(style.fontWeight); |
| } |
| if (style.fontStyle != null) { |
| cssStyle.fontStyle = |
| style.fontStyle == ui.FontStyle.normal ? 'normal' : 'italic'; |
| } |
| // For test environment use effectiveFontFamily since we need to |
| // consistently use Ahem font. |
| if (isSpan && !ui.debugEmulateFlutterTesterEnvironment) { |
| cssStyle.fontFamily = canonicalizeFontFamily(style.fontFamily); |
| } else { |
| cssStyle.fontFamily = canonicalizeFontFamily(style.effectiveFontFamily); |
| } |
| if (style.letterSpacing != null) { |
| cssStyle.letterSpacing = '${style.letterSpacing}px'; |
| } |
| if (style.wordSpacing != null) { |
| cssStyle.wordSpacing = '${style.wordSpacing}px'; |
| } |
| if (style.decoration != null) { |
| updateDecoration = true; |
| } |
| if (style.shadows != null) { |
| cssStyle.textShadow = _shadowListToCss(style.shadows!); |
| } |
| |
| if (updateDecoration) { |
| if (style.decoration != null) { |
| final String? textDecoration = |
| _textDecorationToCssString(style.decoration, style.decorationStyle); |
| if (textDecoration != null) { |
| if (browserEngine == BrowserEngine.webkit) { |
| DomRenderer.setElementStyle( |
| element, '-webkit-text-decoration', textDecoration); |
| } else { |
| cssStyle.textDecoration = textDecoration; |
| } |
| final ui.Color? decorationColor = style.decorationColor; |
| if (decorationColor != null) { |
| cssStyle.textDecorationColor = colorToCssString(decorationColor)!; |
| } |
| } |
| } |
| } |
| |
| final List<ui.FontFeature>? fontFeatures = style.fontFeatures; |
| if (fontFeatures != null && fontFeatures.isNotEmpty) { |
| cssStyle.fontFeatureSettings = _fontFeatureListToCss(fontFeatures); |
| } |
| } |
| |
| html.Element createPlaceholderElement({ |
| required ParagraphPlaceholder placeholder, |
| }) { |
| final html.Element element = domRenderer.createElement('span'); |
| element.className = placeholderClass; |
| final html.CssStyleDeclaration style = element.style; |
| style |
| ..display = 'inline-block' |
| ..width = '${placeholder.width}px' |
| ..height = '${placeholder.height}px' |
| ..verticalAlign = _placeholderAlignmentToCssVerticalAlign(placeholder); |
| |
| return element; |
| } |
| |
| String _placeholderAlignmentToCssVerticalAlign( |
| ParagraphPlaceholder placeholder, |
| ) { |
| // For more details about the vertical-align CSS property, see: |
| // - https://developer.mozilla.org/en-US/docs/Web/CSS/vertical-align |
| switch (placeholder.alignment) { |
| case ui.PlaceholderAlignment.top: |
| return 'top'; |
| |
| case ui.PlaceholderAlignment.middle: |
| return 'middle'; |
| |
| case ui.PlaceholderAlignment.bottom: |
| return 'bottom'; |
| |
| case ui.PlaceholderAlignment.aboveBaseline: |
| return 'baseline'; |
| |
| case ui.PlaceholderAlignment.belowBaseline: |
| return '-${placeholder.height}px'; |
| |
| case ui.PlaceholderAlignment.baseline: |
| // In CSS, the placeholder is already placed above the baseline. But |
| // Flutter's `baselineOffset` assumes the placeholder is placed below the |
| // baseline. That's why we need to subtract the placeholder's height from |
| // `baselineOffset`. |
| final double offset = placeholder.baselineOffset - placeholder.height; |
| return '${offset}px'; |
| } |
| } |
| |
| String _shadowListToCss(List<ui.Shadow> shadows) { |
| if (shadows.isEmpty) { |
| return ''; |
| } |
| // CSS text-shadow is a comma separated list of shadows. |
| // <offsetx> <offsety> <blur-radius> <color>. |
| // Shadows are applied front-to-back with first shadow on top. |
| // Color is optional. offsetx,y are required. blur-radius is optional as well |
| // and defaults to 0. |
| final StringBuffer sb = StringBuffer(); |
| final int len = shadows.length; |
| for (int i = 0; i < len; i++) { |
| if (i != 0) { |
| sb.write(','); |
| } |
| final ui.Shadow shadow = shadows[i]; |
| sb.write('${shadow.offset.dx}px ${shadow.offset.dy}px ' |
| '${shadow.blurRadius}px ${colorToCssString(shadow.color)}'); |
| } |
| return sb.toString(); |
| } |
| |
| String _fontFeatureListToCss(List<ui.FontFeature> fontFeatures) { |
| assert(fontFeatures.isNotEmpty); |
| |
| // For more details, see: |
| // * https://developer.mozilla.org/en-US/docs/Web/CSS/font-feature-settings |
| final StringBuffer sb = StringBuffer(); |
| final int len = fontFeatures.length; |
| for (int i = 0; i < len; i++) { |
| if (i != 0) { |
| sb.write(','); |
| } |
| final ui.FontFeature fontFeature = fontFeatures[i]; |
| sb.write('"${fontFeature.feature}" ${fontFeature.value}'); |
| } |
| return sb.toString(); |
| } |
| |
| /// Applies background color properties in text style to paragraph or span |
| /// elements. |
| void _applyTextBackgroundToElement({ |
| required html.HtmlElement element, |
| required EngineTextStyle style, |
| }) { |
| final ui.Paint? newBackground = style.background; |
| if (newBackground != null) { |
| DomRenderer.setElementStyle( |
| element, 'background-color', colorToCssString(newBackground.color)); |
| } |
| } |
| |
| /// Converts text decoration style to CSS text-decoration-style value. |
| String? _textDecorationToCssString( |
| ui.TextDecoration? decoration, ui.TextDecorationStyle? decorationStyle) { |
| final StringBuffer decorations = StringBuffer(); |
| if (decoration != null) { |
| if (decoration.contains(ui.TextDecoration.underline)) { |
| decorations.write('underline '); |
| } |
| if (decoration.contains(ui.TextDecoration.overline)) { |
| decorations.write('overline '); |
| } |
| if (decoration.contains(ui.TextDecoration.lineThrough)) { |
| decorations.write('line-through '); |
| } |
| } |
| if (decorationStyle != null) { |
| decorations.write(_decorationStyleToCssString(decorationStyle)); |
| } |
| return decorations.isEmpty ? null : decorations.toString(); |
| } |
| |
| String? _decorationStyleToCssString(ui.TextDecorationStyle decorationStyle) { |
| switch (decorationStyle) { |
| case ui.TextDecorationStyle.dashed: |
| return 'dashed'; |
| case ui.TextDecorationStyle.dotted: |
| return 'dotted'; |
| case ui.TextDecorationStyle.double: |
| return 'double'; |
| case ui.TextDecorationStyle.solid: |
| return 'solid'; |
| case ui.TextDecorationStyle.wavy: |
| return 'wavy'; |
| default: |
| return null; |
| } |
| } |
| |
| /// Converts [textDirection] to its corresponding CSS value. |
| /// |
| /// This value is used for the "direction" CSS property, e.g.: |
| /// |
| /// ```css |
| /// direction: rtl; |
| /// ``` |
| String? textDirectionToCss(ui.TextDirection? textDirection) { |
| if (textDirection == null) { |
| return null; |
| } |
| return textDirectionIndexToCss(textDirection.index); |
| } |
| |
| String? textDirectionIndexToCss(int textDirectionIndex) { |
| switch (textDirectionIndex) { |
| case 0: |
| return 'rtl'; |
| case 1: |
| return null; // ltr is the default |
| } |
| |
| assert(() { |
| throw AssertionError( |
| 'Failed to convert text direction $textDirectionIndex to CSS', |
| ); |
| }()); |
| |
| return null; |
| } |
| |
| /// Converts [align] to its corresponding CSS value. |
| /// |
| /// This value is used as the "text-align" CSS property, e.g.: |
| /// |
| /// ```css |
| /// text-align: right; |
| /// ``` |
| String textAlignToCssValue( |
| ui.TextAlign? align, ui.TextDirection textDirection) { |
| switch (align) { |
| case ui.TextAlign.left: |
| return 'left'; |
| case ui.TextAlign.right: |
| return 'right'; |
| case ui.TextAlign.center: |
| return 'center'; |
| case ui.TextAlign.justify: |
| return 'justify'; |
| case ui.TextAlign.end: |
| switch (textDirection) { |
| case ui.TextDirection.ltr: |
| return 'end'; |
| case ui.TextDirection.rtl: |
| return 'left'; |
| } |
| case ui.TextAlign.start: |
| switch (textDirection) { |
| case ui.TextDirection.ltr: |
| return ''; // it's the default |
| case ui.TextDirection.rtl: |
| return 'right'; |
| } |
| case null: |
| // If align is not specified return default. |
| return ''; |
| } |
| } |