blob: 79d1d2d20445cf9785b0d5ec04e7cd2f48c90d0f [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: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;
void _invalidateBoundsCache() {
_cachedBoundingClientRect = null;
}
/// Sets text of contents to a single space character to measure empty text.
void updateTextToSpace() {
_invalidateBoundsCache();
_element.text = ' ';
}
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 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;
}
}