blob: dc12dbb66063c9f984662e5c5890274bfefb383a [file] [log] [blame]
// 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 'package:meta/meta.dart';
import 'package:ui/ui.dart' as ui;
import '../browser_detection.dart';
import '../dom_renderer.dart';
import '../util.dart';
import 'measurement.dart';
import 'paragraph.dart';
String buildCssFontString({
required ui.FontStyle? fontStyle,
required ui.FontWeight? fontWeight,
required double? fontSize,
required String fontFamily,
}) {
final StringBuffer result = StringBuffer();
// Font style
if (fontStyle != null) {
result.write(fontStyle == ui.FontStyle.normal ? 'normal' : 'italic');
} else {
result.write(DomRenderer.defaultFontStyle);
}
result.write(' ');
// Font weight.
if (fontWeight != null) {
result.write(fontWeightToCss(fontWeight));
} else {
result.write(DomRenderer.defaultFontWeight);
}
result.write(' ');
if (fontSize != null) {
result.write(fontSize.floor());
} else {
result.write(DomRenderer.defaultFontSize);
}
result.write('px ');
result.write(canonicalizeFontFamily(fontFamily));
return result.toString();
}
/// Contains the subset of [ui.ParagraphStyle] properties that affect layout.
class ParagraphGeometricStyle {
ParagraphGeometricStyle({
required this.textDirection,
required this.textAlign,
this.fontWeight,
this.fontStyle,
this.fontFamily,
this.fontSize,
this.lineHeight,
this.maxLines,
this.letterSpacing,
this.wordSpacing,
this.decoration,
this.ellipsis,
this.shadows,
});
final ui.TextDirection textDirection;
final ui.TextAlign textAlign;
final ui.FontWeight? fontWeight;
final ui.FontStyle? fontStyle;
final String? fontFamily;
final double? fontSize;
final double? lineHeight;
final int? maxLines;
final double? letterSpacing;
final double? wordSpacing;
final String? decoration;
final String? ellipsis;
final List<ui.Shadow>? shadows;
// Since all fields above are primitives, cache hashcode since ruler lookups
// use this style as key.
int? _cachedHashCode;
/// Returns the font-family that should be used to style the paragraph. It may
/// or may not be different from [fontFamily]:
///
/// - Always returns "Ahem" in tests.
/// - Provides correct defaults when [fontFamily] doesn't have a value.
String get effectiveFontFamily {
if (assertionsEnabled) {
// In widget tests we use a predictable-size font "Ahem". This makes
// widget tests predictable and less flaky.
if (ui.debugEmulateFlutterTesterEnvironment) {
return 'Ahem';
}
}
final String? localFontFamily = fontFamily;
if (localFontFamily == null || localFontFamily.isEmpty) {
return DomRenderer.defaultFontFamily;
}
return localFontFamily;
}
String? _cssFontString;
/// Cached font string that can 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,
);
}
TextHeightStyle? _cachedHeightStyle;
TextHeightStyle get textHeightStyle {
TextHeightStyle? style = _cachedHeightStyle;
if (style == null) {
style = TextHeightStyle(
fontFamily: effectiveFontFamily,
fontSize: fontSize ?? DomRenderer.defaultFontSize,
height: lineHeight,
// TODO(mdebbar): Pass the actual value when font features become supported
// https://github.com/flutter/flutter/issues/64595
fontFeatures: null,
);
_cachedHeightStyle = style;
}
return style;
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other.runtimeType != runtimeType) {
return false;
}
return other is ParagraphGeometricStyle
&& other.textDirection == textDirection
&& other.textAlign == textAlign
&& other.fontWeight == fontWeight
&& other.fontStyle == fontStyle
&& other.fontFamily == fontFamily
&& other.fontSize == fontSize
&& other.lineHeight == lineHeight
&& other.maxLines == maxLines
&& other.letterSpacing == letterSpacing
&& other.wordSpacing == wordSpacing
&& other.decoration == decoration
&& other.ellipsis == ellipsis;
}
@override
int get hashCode => _cachedHashCode ??= ui.hashValues(
textDirection,
textAlign,
fontWeight,
fontStyle,
fontFamily,
fontSize,
lineHeight,
maxLines,
letterSpacing,
wordSpacing,
decoration,
ellipsis,
);
@override
String toString() {
if (assertionsEnabled) {
return '$runtimeType(textDirection: $textDirection, textAlign: $textAlign,'
' fontWeight: $fontWeight,'
' fontStyle: $fontStyle,'
' fontFamily: $fontFamily, fontSize: $fontSize,'
' lineHeight: $lineHeight,'
' maxLines: $maxLines,'
' letterSpacing: $letterSpacing,'
' wordSpacing: $wordSpacing,'
' decoration: $decoration,'
' ellipsis: $ellipsis,'
')';
} else {
return super.toString();
}
}
}
/// Contains all styles that have an effect on the height of text.
///
/// This is useful as a cache key for [TextHeightRuler]. It's more efficient
/// than using the entire [ParagraphGeometricStyle] as a cache key.
class TextHeightStyle {
TextHeightStyle({
required this.fontFamily,
required this.fontSize,
required this.height,
required this.fontFeatures,
});
final String fontFamily;
final double fontSize;
final double? height;
final List<ui.FontFeature>? fontFeatures;
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is TextHeightStyle && other.hashCode == hashCode;
}
@override
late final int hashCode = ui.hashValues(
fontFamily,
fontSize,
height,
ui.hashList(fontFeatures),
);
}
/// Provides text dimensions found on [_element]. The idea behind this class is
/// to allow the [ParagraphRuler] to mutate multiple dom elements and allow
/// consumers to lazily read the measurements.
///
/// The [ParagraphRuler] would have multiple instances of [TextDimensions] with
/// different backing elements for different types of measurements. When a
/// measurement is needed, the [ParagraphRuler] would mutate all the backing
/// elements at once. The consumer of the ruler can later read those
/// measurements.
///
/// The rationale behind this is to minimize browser reflows by batching dom
/// writes first, then performing all the reads.
class TextDimensions {
TextDimensions(this._element);
final html.HtmlElement _element;
html.Rectangle<num>? _cachedBoundingClientRect;
/// Attempts to efficiently copy text from [from].
///
/// The primary efficiency gain is from rare occurrence of rich text in
/// typical apps.
void updateText(DomParagraph from, ParagraphGeometricStyle style) {
assert(from != null); // ignore: unnecessary_null_comparison
assert(_element != null); // ignore: unnecessary_null_comparison
assert(from.debugHasSameRootStyle(style));
assert(() {
final bool wasEmptyOrPlainText = _element.childNodes.isEmpty ||
(_element.childNodes.length == 1 &&
_element.childNodes.first is html.Text);
if (!wasEmptyOrPlainText) {
throw Exception(
'Failed to copy text into the paragraph measuring element. The '
'element already contains rich text "${_element.innerHtml}". It is '
'likely that a previous measurement did not clean up after '
'itself.');
}
return true;
}());
_invalidateBoundsCache();
final String? plainText = from.plainText;
if (plainText != null) {
// Plain text: just set the string. The paragraph's style is assumed to
// match the style set on the `element`. Setting text as plain string is
// faster because it doesn't change the DOM structure or CSS attributes,
// and therefore doesn't trigger style recalculations in the browser.
if (plainText.endsWith('\n')) {
// On the web the last newline is ignored. To be consistent with
// native engine implementation we add extra newline to get correct
// height measurement.
_element.text = '$plainText\n';
} else {
_element.text = plainText;
}
} else {
// Rich text: deeply copy contents. This is the slow case that should be
// avoided if fast layout performance is desired.
final html.Element copy = from.paragraphElement.clone(true) as html.Element;
_element.nodes.addAll(copy.nodes);
}
}
/// Updated element style width.
void updateConstraintWidth(double width, String? ellipsis) {
_invalidateBoundsCache();
if (width.isInfinite) {
_element.style
..width = null
..whiteSpace = 'pre';
} else if (ellipsis != null) {
// Width is finite, but we don't want to let the text soft-wrap when
// ellipsis overflow is enabled.
_element.style
..width = '${width}px'
..whiteSpace = 'pre';
} else {
// Width is finite and there's no ellipsis overflow.
_element.style
..width = '${width}px'
..whiteSpace = 'pre-wrap';
}
}
void _invalidateBoundsCache() {
_cachedBoundingClientRect = null;
}
/// Sets text of contents to a single space character to measure empty text.
void updateTextToSpace() {
_invalidateBoundsCache();
_element.text = ' ';
}
/// Applies geometric style properties to the [element].
void applyStyle(ParagraphGeometricStyle style) {
final html.CssStyleDeclaration elementStyle = _element.style;
elementStyle
..direction = textDirectionToCss(style.textDirection)
..textAlign = textAlignToCssValue(style.textAlign, style.textDirection)
..fontSize = style.fontSize != null ? '${style.fontSize!.floor()}px' : null
..fontFamily = canonicalizeFontFamily(style.effectiveFontFamily)
..fontWeight =
style.fontWeight != null ? fontWeightToCss(style.fontWeight) : null
..fontStyle = style.fontStyle != null
? style.fontStyle == ui.FontStyle.normal ? 'normal' : 'italic'
: null
..letterSpacing =
style.letterSpacing != null ? '${style.letterSpacing}px' : null
..wordSpacing =
style.wordSpacing != null ? '${style.wordSpacing}px' : null;
final String? decoration = style.decoration;
if (browserEngine == BrowserEngine.webkit) {
DomRenderer.setElementStyle(
_element, '-webkit-text-decoration', decoration);
} else {
elementStyle.textDecoration = decoration;
}
if (style.lineHeight != null) {
elementStyle.lineHeight = style.lineHeight!.toString();
}
_invalidateBoundsCache();
}
void applyHeightStyle(TextHeightStyle textHeightStyle) {
final String fontFamily = textHeightStyle.fontFamily;
final double fontSize = textHeightStyle.fontSize;
final html.CssStyleDeclaration style = _element.style;
style
..fontSize = '${fontSize.floor()}px'
..fontFamily = canonicalizeFontFamily(fontFamily);
final double? height = textHeightStyle.height;
if (height != null) {
style.lineHeight = height.toString();
}
_invalidateBoundsCache();
}
/// Appends element and probe to hostElement that is set up for a specific
/// TextStyle.
void appendToHost(html.HtmlElement hostElement) {
hostElement.append(_element);
_invalidateBoundsCache();
}
html.Rectangle<num> _readAndCacheMetrics() =>
_cachedBoundingClientRect ??= _element.getBoundingClientRect();
/// The width of the paragraph being measured.
double get width => _readAndCacheMetrics().width as double;
/// The height of the paragraph being measured.
double get height {
double cachedHeight = _readAndCacheMetrics().height as double;
if (browserEngine == BrowserEngine.firefox &&
// In the flutter tester environment, we use a predictable-size for font
// measurement tests.
!ui.debugEmulateFlutterTesterEnvironment) {
// See subpixel rounding bug :
// https://bugzilla.mozilla.org/show_bug.cgi?id=442139
// This causes bottom of letters such as 'y' to be cutoff and
// incorrect rendering of double underlines.
cachedHeight += 1.0;
}
return cachedHeight;
}
}
/// Performs height measurement for the given [textHeightStyle].
///
/// The two results of this ruler's measurement are:
///
/// 1. [alphabeticBaseline].
/// 2. [height].
class TextHeightRuler {
TextHeightRuler(this.textHeightStyle, this.rulerHost);
final TextHeightStyle textHeightStyle;
final RulerHost rulerHost;
// Elements used to measure the line-height metric.
late final html.HtmlElement _probe = _createProbe();
late final html.HtmlElement _host = _createHost();
final TextDimensions _dimensions = TextDimensions(html.ParagraphElement());
/// The alphabetic baseline for this ruler's [textHeightStyle].
late final double alphabeticBaseline = _probe.getBoundingClientRect().bottom.toDouble();
/// The height for this ruler's [textHeightStyle].
late final double height = _dimensions.height;
/// Disposes of this ruler and detaches it from the DOM tree.
void dispose() {
_host.remove();
}
html.HtmlElement _createHost() {
final html.DivElement host = html.DivElement();
host.style
..visibility = 'hidden'
..position = 'absolute'
..top = '0'
..left = '0'
..display = 'flex'
..flexDirection = 'row'
..alignItems = 'baseline'
..margin = '0'
..border = '0'
..padding = '0';
if (assertionsEnabled) {
host.setAttribute('data-ruler', 'line-height');
}
_dimensions.applyHeightStyle(textHeightStyle);
// Force single-line (even if wider than screen) and preserve whitespaces.
_dimensions._element.style.whiteSpace = 'pre';
// To measure line-height, all we need is a whitespace.
_dimensions.updateTextToSpace();
_dimensions.appendToHost(host);
rulerHost.addElement(host);
return host;
}
html.HtmlElement _createProbe() {
final html.HtmlElement probe = html.DivElement();
_host.append(probe);
return probe;
}
}
/// Performs 4 types of measurements:
///
/// 1. Single line: can be prepared by calling [measureAsSingleLine].
/// Measurement values will be available at [singleLineDimensions].
///
/// 2. Minimum intrinsic width: can be prepared by calling
/// [measureMinIntrinsicWidth]. Measurement values will be available at
/// [minIntrinsicDimensions].
///
/// 3. Constrained: can be prepared by calling [measureWithConstraints] and
/// passing the constraints. Measurement values will be available at
/// [constrainedDimensions].
///
/// 4. Boxes: within a paragraph, it measures a list of text boxes that enclose
/// a given range of text.
///
/// For performance reasons, it's advised to use [measureAll] and then reading
/// whatever measurements are needed. This causes the browser to only reflow
/// once instead of many times.
///
/// The [measureAll] method performs the first 3 stateful measurements but not
/// the 4th one.
///
/// This class is both reusable and stateful. Use it carefully. The correct
/// usage is as follows:
///
/// * First, call [willMeasure] passing it the paragraph to be measured.
/// * Call any of the [measureAsSingleLine], [measureMinIntrinsicWidth],
/// [measureWithConstraints], or [measureAll], to prepare the respective
/// measurement. These methods can be called any number of times.
/// * Call [didMeasure] to indicate that you are done with the paragraph passed
/// to the [willMeasure] method.
///
/// It is safe to reuse this object as long as paragraphs passed to the
/// [measure] method have the same style.
///
/// The only stateless method provided by this class is [measureBoxesForRange]
/// that doesn't rely on [willMeasure] and [didMeasure] lifecycle methods.
///
/// This class optimizes for plain text paragraphs, which should constitute the
/// majority of paragraphs in typical apps.
class ParagraphRuler {
/// The only style that this [ParagraphRuler] measures text.
final ParagraphGeometricStyle style;
/// A [RulerManager] owns the host DOM element that this ruler can add
/// elements to.
///
/// The [rulerManager] keeps a cache of multiple [ParagraphRuler] instances,
/// but a [ParagraphRuler] can only belong to one [RulerManager].
final RulerManager rulerManager;
ParagraphRuler(this.style, this.rulerManager) {
_configureSingleLineHostElements();
_configureMinIntrinsicHostElements();
_configureConstrainedHostElements();
}
/// The alphabetic baseline of the paragraph being measured.
double get alphabeticBaseline => _textHeightRuler.alphabeticBaseline;
// Elements used to measure single-line metrics.
final html.DivElement _singleLineHost = html.DivElement();
final TextDimensions singleLineDimensions =
TextDimensions(html.ParagraphElement());
// Elements used to measure minIntrinsicWidth.
final html.DivElement _minIntrinsicHost = html.DivElement();
TextDimensions minIntrinsicDimensions =
TextDimensions(html.ParagraphElement());
// Elements used to measure metrics under a width constraint.
final html.DivElement _constrainedHost = html.DivElement();
TextDimensions constrainedDimensions =
TextDimensions(html.ParagraphElement());
// Elements used to measure the line-height metric.
late final TextHeightRuler _textHeightRuler =
TextHeightRuler(style.textHeightStyle, rulerManager);
double get lineHeight {
return _textHeightRuler.height;
}
/// The number of times this ruler was used this frame.
///
/// This value is used to determine which rulers are rarely used and should be
/// evicted from the ruler cache.
int get hitCount => _hitCount;
int _hitCount = 0;
/// This method should be called whenever this ruler is being used to perform
/// measurements.
///
/// It increases the hit count of this ruler which is used when clearing the
/// [rulerManager]'s cache to find the least used rulers.
void hit() {
_hitCount++;
}
/// Resets the hit count back to zero.
void resetHitCount() {
_hitCount = 0;
}
/// Makes sure this ruler is not used again after it has been disposed of,
/// which would indicate a bug.
@visibleForTesting
bool get debugIsDisposed => _debugIsDisposed;
bool _debugIsDisposed = false;
void _configureSingleLineHostElements() {
_singleLineHost.style
..visibility = 'hidden'
..position = 'absolute'
..top = '0' // this is important as baseline == probe.bottom
..left = '0'
..display = 'flex'
..flexDirection = 'row'
..alignItems = 'baseline'
..margin = '0'
..border = '0'
..padding = '0';
if (assertionsEnabled) {
_singleLineHost.setAttribute('data-ruler', 'single-line');
}
singleLineDimensions.applyStyle(style);
// Force single-line (even if wider than screen) and preserve whitespaces.
singleLineDimensions._element.style.whiteSpace = 'pre';
singleLineDimensions.appendToHost(_singleLineHost);
rulerManager.addElement(_singleLineHost);
}
void _configureMinIntrinsicHostElements() {
// Configure min intrinsic host elements.
_minIntrinsicHost.style
..visibility = 'hidden'
..position = 'absolute'
..top = '0' // this is important as baseline == probe.bottom
..left = '0'
..display = 'flex'
..flexDirection = 'row'
..margin = '0'
..border = '0'
..padding = '0';
if (assertionsEnabled) {
_minIntrinsicHost.setAttribute('data-ruler', 'min-intrinsic');
}
minIntrinsicDimensions.applyStyle(style);
// "flex: 0" causes the paragraph element to shrink horizontally, exposing
// its minimum intrinsic width.
minIntrinsicDimensions._element.style
..flex = '0'
..display = 'inline'
// Preserve newlines, wrap text, remove end of line spaces.
// Not using pre-wrap here since end of line space hang measurement
// changed in Chrome 77 Beta.
..whiteSpace = 'pre-line';
_minIntrinsicHost.append(minIntrinsicDimensions._element);
rulerManager.addElement(_minIntrinsicHost);
}
void _configureConstrainedHostElements() {
_constrainedHost.style
..visibility = 'hidden'
..position = 'absolute'
..top = '0' // this is important as baseline == probe.bottom
..left = '0'
..display = 'flex'
..flexDirection = 'row'
..alignItems = 'baseline'
..margin = '0'
..border = '0'
..padding = '0';
if (assertionsEnabled) {
_constrainedHost.setAttribute('data-ruler', 'constrained');
}
constrainedDimensions.applyStyle(style);
final html.CssStyleDeclaration elementStyle =
constrainedDimensions._element.style;
elementStyle
..display = 'block'
..overflowWrap = 'break-word';
if (style.ellipsis != null) {
elementStyle
..overflow = 'hidden'
..textOverflow = 'ellipsis';
}
constrainedDimensions.appendToHost(_constrainedHost);
rulerManager.addElement(_constrainedHost);
}
/// The paragraph being measured.
DomParagraph? _paragraph;
/// Prepares this ruler for measuring the given [paragraph].
///
/// This method must be called before calling any of the `measure*` methods.
void willMeasure(DomParagraph paragraph) {
assert(paragraph != null); // ignore: unnecessary_null_comparison
assert(() {
if (_paragraph != null) {
throw Exception(
'Attempted to reuse a $ParagraphRuler but it is currently '
'measuring another paragraph ($_paragraph). It is possible that ');
}
return true;
}());
assert(paragraph.debugHasSameRootStyle(style));
_paragraph = paragraph;
}
/// Prepares all 3 measurements:
/// 1. single line.
/// 2. minimum intrinsic width.
/// 3. constrained.
void measureAll(ui.ParagraphConstraints constraints) {
measureAsSingleLine();
measureMinIntrinsicWidth();
measureWithConstraints(constraints);
}
/// Lays out the paragraph in a single line, giving it infinite amount of
/// horizontal space.
///
/// Measures [width], [height], and [alphabeticBaseline].
void measureAsSingleLine() {
assert(!_debugIsDisposed);
assert(_paragraph != null);
// HACK(mdebbar): TextField uses an empty string to measure the line height,
// which doesn't work. So we need to replace it with a whitespace. The
// correct fix would be to do line height and baseline measurements and
// cache them separately.
if (_paragraph!.plainText == '') {
singleLineDimensions.updateTextToSpace();
} else {
singleLineDimensions.updateText(_paragraph!, style);
}
}
/// Lays out the paragraph inside a flex row and sets "flex: 0", which
/// squeezes the paragraph, forcing it to occupy minimum intrinsic width.
///
/// Measures [width] and [height].
void measureMinIntrinsicWidth() {
assert(!_debugIsDisposed);
assert(_paragraph != null);
minIntrinsicDimensions.updateText(_paragraph!, style);
}
/// Lays out the paragraph giving it a width constraint.
///
/// Measures [width], [height], and [alphabeticBaseline].
void measureWithConstraints(ui.ParagraphConstraints constraints) {
assert(!_debugIsDisposed);
assert(_paragraph != null);
constrainedDimensions.updateText(_paragraph!, style);
// The extra 0.5 is because sometimes the browser needs slightly more space
// than the size it reports back. When that happens the text may be wrap
// when we thought it didn't.
constrainedDimensions.updateConstraintWidth(
constraints.width + 0.5,
style.ellipsis,
);
}
List<ui.TextBox> measurePlaceholderBoxes() {
assert(!_debugIsDisposed);
final DomParagraph? paragraph = _paragraph;
assert(paragraph != null);
if (paragraph!.placeholderCount == 0) {
return const <ui.TextBox>[];
}
final List<html.Element> placeholderElements =
constrainedDimensions._element.querySelectorAll('.$placeholderClass');
final List<ui.TextBox> boxes = <ui.TextBox>[];
for (final html.Element element in placeholderElements) {
final html.Rectangle<num> rect = element.getBoundingClientRect();
boxes.add(ui.TextBox.fromLTRBD(
rect.left as double,
rect.top as double,
rect.right as double,
rect.bottom as double,
paragraph.textDirection,
));
}
return boxes;
}
/// Returns text position in a paragraph that contains multiple
/// nested spans given an offset.
int hitTest(ui.ParagraphConstraints constraints, ui.Offset offset) {
measureWithConstraints(constraints);
// Get paragraph element root used to measure constrainedDimensions.
final html.HtmlElement el = constrainedDimensions._element;
final List<html.Node> textNodes = <html.Node>[];
// Collect all text nodes (breadth first traversal).
// Since there is no api to get bounds of text nodes directly we work
// upwards and measure span elements and finally the paragraph.
_collectTextNodes(el.childNodes, textNodes);
// Hit test spans starting from leaf nodes up (backwards).
for (int i = textNodes.length - 1; i >= 0; i--) {
final html.Node node = textNodes[i];
// Check if offset is within client rect bounds of text node's
// parent element.
final html.Element parent = node.parentNode! as html.Element;
final html.Rectangle<num> bounds = parent.getBoundingClientRect();
final double dx = offset.dx;
final double dy = offset.dy;
if (dx >= bounds.left &&
dx < bounds.right &&
dy >= bounds.top &&
dy < bounds.bottom) {
// We found the element bounds that contains offset.
// Calculate text position for this node.
return _countTextPosition(el.childNodes, textNodes[i]);
}
}
return 0;
}
void _collectTextNodes(Iterable<html.Node> nodes, List<html.Node> textNodes) {
if (nodes.isEmpty) {
return;
}
final List<html.Node> childNodes = <html.Node>[];
for (final html.Node node in nodes) {
if (node.nodeType == html.Node.TEXT_NODE) {
textNodes.add(node);
}
childNodes.addAll(node.childNodes);
}
_collectTextNodes(childNodes, textNodes);
}
int _countTextPosition(List<html.Node> nodes, html.Node endNode) {
int position = 0;
final List<html.Node> stack = nodes.reversed.toList();
while (true) {
final html.Node node = stack.removeLast();
stack.addAll(node.childNodes.reversed);
if (node == endNode) {
break;
}
if (node.nodeType == html.Node.TEXT_NODE) {
position += node.text!.length;
}
}
return position;
}
/// Performs clean-up after a measurement is done, preparing this ruler for
/// a future reuse.
///
/// Call this method immediately after calling `measure*` methods for a
/// particular [paragraph]. This ruler is not reusable until [didMeasure] is
/// called.
void didMeasure() {
assert(_paragraph != null);
// Remove any rich text we set during layout for the following reasons:
// - there won't be any text for the browser to lay out when we commit the
// current frame.
// - this keeps the cost of removing content together with the measurement
// in the profile. Otherwise, the cost of removing will be paid by a
// random next paragraph measured in the future, and make the performance
// profile hard to understand.
//
// We do not do this for plain text, because replacing plain text is more
// expensive than paying the cost of the DOM mutation to clean it.
if (_paragraph!.plainText == null) {
domRenderer
..clearDom(singleLineDimensions._element)
..clearDom(minIntrinsicDimensions._element)
..clearDom(constrainedDimensions._element);
}
_paragraph = null;
}
/// Performs stateless measurement of text boxes for a given range of text.
///
/// This method doesn't depend on [willMeasure] and [didMeasure] lifecycle
/// methods.
List<ui.TextBox> measureBoxesForRange(
String plainText,
ui.ParagraphConstraints constraints, {
required int start,
required int end,
required double alignOffset,
required ui.TextDirection textDirection,
}) {
assert(!_debugIsDisposed);
assert(start >= 0 && start <= plainText.length);
assert(end >= 0 && end <= plainText.length);
assert(start <= end);
final String before = plainText.substring(0, start);
final String rangeText = plainText.substring(start, end);
final String after = plainText.substring(end);
final html.SpanElement rangeSpan = html.SpanElement()..text = rangeText;
// Setup the [ruler.constrainedDimensions] element to be used for measurement.
domRenderer.clearDom(constrainedDimensions._element);
constrainedDimensions._element
..appendText(before)
..append(rangeSpan)
..appendText(after);
constrainedDimensions.updateConstraintWidth(constraints.width, null);
// Measure the rects of [rangeSpan].
final List<html.Rectangle<num>> clientRects = rangeSpan.getClientRects();
final List<ui.TextBox> boxes = <ui.TextBox>[];
final double maxLinesLimit = style.maxLines == null
? double.infinity
: style.maxLines! * lineHeight;
html.Rectangle<num>? previousRect;
for (final html.Rectangle<num> rect in clientRects) {
// If [rect] is an empty box on the same line as the previous box, don't
// include it in the result.
if (rect.top == previousRect?.top && rect.left == rect.right) {
continue;
}
// As soon as we go beyond [maxLines], stop adding boxes.
if (rect.top >= maxLinesLimit) {
break;
}
boxes.add(ui.TextBox.fromLTRBD(
rect.left.toDouble() + alignOffset,
rect.top as double,
rect.right.toDouble() + alignOffset,
rect.bottom as double,
textDirection,
));
previousRect = rect;
}
// Cleanup after measuring the boxes.
domRenderer.clearDom(constrainedDimensions._element);
return boxes;
}
/// Detaches this ruler from the DOM and makes it unusable for future
/// measurements.
///
/// Disposed rulers should be garbage collected after calling this method.
void dispose() {
assert(() {
if (_paragraph != null) {
throw Exception('Attempted to dispose of a ruler in the middle of '
'measurement. This is likely a bug in the framework.');
}
return true;
}());
_singleLineHost.remove();
_minIntrinsicHost.remove();
_constrainedHost.remove();
_textHeightRuler.dispose();
assert(() {
_debugIsDisposed = true;
return true;
}());
}
// Bounded cache for text measurement for a particular width constraint.
Map<String?, List<MeasurementResult?>> _measurementCache =
<String?, List<MeasurementResult?>>{};
// Mru list for cache.
final List<String?> _mruList = <String?>[];
static const int _cacheLimit = 2400;
// Number of items to evict when cache limit is reached.
static const int _cacheBlockFactor = 100;
// Number of constraint results per unique text item.
// This limit prevents growth during animation where the size of a container
// is changing.
static const int _constraintCacheSize = 8;
void cacheMeasurement(DomParagraph paragraph, MeasurementResult? item) {
final String? plainText = paragraph.plainText;
final List<MeasurementResult?> constraintCache =
_measurementCache[plainText] ??= <MeasurementResult?>[];
constraintCache.add(item);
if (constraintCache.length > _constraintCacheSize) {
constraintCache.removeAt(0);
}
_mruList.add(plainText);
if (_mruList.length > _cacheLimit) {
// Evict a range.
for (int i = 0; i < _cacheBlockFactor; i++) {
_measurementCache.remove(_mruList[i]);
}
_mruList.removeRange(0, _cacheBlockFactor);
}
}
MeasurementResult? cacheLookup(
DomParagraph paragraph, ui.ParagraphConstraints constraints) {
final String? plainText = paragraph.plainText;
if (plainText == null) {
// Multi span paragraph, do not use cache item.
return null;
}
final List<MeasurementResult?>? constraintCache =
_measurementCache[plainText];
if (constraintCache == null) {
return null;
}
final int len = constraintCache.length;
for (int i = 0; i < len; i++) {
final MeasurementResult item = constraintCache[i]!;
if (item.constraintWidth == constraints.width &&
item.textAlign == paragraph.textAlign &&
item.textDirection == paragraph.textDirection) {
return item;
}
}
return null;
}
}
/// The result that contains all measurements of a paragraph at the given
/// constraint width.
@immutable
class MeasurementResult {
/// The width that was given as a constraint when the paragraph was laid out.
final double constraintWidth;
/// Whether the paragraph can fit in a single line given [constraintWidth].
final bool isSingleLine;
/// The amount of horizontal space the paragraph occupies.
final double width;
/// The amount of vertical space the paragraph occupies.
final double height;
/// {@macro dart.ui.paragraph.naturalHeight}
///
/// When [ParagraphGeometricStyle.maxLines] is null, [naturalHeight] and
/// [height] should be equal.
final double naturalHeight;
/// The amount of vertical space each line of the paragraph occupies.
///
/// In some cases, measuring [lineHeight] is unnecessary, so it's nullable. If
/// present, it should be equal to [height] when [isSingleLine] is true.
final double? lineHeight;
/// {@macro dart.ui.paragraph.minIntrinsicWidth}
final double minIntrinsicWidth;
/// {@macro dart.ui.paragraph.maxIntrinsicWidth}
final double maxIntrinsicWidth;
/// {@macro dart.ui.paragraph.alphabeticBaseline}
final double alphabeticBaseline;
/// {@macro dart.ui.paragraph.ideographicBaseline}
final double ideographicBaseline;
/// The full list of [EngineLineMetrics] that describe in detail the various metrics
/// of each laid out line.
final List<EngineLineMetrics>? lines;
final List<ui.TextBox> placeholderBoxes;
/// The text align value of the paragraph.
final ui.TextAlign textAlign;
/// The text direction of the paragraph.
final ui.TextDirection textDirection;
const MeasurementResult(
this.constraintWidth, {
required this.isSingleLine,
required this.width,
required this.height,
required this.naturalHeight,
required this.lineHeight,
required this.minIntrinsicWidth,
required this.maxIntrinsicWidth,
required this.alphabeticBaseline,
required this.ideographicBaseline,
required this.lines,
required this.placeholderBoxes,
required ui.TextAlign? textAlign,
required ui.TextDirection? textDirection,
}) : assert(constraintWidth != null), // ignore: unnecessary_null_comparison
assert(isSingleLine != null), // ignore: unnecessary_null_comparison
assert(width != null), // ignore: unnecessary_null_comparison
assert(height != null), // ignore: unnecessary_null_comparison
assert(naturalHeight != null), // ignore: unnecessary_null_comparison
assert(minIntrinsicWidth != null), // ignore: unnecessary_null_comparison
assert(maxIntrinsicWidth != null), // ignore: unnecessary_null_comparison
assert(alphabeticBaseline != null), // ignore: unnecessary_null_comparison
assert(ideographicBaseline != null), // ignore: unnecessary_null_comparison
this.textAlign = textAlign ?? ui.TextAlign.start,// ignore: unnecessary_this
this.textDirection = textDirection ?? ui.TextDirection.ltr;// ignore: unnecessary_this
}