mirror of
https://github.com/HChaZZY/PiliPlus.git
synced 2025-12-06 09:13:48 +08:00
3817 lines
139 KiB
Dart
3817 lines
139 KiB
Dart
// Copyright 2014 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.
|
|
|
|
// ignore_for_file: uri_does_not_exist_in_doc_import
|
|
|
|
/// @docImport 'package:flutter/widgets.dart';
|
|
///
|
|
/// @docImport 'editable.dart';
|
|
library;
|
|
|
|
import 'dart:collection';
|
|
import 'dart:math' as math;
|
|
import 'dart:ui'
|
|
as ui
|
|
show
|
|
BoxHeightStyle,
|
|
BoxWidthStyle,
|
|
Gradient,
|
|
LineMetrics,
|
|
Shader,
|
|
TextBox,
|
|
TextHeightBehavior;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
/// The start and end positions for a text boundary.
|
|
typedef _TextBoundaryRecord = ({
|
|
TextPosition boundaryStart,
|
|
TextPosition boundaryEnd,
|
|
});
|
|
|
|
/// Signature for a function that determines the [_TextBoundaryRecord] at the given
|
|
/// [TextPosition].
|
|
typedef _TextBoundaryAtPosition =
|
|
_TextBoundaryRecord Function(TextPosition position);
|
|
|
|
/// Signature for a function that determines the [_TextBoundaryRecord] at the given
|
|
/// [TextPosition], for the given [String].
|
|
typedef _TextBoundaryAtPositionInText =
|
|
_TextBoundaryRecord Function(TextPosition position, String text);
|
|
|
|
const String _kEllipsis = '\u2026';
|
|
|
|
/// A render object that displays a paragraph of text.
|
|
class RenderParagraph extends RenderBox
|
|
with
|
|
ContainerRenderObjectMixin<RenderBox, TextParentData>,
|
|
RenderInlineChildrenContainerDefaults,
|
|
RelayoutWhenSystemFontsChangeMixin {
|
|
/// Creates a paragraph render object.
|
|
///
|
|
/// The [maxLines] property may be null (and indeed defaults to null), but if
|
|
/// it is not null, it must be greater than zero.
|
|
RenderParagraph(
|
|
InlineSpan text, {
|
|
TextAlign textAlign = TextAlign.start,
|
|
required TextDirection textDirection,
|
|
bool softWrap = true,
|
|
TextOverflow overflow = TextOverflow.clip,
|
|
@Deprecated(
|
|
'Use textScaler instead. '
|
|
'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. '
|
|
'This feature was deprecated after v3.12.0-2.0.pre.',
|
|
)
|
|
double textScaleFactor = 1.0,
|
|
TextScaler textScaler = TextScaler.noScaling,
|
|
int? maxLines,
|
|
Locale? locale,
|
|
StrutStyle? strutStyle,
|
|
TextWidthBasis textWidthBasis = TextWidthBasis.parent,
|
|
ui.TextHeightBehavior? textHeightBehavior,
|
|
List<RenderBox>? children,
|
|
Color? selectionColor,
|
|
SelectionRegistrar? registrar,
|
|
required Color primary,
|
|
VoidCallback? onShowMore,
|
|
}) : assert(text.debugAssertIsValid()),
|
|
assert(
|
|
maxLines == null ||
|
|
(maxLines > 0 &&
|
|
overflow != TextOverflow.ellipsis &&
|
|
overflow != TextOverflow.fade),
|
|
),
|
|
assert(
|
|
identical(textScaler, TextScaler.noScaling) || textScaleFactor == 1.0,
|
|
'textScaleFactor is deprecated and cannot be specified when textScaler is specified.',
|
|
),
|
|
_primary = primary,
|
|
_softWrap = softWrap,
|
|
_overflow = overflow,
|
|
_selectionColor = selectionColor,
|
|
_onShowMore = onShowMore,
|
|
_textPainter = TextPainter(
|
|
text: text,
|
|
textAlign: textAlign,
|
|
textDirection: textDirection,
|
|
textScaler: textScaler == TextScaler.noScaling
|
|
? TextScaler.linear(textScaleFactor)
|
|
: textScaler,
|
|
maxLines: maxLines,
|
|
ellipsis: overflow == TextOverflow.ellipsis ? _kEllipsis : null,
|
|
locale: locale,
|
|
strutStyle: strutStyle,
|
|
textWidthBasis: textWidthBasis,
|
|
textHeightBehavior: textHeightBehavior,
|
|
) {
|
|
addAll(children);
|
|
this.registrar = registrar;
|
|
}
|
|
|
|
static final String _placeholderCharacter = String.fromCharCode(
|
|
PlaceholderSpan.placeholderCodeUnit,
|
|
);
|
|
|
|
final TextPainter _textPainter;
|
|
|
|
// Currently, computing min/max intrinsic width/height will destroy state
|
|
// inside the painter. Instead of calling _layout again to get back the correct
|
|
// state, use a separate TextPainter for intrinsics calculation.
|
|
//
|
|
// TODO(abarth): Make computing the min/max intrinsic width/height a
|
|
// non-destructive operation.
|
|
TextPainter? _textIntrinsicsCache;
|
|
TextPainter get _textIntrinsics {
|
|
return (_textIntrinsicsCache ??= TextPainter())
|
|
..text = _textPainter.text
|
|
..textAlign = _textPainter.textAlign
|
|
..textDirection = _textPainter.textDirection
|
|
..textScaler = _textPainter.textScaler
|
|
..maxLines = _textPainter.maxLines
|
|
..ellipsis = _textPainter.ellipsis
|
|
..locale = _textPainter.locale
|
|
..strutStyle = _textPainter.strutStyle
|
|
..textWidthBasis = _textPainter.textWidthBasis
|
|
..textHeightBehavior = _textPainter.textHeightBehavior;
|
|
}
|
|
|
|
List<AttributedString>? _cachedAttributedLabels;
|
|
|
|
List<InlineSpanSemanticsInformation>? _cachedCombinedSemanticsInfos;
|
|
|
|
/// The text to display.
|
|
InlineSpan get text => _textPainter.text!;
|
|
set text(InlineSpan value) {
|
|
switch (_textPainter.text!.compareTo(value)) {
|
|
case RenderComparison.identical:
|
|
return;
|
|
case RenderComparison.metadata:
|
|
_textPainter.text = value;
|
|
_cachedCombinedSemanticsInfos = null;
|
|
markNeedsSemanticsUpdate();
|
|
case RenderComparison.paint:
|
|
_textPainter.text = value;
|
|
_cachedAttributedLabels = null;
|
|
_cachedCombinedSemanticsInfos = null;
|
|
markNeedsPaint();
|
|
markNeedsSemanticsUpdate();
|
|
case RenderComparison.layout:
|
|
_textPainter.text = value;
|
|
_overflowShader = null;
|
|
_cachedAttributedLabels = null;
|
|
_cachedCombinedSemanticsInfos = null;
|
|
markNeedsLayout();
|
|
_removeSelectionRegistrarSubscription();
|
|
_disposeSelectableFragments();
|
|
_updateSelectionRegistrarSubscription();
|
|
}
|
|
}
|
|
|
|
/// The ongoing selections in this paragraph.
|
|
///
|
|
/// The selection does not include selections in [PlaceholderSpan] if there
|
|
/// are any.
|
|
@visibleForTesting
|
|
List<TextSelection> get selections {
|
|
if (_lastSelectableFragments == null) {
|
|
return const <TextSelection>[];
|
|
}
|
|
final List<TextSelection> results = <TextSelection>[];
|
|
for (final _SelectableFragment fragment in _lastSelectableFragments!) {
|
|
if (fragment._textSelectionStart != null &&
|
|
fragment._textSelectionEnd != null) {
|
|
results.add(
|
|
TextSelection(
|
|
baseOffset: fragment._textSelectionStart!.offset,
|
|
extentOffset: fragment._textSelectionEnd!.offset,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
// Should be null if selection is not enabled, i.e. _registrar = null. The
|
|
// paragraph splits on [PlaceholderSpan.placeholderCodeUnit], and stores each
|
|
// fragment in this list.
|
|
List<_SelectableFragment>? _lastSelectableFragments;
|
|
|
|
/// The [SelectionRegistrar] this paragraph will be, or is, registered to.
|
|
SelectionRegistrar? get registrar => _registrar;
|
|
SelectionRegistrar? _registrar;
|
|
set registrar(SelectionRegistrar? value) {
|
|
if (value == _registrar) {
|
|
return;
|
|
}
|
|
_removeSelectionRegistrarSubscription();
|
|
_disposeSelectableFragments();
|
|
_registrar = value;
|
|
_updateSelectionRegistrarSubscription();
|
|
}
|
|
|
|
void _updateSelectionRegistrarSubscription() {
|
|
if (_registrar == null) {
|
|
return;
|
|
}
|
|
_lastSelectableFragments ??= _getSelectableFragments();
|
|
_lastSelectableFragments!.forEach(_registrar!.add);
|
|
if (_lastSelectableFragments!.isNotEmpty) {
|
|
markNeedsCompositingBitsUpdate();
|
|
}
|
|
}
|
|
|
|
void _removeSelectionRegistrarSubscription() {
|
|
if (_registrar == null || _lastSelectableFragments == null) {
|
|
return;
|
|
}
|
|
_lastSelectableFragments!.forEach(_registrar!.remove);
|
|
}
|
|
|
|
List<_SelectableFragment> _getSelectableFragments() {
|
|
final String plainText = text.toPlainText(includeSemanticsLabels: false);
|
|
final List<_SelectableFragment> result = <_SelectableFragment>[];
|
|
int start = 0;
|
|
while (start < plainText.length) {
|
|
int end = plainText.indexOf(_placeholderCharacter, start);
|
|
if (start != end) {
|
|
if (end == -1) {
|
|
end = plainText.length;
|
|
}
|
|
result.add(
|
|
_SelectableFragment(
|
|
paragraph: this,
|
|
range: TextRange(start: start, end: end),
|
|
fullText: plainText,
|
|
),
|
|
);
|
|
start = end;
|
|
}
|
|
start += 1;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// Determines whether the given [Selectable] was created by this
|
|
/// [RenderParagraph].
|
|
///
|
|
/// The [RenderParagraph] splits its text into multiple [Selectable]s,
|
|
/// delimited by [PlaceholderSpan]s or [WidgetSpan]s.
|
|
bool selectableBelongsToParagraph(Selectable selectable) {
|
|
if (_lastSelectableFragments == null) {
|
|
return false;
|
|
}
|
|
return _lastSelectableFragments!.contains(selectable);
|
|
}
|
|
|
|
void _disposeSelectableFragments() {
|
|
if (_lastSelectableFragments == null) {
|
|
return;
|
|
}
|
|
for (final _SelectableFragment fragment in _lastSelectableFragments!) {
|
|
fragment.dispose();
|
|
}
|
|
_lastSelectableFragments = null;
|
|
}
|
|
|
|
@override
|
|
bool get alwaysNeedsCompositing =>
|
|
_lastSelectableFragments?.isNotEmpty ?? false;
|
|
|
|
@override
|
|
void markNeedsLayout() {
|
|
_lastSelectableFragments?.forEach(
|
|
(_SelectableFragment element) => element.didChangeParagraphLayout(),
|
|
);
|
|
super.markNeedsLayout();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_removeSelectionRegistrarSubscription();
|
|
_disposeSelectableFragments();
|
|
_textPainter.dispose();
|
|
_textIntrinsicsCache?.dispose();
|
|
_tapGestureRecognizer?.dispose();
|
|
_tapGestureRecognizer = null;
|
|
_morePainter?.dispose();
|
|
_morePainter = null;
|
|
super.dispose();
|
|
}
|
|
|
|
/// How the text should be aligned horizontally.
|
|
TextAlign get textAlign => _textPainter.textAlign;
|
|
set textAlign(TextAlign value) {
|
|
if (_textPainter.textAlign == value) {
|
|
return;
|
|
}
|
|
_textPainter.textAlign = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
/// The directionality of the text.
|
|
///
|
|
/// This decides how the [TextAlign.start], [TextAlign.end], and
|
|
/// [TextAlign.justify] values of [textAlign] are interpreted.
|
|
///
|
|
/// This is also used to disambiguate how to render bidirectional text. For
|
|
/// example, if the [text] is an English phrase followed by a Hebrew phrase,
|
|
/// in a [TextDirection.ltr] context the English phrase will be on the left
|
|
/// and the Hebrew phrase to its right, while in a [TextDirection.rtl]
|
|
/// context, the English phrase will be on the right and the Hebrew phrase on
|
|
/// its left.
|
|
TextDirection get textDirection => _textPainter.textDirection!;
|
|
set textDirection(TextDirection value) {
|
|
if (_textPainter.textDirection == value) {
|
|
return;
|
|
}
|
|
_textPainter.textDirection = value;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
/// Whether the text should break at soft line breaks.
|
|
///
|
|
/// If false, the glyphs in the text will be positioned as if there was
|
|
/// unlimited horizontal space.
|
|
///
|
|
/// If [softWrap] is false, [overflow] and [textAlign] may have unexpected
|
|
/// effects.
|
|
bool get softWrap => _softWrap;
|
|
bool _softWrap;
|
|
set softWrap(bool value) {
|
|
if (_softWrap == value) {
|
|
return;
|
|
}
|
|
_softWrap = value;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
/// How visual overflow should be handled.
|
|
TextOverflow get overflow => _overflow;
|
|
TextOverflow _overflow;
|
|
set overflow(TextOverflow value) {
|
|
if (_overflow == value) {
|
|
return;
|
|
}
|
|
_overflow = value;
|
|
_textPainter.ellipsis = value == TextOverflow.ellipsis ? _kEllipsis : null;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
/// Deprecated. Will be removed in a future version of Flutter. Use
|
|
/// [textScaler] instead.
|
|
///
|
|
/// The number of font pixels for each logical pixel.
|
|
///
|
|
/// For example, if the text scale factor is 1.5, text will be 50% larger than
|
|
/// the specified font size.
|
|
@Deprecated(
|
|
'Use textScaler instead. '
|
|
'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. '
|
|
'This feature was deprecated after v3.12.0-2.0.pre.',
|
|
)
|
|
double get textScaleFactor => _textPainter.textScaleFactor;
|
|
@Deprecated(
|
|
'Use textScaler instead. '
|
|
'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. '
|
|
'This feature was deprecated after v3.12.0-2.0.pre.',
|
|
)
|
|
set textScaleFactor(double value) {
|
|
textScaler = TextScaler.linear(value);
|
|
}
|
|
|
|
/// {@macro flutter.painting.textPainter.textScaler}
|
|
TextScaler get textScaler => _textPainter.textScaler;
|
|
set textScaler(TextScaler value) {
|
|
if (_textPainter.textScaler == value) {
|
|
return;
|
|
}
|
|
_textPainter.textScaler = value;
|
|
_overflowShader = null;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
/// An optional maximum number of lines for the text to span, wrapping if
|
|
/// necessary. If the text exceeds the given number of lines, it will be
|
|
/// truncated according to [overflow] and [softWrap].
|
|
int? get maxLines => _textPainter.maxLines;
|
|
|
|
/// The value may be null. If it is not null, then it must be greater than
|
|
/// zero.
|
|
set maxLines(int? value) {
|
|
assert(value == null || value > 0);
|
|
if (_textPainter.maxLines == value) {
|
|
return;
|
|
}
|
|
_textPainter.maxLines = value;
|
|
_overflowShader = null;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
/// Used by this paragraph's internal [TextPainter] to select a
|
|
/// locale-specific font.
|
|
///
|
|
/// In some cases, the same Unicode character may be rendered differently
|
|
/// depending on the locale. For example, the '骨' character is rendered
|
|
/// differently in the Chinese and Japanese locales. In these cases, the
|
|
/// [locale] may be used to select a locale-specific font.
|
|
Locale? get locale => _textPainter.locale;
|
|
|
|
/// The value may be null.
|
|
set locale(Locale? value) {
|
|
if (_textPainter.locale == value) {
|
|
return;
|
|
}
|
|
_textPainter.locale = value;
|
|
_overflowShader = null;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
/// {@macro flutter.painting.textPainter.strutStyle}
|
|
StrutStyle? get strutStyle => _textPainter.strutStyle;
|
|
|
|
/// The value may be null.
|
|
set strutStyle(StrutStyle? value) {
|
|
if (_textPainter.strutStyle == value) {
|
|
return;
|
|
}
|
|
_textPainter.strutStyle = value;
|
|
_overflowShader = null;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
/// {@macro flutter.painting.textPainter.textWidthBasis}
|
|
TextWidthBasis get textWidthBasis => _textPainter.textWidthBasis;
|
|
set textWidthBasis(TextWidthBasis value) {
|
|
if (_textPainter.textWidthBasis == value) {
|
|
return;
|
|
}
|
|
_textPainter.textWidthBasis = value;
|
|
_overflowShader = null;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
/// {@macro dart.ui.textHeightBehavior}
|
|
ui.TextHeightBehavior? get textHeightBehavior =>
|
|
_textPainter.textHeightBehavior;
|
|
set textHeightBehavior(ui.TextHeightBehavior? value) {
|
|
if (_textPainter.textHeightBehavior == value) {
|
|
return;
|
|
}
|
|
_textPainter.textHeightBehavior = value;
|
|
_overflowShader = null;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
/// The color to use when painting the selection.
|
|
///
|
|
/// Ignored if the text is not selectable (e.g. if [registrar] is null).
|
|
Color? get selectionColor => _selectionColor;
|
|
Color? _selectionColor;
|
|
set selectionColor(Color? value) {
|
|
if (_selectionColor == value) {
|
|
return;
|
|
}
|
|
_selectionColor = value;
|
|
if (_lastSelectableFragments?.any(
|
|
(_SelectableFragment fragment) => fragment.value.hasSelection,
|
|
) ??
|
|
false) {
|
|
markNeedsPaint();
|
|
}
|
|
}
|
|
|
|
Offset _getOffsetForPosition(TextPosition position) {
|
|
return getOffsetForCaret(position, Rect.zero) +
|
|
Offset(0, getFullHeightForCaret(position));
|
|
}
|
|
|
|
@override
|
|
double computeMinIntrinsicWidth(double height) {
|
|
final List<PlaceholderDimensions> placeholderDimensions =
|
|
layoutInlineChildren(
|
|
double.infinity,
|
|
(RenderBox child, BoxConstraints constraints) =>
|
|
Size(child.getMinIntrinsicWidth(double.infinity), 0.0),
|
|
ChildLayoutHelper.getDryBaseline,
|
|
);
|
|
return (_textIntrinsics
|
|
..setPlaceholderDimensions(placeholderDimensions)
|
|
..layout())
|
|
.minIntrinsicWidth;
|
|
}
|
|
|
|
@override
|
|
double computeMaxIntrinsicWidth(double height) {
|
|
final List<PlaceholderDimensions>
|
|
placeholderDimensions = layoutInlineChildren(
|
|
double.infinity,
|
|
// Height and baseline is irrelevant as all text will be laid
|
|
// out in a single line. Therefore, using 0.0 as a dummy for the height.
|
|
(RenderBox child, BoxConstraints constraints) =>
|
|
Size(child.getMaxIntrinsicWidth(double.infinity), 0.0),
|
|
ChildLayoutHelper.getDryBaseline,
|
|
);
|
|
return (_textIntrinsics
|
|
..setPlaceholderDimensions(placeholderDimensions)
|
|
..layout())
|
|
.maxIntrinsicWidth;
|
|
}
|
|
|
|
/// An estimate of the height of a line in the text. See [TextPainter.preferredLineHeight].
|
|
///
|
|
/// This does not require the layout to be updated.
|
|
@visibleForTesting
|
|
double get preferredLineHeight => _textPainter.preferredLineHeight;
|
|
|
|
double _computeIntrinsicHeight(double width) {
|
|
return (_textIntrinsics
|
|
..setPlaceholderDimensions(
|
|
layoutInlineChildren(
|
|
width,
|
|
ChildLayoutHelper.dryLayoutChild,
|
|
ChildLayoutHelper.getDryBaseline,
|
|
),
|
|
)
|
|
..layout(minWidth: width, maxWidth: _adjustMaxWidth(width)))
|
|
.height;
|
|
}
|
|
|
|
@override
|
|
double computeMinIntrinsicHeight(double width) {
|
|
return _computeIntrinsicHeight(width);
|
|
}
|
|
|
|
@override
|
|
double computeMaxIntrinsicHeight(double width) {
|
|
return _computeIntrinsicHeight(width);
|
|
}
|
|
|
|
@override
|
|
bool hitTestSelf(Offset position) => true;
|
|
|
|
@override
|
|
@protected
|
|
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
|
|
if (_morePainter case final textPainter?) {
|
|
late final height = _textPainter.height;
|
|
if (position.dx < textPainter.width &&
|
|
position.dy > height &&
|
|
position.dy < height + textPainter.height) {
|
|
result.add(HitTestEntry(_moreTextSpan));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
final GlyphInfo? glyph = _textPainter.getClosestGlyphForOffset(position);
|
|
// The hit-test can't fall through the horizontal gaps between visually
|
|
// adjacent characters on the same line, even with a large letter-spacing or
|
|
// text justification, as graphemeClusterLayoutBounds.width is the advance
|
|
// width to the next character, so there's no gap between their
|
|
// graphemeClusterLayoutBounds rects.
|
|
final InlineSpan? spanHit =
|
|
glyph != null && glyph.graphemeClusterLayoutBounds.contains(position)
|
|
? _textPainter.text!.getSpanForPosition(
|
|
TextPosition(offset: glyph.graphemeClusterCodeUnitRange.start),
|
|
)
|
|
: null;
|
|
switch (spanHit) {
|
|
case final HitTestTarget span:
|
|
result.add(HitTestEntry(span));
|
|
return true;
|
|
case _:
|
|
return hitTestInlineChildren(result, position);
|
|
}
|
|
}
|
|
|
|
bool _needsClipping = false;
|
|
ui.Shader? _overflowShader;
|
|
|
|
/// Whether this paragraph currently has a [dart:ui.Shader] for its overflow
|
|
/// effect.
|
|
///
|
|
/// Used to test this object. Not for use in production.
|
|
@visibleForTesting
|
|
bool get debugHasOverflowShader => _overflowShader != null;
|
|
|
|
@override
|
|
void systemFontsDidChange() {
|
|
super.systemFontsDidChange();
|
|
_textPainter.markNeedsLayout();
|
|
}
|
|
|
|
// Placeholder dimensions representing the sizes of child inline widgets.
|
|
//
|
|
// These need to be cached because the text painter's placeholder dimensions
|
|
// will be overwritten during intrinsic width/height calculations and must be
|
|
// restored to the original values before final layout and painting.
|
|
List<PlaceholderDimensions>? _placeholderDimensions;
|
|
|
|
double _adjustMaxWidth(double maxWidth) {
|
|
return softWrap || overflow == TextOverflow.ellipsis
|
|
? maxWidth
|
|
: double.infinity;
|
|
}
|
|
|
|
void _layoutTextWithConstraints(BoxConstraints constraints) {
|
|
_textPainter
|
|
..setPlaceholderDimensions(_placeholderDimensions)
|
|
..layout(
|
|
minWidth: constraints.minWidth,
|
|
maxWidth: _adjustMaxWidth(constraints.maxWidth),
|
|
);
|
|
}
|
|
|
|
@override
|
|
@protected
|
|
Size computeDryLayout(covariant BoxConstraints constraints) {
|
|
final Size size =
|
|
(_textIntrinsics
|
|
..setPlaceholderDimensions(
|
|
layoutInlineChildren(
|
|
constraints.maxWidth,
|
|
ChildLayoutHelper.dryLayoutChild,
|
|
ChildLayoutHelper.getDryBaseline,
|
|
),
|
|
)
|
|
..layout(
|
|
minWidth: constraints.minWidth,
|
|
maxWidth: _adjustMaxWidth(constraints.maxWidth),
|
|
))
|
|
.size;
|
|
return constraints.constrain(size);
|
|
}
|
|
|
|
@override
|
|
double computeDistanceToActualBaseline(TextBaseline baseline) {
|
|
assert(!debugNeedsLayout);
|
|
assert(constraints.debugAssertIsValid());
|
|
_layoutTextWithConstraints(constraints);
|
|
// TODO(garyq): Since our metric for ideographic baseline is currently
|
|
// inaccurate and the non-alphabetic baselines are based off of the
|
|
// alphabetic baseline, we use the alphabetic for now to produce correct
|
|
// layouts. We should eventually change this back to pass the `baseline`
|
|
// property when the ideographic baseline is properly implemented
|
|
// (https://github.com/flutter/flutter/issues/22625).
|
|
return _textPainter.computeDistanceToActualBaseline(
|
|
TextBaseline.alphabetic,
|
|
);
|
|
}
|
|
|
|
@override
|
|
double computeDryBaseline(
|
|
covariant BoxConstraints constraints,
|
|
TextBaseline baseline,
|
|
) {
|
|
assert(constraints.debugAssertIsValid());
|
|
_textIntrinsics
|
|
..setPlaceholderDimensions(
|
|
layoutInlineChildren(
|
|
constraints.maxWidth,
|
|
ChildLayoutHelper.dryLayoutChild,
|
|
ChildLayoutHelper.getDryBaseline,
|
|
),
|
|
)
|
|
..layout(
|
|
minWidth: constraints.minWidth,
|
|
maxWidth: _adjustMaxWidth(constraints.maxWidth),
|
|
);
|
|
return _textIntrinsics.computeDistanceToActualBaseline(
|
|
TextBaseline.alphabetic,
|
|
);
|
|
}
|
|
|
|
Color _primary;
|
|
|
|
set primary(Color primary) {
|
|
if (_primary != primary) {
|
|
_primary = primary;
|
|
_morePainter?.text = _moreTextSpan;
|
|
}
|
|
}
|
|
|
|
VoidCallback? _onShowMore;
|
|
set onShowMore(VoidCallback? onShowMore) {
|
|
_onShowMore = onShowMore;
|
|
_tapGestureRecognizer?.onTap = onShowMore;
|
|
}
|
|
|
|
TapGestureRecognizer? _tapGestureRecognizer;
|
|
TapGestureRecognizer get _effectiveTapRecognizer =>
|
|
_tapGestureRecognizer ??= TapGestureRecognizer()..onTap = _onShowMore;
|
|
|
|
TextSpan get _moreTextSpan => TextSpan(
|
|
style: text.style!.copyWith(color: _primary),
|
|
text: '查看更多',
|
|
recognizer: _effectiveTapRecognizer,
|
|
);
|
|
TextPainter? _morePainter;
|
|
|
|
bool didOverflowHeight = false;
|
|
|
|
@override
|
|
void performLayout() {
|
|
_lastSelectableFragments?.forEach(
|
|
(_SelectableFragment element) => element.didChangeParagraphLayout(),
|
|
);
|
|
final BoxConstraints constraints = this.constraints;
|
|
_placeholderDimensions = layoutInlineChildren(
|
|
constraints.maxWidth,
|
|
ChildLayoutHelper.layoutChild,
|
|
ChildLayoutHelper.getBaseline,
|
|
);
|
|
_layoutTextWithConstraints(constraints);
|
|
positionInlineChildren(_textPainter.inlinePlaceholderBoxes!);
|
|
|
|
final Size textSize = _textPainter.size;
|
|
size = constraints.constrain(textSize);
|
|
|
|
didOverflowHeight =
|
|
size.height < textSize.height || _textPainter.didExceedMaxLines;
|
|
|
|
if (didOverflowHeight) {
|
|
_morePainter ??= TextPainter(
|
|
text: _moreTextSpan,
|
|
textDirection: textDirection,
|
|
textScaler: textScaler,
|
|
locale: locale,
|
|
)..layout(maxWidth: constraints.maxWidth);
|
|
size = Size(
|
|
size.width,
|
|
constraints.constrainHeight(size.height + _morePainter!.height),
|
|
);
|
|
}
|
|
|
|
final bool didOverflowWidth = size.width < textSize.width;
|
|
// TODO(abarth): We're only measuring the sizes of the line boxes here. If
|
|
// the glyphs draw outside the line boxes, we might think that there isn't
|
|
// visual overflow when there actually is visual overflow. This can become
|
|
// a problem if we start having horizontal overflow and introduce a clip
|
|
// that affects the actual (but undetected) vertical overflow.
|
|
final bool hasVisualOverflow = didOverflowWidth || didOverflowHeight;
|
|
if (hasVisualOverflow) {
|
|
switch (_overflow) {
|
|
case TextOverflow.visible:
|
|
_needsClipping = false;
|
|
_overflowShader = null;
|
|
case TextOverflow.clip:
|
|
case TextOverflow.ellipsis:
|
|
_needsClipping = true;
|
|
_overflowShader = null;
|
|
case TextOverflow.fade:
|
|
_needsClipping = true;
|
|
final TextPainter fadeSizePainter = TextPainter(
|
|
text: TextSpan(style: _textPainter.text!.style, text: '\u2026'),
|
|
textDirection: textDirection,
|
|
textScaler: textScaler,
|
|
locale: locale,
|
|
)..layout();
|
|
if (didOverflowWidth) {
|
|
final (double fadeStart, double fadeEnd) = switch (textDirection) {
|
|
TextDirection.rtl => (fadeSizePainter.width, 0.0),
|
|
TextDirection.ltr => (
|
|
size.width - fadeSizePainter.width,
|
|
size.width,
|
|
),
|
|
};
|
|
_overflowShader = ui.Gradient.linear(
|
|
Offset(fadeStart, 0.0),
|
|
Offset(fadeEnd, 0.0),
|
|
<Color>[const Color(0xFFFFFFFF), const Color(0x00FFFFFF)],
|
|
);
|
|
} else {
|
|
final double fadeEnd = size.height;
|
|
final double fadeStart = fadeEnd - fadeSizePainter.height / 2.0;
|
|
_overflowShader = ui.Gradient.linear(
|
|
Offset(0.0, fadeStart),
|
|
Offset(0.0, fadeEnd),
|
|
<Color>[const Color(0xFFFFFFFF), const Color(0x00FFFFFF)],
|
|
);
|
|
}
|
|
fadeSizePainter.dispose();
|
|
}
|
|
} else {
|
|
_needsClipping = false;
|
|
_overflowShader = null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void applyPaintTransform(RenderBox child, Matrix4 transform) {
|
|
defaultApplyPaintTransform(child, transform);
|
|
}
|
|
|
|
@override
|
|
void paint(PaintingContext context, Offset offset) {
|
|
// Text alignment only triggers repaint so it's possible the text layout has
|
|
// been invalidated but performLayout wasn't called at this point. Make sure
|
|
// the TextPainter has a valid layout.
|
|
_layoutTextWithConstraints(constraints);
|
|
assert(() {
|
|
if (debugRepaintTextRainbowEnabled) {
|
|
final Paint paint = Paint()..color = debugCurrentRepaintColor.toColor();
|
|
context.canvas.drawRect(offset & size, paint);
|
|
}
|
|
return true;
|
|
}());
|
|
|
|
if (_needsClipping) {
|
|
final Rect bounds = offset & size;
|
|
if (_overflowShader != null) {
|
|
// This layer limits what the shader below blends with to be just the
|
|
// text (as opposed to the text and its background).
|
|
context.canvas.saveLayer(bounds, Paint());
|
|
} else {
|
|
context.canvas.save();
|
|
}
|
|
context.canvas.clipRect(bounds);
|
|
}
|
|
|
|
if (_lastSelectableFragments != null) {
|
|
for (final _SelectableFragment fragment in _lastSelectableFragments!) {
|
|
fragment.paint(context, offset);
|
|
}
|
|
}
|
|
|
|
_textPainter.paint(context.canvas, offset);
|
|
|
|
paintInlineChildren(context, offset);
|
|
|
|
if (_needsClipping) {
|
|
if (_overflowShader != null) {
|
|
context.canvas.translate(offset.dx, offset.dy);
|
|
final Paint paint = Paint()
|
|
..blendMode = BlendMode.modulate
|
|
..shader = _overflowShader;
|
|
context.canvas.drawRect(Offset.zero & size, paint);
|
|
}
|
|
context.canvas.restore();
|
|
}
|
|
|
|
if (didOverflowHeight) {
|
|
_morePainter?.paint(
|
|
context.canvas,
|
|
offset + Offset(0, _textPainter.height),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Returns the offset at which to paint the caret.
|
|
///
|
|
/// Valid only after [layout].
|
|
Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) {
|
|
assert(!debugNeedsLayout);
|
|
_layoutTextWithConstraints(constraints);
|
|
return _textPainter.getOffsetForCaret(position, caretPrototype);
|
|
}
|
|
|
|
/// {@macro flutter.painting.textPainter.getFullHeightForCaret}
|
|
///
|
|
/// Valid only after [layout].
|
|
double getFullHeightForCaret(TextPosition position) {
|
|
assert(!debugNeedsLayout);
|
|
_layoutTextWithConstraints(constraints);
|
|
return _textPainter.getFullHeightForCaret(position, Rect.zero);
|
|
}
|
|
|
|
/// Returns a list of rects that bound the given selection.
|
|
///
|
|
/// The [boxHeightStyle] and [boxWidthStyle] arguments may be used to select
|
|
/// the shape of the [TextBox]es. These properties default to
|
|
/// [ui.BoxHeightStyle.tight] and [ui.BoxWidthStyle.tight] respectively.
|
|
///
|
|
/// A given selection might have more than one rect if the [RenderParagraph]
|
|
/// contains multiple [InlineSpan]s or bidirectional text, because logically
|
|
/// contiguous text might not be visually contiguous.
|
|
///
|
|
/// Valid only after [layout].
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [TextPainter.getBoxesForSelection], the method in TextPainter to get
|
|
/// the equivalent boxes.
|
|
List<ui.TextBox> getBoxesForSelection(
|
|
TextSelection selection, {
|
|
ui.BoxHeightStyle boxHeightStyle = ui.BoxHeightStyle.tight,
|
|
ui.BoxWidthStyle boxWidthStyle = ui.BoxWidthStyle.tight,
|
|
}) {
|
|
assert(!debugNeedsLayout);
|
|
_layoutTextWithConstraints(constraints);
|
|
return _textPainter.getBoxesForSelection(
|
|
selection,
|
|
boxHeightStyle: boxHeightStyle,
|
|
boxWidthStyle: boxWidthStyle,
|
|
);
|
|
}
|
|
|
|
/// Returns the position within the text for the given pixel offset.
|
|
///
|
|
/// Valid only after [layout].
|
|
TextPosition getPositionForOffset(Offset offset) {
|
|
assert(!debugNeedsLayout);
|
|
_layoutTextWithConstraints(constraints);
|
|
return _textPainter.getPositionForOffset(offset);
|
|
}
|
|
|
|
/// Returns the text range of the word at the given offset. Characters not
|
|
/// part of a word, such as spaces, symbols, and punctuation, have word breaks
|
|
/// on both sides. In such cases, this method will return a text range that
|
|
/// contains the given text position.
|
|
///
|
|
/// Word boundaries are defined more precisely in Unicode Standard Annex #29
|
|
/// <http://www.unicode.org/reports/tr29/#Word_Boundaries>.
|
|
///
|
|
/// Valid only after [layout].
|
|
TextRange getWordBoundary(TextPosition position) {
|
|
assert(!debugNeedsLayout);
|
|
_layoutTextWithConstraints(constraints);
|
|
return _textPainter.getWordBoundary(position);
|
|
}
|
|
|
|
TextRange _getLineAtOffset(TextPosition position) =>
|
|
_textPainter.getLineBoundary(position);
|
|
|
|
TextPosition _getTextPositionAbove(TextPosition position) {
|
|
// -0.5 of preferredLineHeight points to the middle of the line above.
|
|
final double preferredLineHeight = _textPainter.preferredLineHeight;
|
|
final double verticalOffset = -0.5 * preferredLineHeight;
|
|
return _getTextPositionVertical(position, verticalOffset);
|
|
}
|
|
|
|
TextPosition _getTextPositionBelow(TextPosition position) {
|
|
// 1.5 of preferredLineHeight points to the middle of the line below.
|
|
final double preferredLineHeight = _textPainter.preferredLineHeight;
|
|
final double verticalOffset = 1.5 * preferredLineHeight;
|
|
return _getTextPositionVertical(position, verticalOffset);
|
|
}
|
|
|
|
TextPosition _getTextPositionVertical(
|
|
TextPosition position,
|
|
double verticalOffset,
|
|
) {
|
|
final Offset caretOffset = _textPainter.getOffsetForCaret(
|
|
position,
|
|
Rect.zero,
|
|
);
|
|
final Offset caretOffsetTranslated = caretOffset.translate(
|
|
0.0,
|
|
verticalOffset,
|
|
);
|
|
return _textPainter.getPositionForOffset(caretOffsetTranslated);
|
|
}
|
|
|
|
/// Returns the size of the text as laid out.
|
|
///
|
|
/// This can differ from [size] if the text overflowed or if the [constraints]
|
|
/// provided by the parent [RenderObject] forced the layout to be bigger than
|
|
/// necessary for the given [text].
|
|
///
|
|
/// This returns the [TextPainter.size] of the underlying [TextPainter].
|
|
///
|
|
/// Valid only after [layout].
|
|
Size get textSize {
|
|
assert(!debugNeedsLayout);
|
|
return _textPainter.size;
|
|
}
|
|
|
|
/// Whether the text was truncated or ellipsized as laid out.
|
|
///
|
|
/// This returns the [TextPainter.didExceedMaxLines] of the underlying [TextPainter].
|
|
///
|
|
/// Valid only after [layout].
|
|
bool get didExceedMaxLines {
|
|
assert(!debugNeedsLayout);
|
|
return _textPainter.didExceedMaxLines;
|
|
}
|
|
|
|
/// Collected during [describeSemanticsConfiguration], used by
|
|
/// [assembleSemanticsNode].
|
|
List<InlineSpanSemanticsInformation>? _semanticsInfo;
|
|
|
|
@override
|
|
void describeSemanticsConfiguration(SemanticsConfiguration config) {
|
|
super.describeSemanticsConfiguration(config);
|
|
_semanticsInfo = text.getSemanticsInformation();
|
|
bool needsAssembleSemanticsNode = false;
|
|
bool needsChildConfigurationsDelegate = false;
|
|
for (final InlineSpanSemanticsInformation info in _semanticsInfo!) {
|
|
if (info.recognizer != null || info.semanticsIdentifier != null) {
|
|
needsAssembleSemanticsNode = true;
|
|
break;
|
|
}
|
|
needsChildConfigurationsDelegate =
|
|
needsChildConfigurationsDelegate || info.isPlaceholder;
|
|
}
|
|
|
|
if (needsAssembleSemanticsNode) {
|
|
config.explicitChildNodes = true;
|
|
config.isSemanticBoundary = true;
|
|
} else if (needsChildConfigurationsDelegate) {
|
|
config.childConfigurationsDelegate =
|
|
_childSemanticsConfigurationsDelegate;
|
|
} else {
|
|
if (_cachedAttributedLabels == null) {
|
|
final StringBuffer buffer = StringBuffer();
|
|
int offset = 0;
|
|
final List<StringAttribute> attributes = <StringAttribute>[];
|
|
for (final InlineSpanSemanticsInformation info in _semanticsInfo!) {
|
|
final String label = info.semanticsLabel ?? info.text;
|
|
for (final StringAttribute infoAttribute in info.stringAttributes) {
|
|
final TextRange originalRange = infoAttribute.range;
|
|
attributes.add(
|
|
infoAttribute.copy(
|
|
range: TextRange(
|
|
start: offset + originalRange.start,
|
|
end: offset + originalRange.end,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
buffer.write(label);
|
|
offset += label.length;
|
|
}
|
|
_cachedAttributedLabels = <AttributedString>[
|
|
AttributedString(buffer.toString(), attributes: attributes),
|
|
];
|
|
}
|
|
config.attributedLabel = _cachedAttributedLabels![0];
|
|
config.textDirection = textDirection;
|
|
}
|
|
}
|
|
|
|
ChildSemanticsConfigurationsResult _childSemanticsConfigurationsDelegate(
|
|
List<SemanticsConfiguration> childConfigs,
|
|
) {
|
|
final ChildSemanticsConfigurationsResultBuilder builder =
|
|
ChildSemanticsConfigurationsResultBuilder();
|
|
int placeholderIndex = 0;
|
|
int childConfigsIndex = 0;
|
|
int attributedLabelCacheIndex = 0;
|
|
InlineSpanSemanticsInformation? seenTextInfo;
|
|
_cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!);
|
|
for (final InlineSpanSemanticsInformation info
|
|
in _cachedCombinedSemanticsInfos!) {
|
|
if (info.isPlaceholder) {
|
|
if (seenTextInfo != null) {
|
|
builder.markAsMergeUp(
|
|
_createSemanticsConfigForTextInfo(
|
|
seenTextInfo,
|
|
attributedLabelCacheIndex,
|
|
),
|
|
);
|
|
attributedLabelCacheIndex += 1;
|
|
}
|
|
// Mark every childConfig belongs to this placeholder to merge up group.
|
|
while (childConfigsIndex < childConfigs.length &&
|
|
childConfigs[childConfigsIndex].tagsChildrenWith(
|
|
PlaceholderSpanIndexSemanticsTag(placeholderIndex),
|
|
)) {
|
|
builder.markAsMergeUp(childConfigs[childConfigsIndex]);
|
|
childConfigsIndex += 1;
|
|
}
|
|
placeholderIndex += 1;
|
|
} else {
|
|
seenTextInfo = info;
|
|
}
|
|
}
|
|
|
|
// Handle plain text info at the end.
|
|
if (seenTextInfo != null) {
|
|
builder.markAsMergeUp(
|
|
_createSemanticsConfigForTextInfo(
|
|
seenTextInfo,
|
|
attributedLabelCacheIndex,
|
|
),
|
|
);
|
|
}
|
|
return builder.build();
|
|
}
|
|
|
|
SemanticsConfiguration _createSemanticsConfigForTextInfo(
|
|
InlineSpanSemanticsInformation textInfo,
|
|
int cacheIndex,
|
|
) {
|
|
assert(!textInfo.requiresOwnNode);
|
|
final List<AttributedString> cachedStrings = _cachedAttributedLabels ??=
|
|
<AttributedString>[];
|
|
assert(cacheIndex <= cachedStrings.length);
|
|
final bool hasCache = cacheIndex < cachedStrings.length;
|
|
|
|
late AttributedString attributedLabel;
|
|
if (hasCache) {
|
|
attributedLabel = cachedStrings[cacheIndex];
|
|
} else {
|
|
assert(cachedStrings.length == cacheIndex);
|
|
attributedLabel = AttributedString(
|
|
textInfo.semanticsLabel ?? textInfo.text,
|
|
attributes: textInfo.stringAttributes,
|
|
);
|
|
cachedStrings.add(attributedLabel);
|
|
}
|
|
return SemanticsConfiguration()
|
|
..textDirection = textDirection
|
|
..attributedLabel = attributedLabel;
|
|
}
|
|
|
|
// Caches [SemanticsNode]s created during [assembleSemanticsNode] so they
|
|
// can be re-used when [assembleSemanticsNode] is called again. This ensures
|
|
// stable ids for the [SemanticsNode]s of [TextSpan]s across
|
|
// [assembleSemanticsNode] invocations.
|
|
LinkedHashMap<Key, SemanticsNode>? _cachedChildNodes;
|
|
|
|
@override
|
|
void assembleSemanticsNode(
|
|
SemanticsNode node,
|
|
SemanticsConfiguration config,
|
|
Iterable<SemanticsNode> children,
|
|
) {
|
|
assert(_semanticsInfo != null && _semanticsInfo!.isNotEmpty);
|
|
final List<SemanticsNode> newChildren = <SemanticsNode>[];
|
|
TextDirection currentDirection = textDirection;
|
|
Rect currentRect;
|
|
double ordinal = 0.0;
|
|
int start = 0;
|
|
int placeholderIndex = 0;
|
|
int childIndex = 0;
|
|
RenderBox? child = firstChild;
|
|
final LinkedHashMap<Key, SemanticsNode> newChildCache =
|
|
LinkedHashMap<Key, SemanticsNode>();
|
|
_cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!);
|
|
for (final InlineSpanSemanticsInformation info
|
|
in _cachedCombinedSemanticsInfos!) {
|
|
final TextSelection selection = TextSelection(
|
|
baseOffset: start,
|
|
extentOffset: start + info.text.length,
|
|
);
|
|
start += info.text.length;
|
|
|
|
if (info.isPlaceholder) {
|
|
// A placeholder span may have 0 to multiple semantics nodes, we need
|
|
// to annotate all of the semantics nodes belong to this span.
|
|
while (children.length > childIndex &&
|
|
children
|
|
.elementAt(childIndex)
|
|
.isTagged(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) {
|
|
final SemanticsNode childNode = children.elementAt(childIndex);
|
|
final TextParentData parentData =
|
|
child!.parentData! as TextParentData;
|
|
// parentData.scale may be null if the render object is truncated.
|
|
if (parentData.offset != null) {
|
|
newChildren.add(childNode);
|
|
}
|
|
childIndex += 1;
|
|
}
|
|
child = childAfter(child!);
|
|
placeholderIndex += 1;
|
|
} else {
|
|
final TextDirection initialDirection = currentDirection;
|
|
final List<ui.TextBox> rects = getBoxesForSelection(selection);
|
|
if (rects.isEmpty) {
|
|
continue;
|
|
}
|
|
Rect rect = rects.first.toRect();
|
|
currentDirection = rects.first.direction;
|
|
for (final ui.TextBox textBox in rects.skip(1)) {
|
|
rect = rect.expandToInclude(textBox.toRect());
|
|
currentDirection = textBox.direction;
|
|
}
|
|
// Any of the text boxes may have had infinite dimensions.
|
|
// We shouldn't pass infinite dimensions up to the bridges.
|
|
rect = Rect.fromLTWH(
|
|
math.max(0.0, rect.left),
|
|
math.max(0.0, rect.top),
|
|
math.min(rect.width, constraints.maxWidth),
|
|
math.min(rect.height, constraints.maxHeight),
|
|
);
|
|
// round the current rectangle to make this API testable and add some
|
|
// padding so that the accessibility rects do not overlap with the text.
|
|
currentRect = Rect.fromLTRB(
|
|
rect.left.floorToDouble() - 4.0,
|
|
rect.top.floorToDouble() - 4.0,
|
|
rect.right.ceilToDouble() + 4.0,
|
|
rect.bottom.ceilToDouble() + 4.0,
|
|
);
|
|
final SemanticsConfiguration configuration = SemanticsConfiguration()
|
|
..sortKey = OrdinalSortKey(ordinal++)
|
|
..textDirection = initialDirection
|
|
..identifier = info.semanticsIdentifier ?? ''
|
|
..attributedLabel = AttributedString(
|
|
info.semanticsLabel ?? info.text,
|
|
attributes: info.stringAttributes,
|
|
);
|
|
switch (info.recognizer) {
|
|
case TapGestureRecognizer(onTap: final VoidCallback? handler):
|
|
case DoubleTapGestureRecognizer(
|
|
onDoubleTap: final VoidCallback? handler,
|
|
):
|
|
if (handler != null) {
|
|
configuration.onTap = handler;
|
|
configuration.isLink = true;
|
|
}
|
|
case LongPressGestureRecognizer(
|
|
onLongPress: final GestureLongPressCallback? onLongPress,
|
|
):
|
|
if (onLongPress != null) {
|
|
configuration.onLongPress = onLongPress;
|
|
}
|
|
case null:
|
|
break;
|
|
default:
|
|
assert(false, '${info.recognizer.runtimeType} is not supported.');
|
|
}
|
|
if (node.parentPaintClipRect != null) {
|
|
final Rect paintRect = node.parentPaintClipRect!.intersect(
|
|
currentRect,
|
|
);
|
|
configuration.isHidden = paintRect.isEmpty && !currentRect.isEmpty;
|
|
}
|
|
final SemanticsNode newChild;
|
|
if (_cachedChildNodes?.isNotEmpty ?? false) {
|
|
newChild = _cachedChildNodes!.remove(_cachedChildNodes!.keys.first)!;
|
|
} else {
|
|
final UniqueKey key = UniqueKey();
|
|
newChild = SemanticsNode(
|
|
key: key,
|
|
showOnScreen: _createShowOnScreenFor(key),
|
|
);
|
|
}
|
|
newChild
|
|
..updateWith(config: configuration)
|
|
..rect = currentRect;
|
|
newChildCache[newChild.key!] = newChild;
|
|
newChildren.add(newChild);
|
|
}
|
|
}
|
|
// Makes sure we annotated all of the semantics children.
|
|
assert(childIndex == children.length);
|
|
assert(child == null);
|
|
|
|
_cachedChildNodes = newChildCache;
|
|
node.updateWith(config: config, childrenInInversePaintOrder: newChildren);
|
|
}
|
|
|
|
VoidCallback? _createShowOnScreenFor(Key key) {
|
|
return () {
|
|
final SemanticsNode node = _cachedChildNodes![key]!;
|
|
showOnScreen(descendant: this, rect: node.rect);
|
|
};
|
|
}
|
|
|
|
@override
|
|
void clearSemantics() {
|
|
super.clearSemantics();
|
|
_cachedChildNodes = null;
|
|
}
|
|
|
|
@override
|
|
List<DiagnosticsNode> debugDescribeChildren() {
|
|
return <DiagnosticsNode>[
|
|
text.toDiagnosticsNode(
|
|
name: 'text',
|
|
style: DiagnosticsTreeStyle.transition,
|
|
),
|
|
];
|
|
}
|
|
|
|
@override
|
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
|
super.debugFillProperties(properties);
|
|
properties.add(EnumProperty<TextAlign>('textAlign', textAlign));
|
|
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
|
properties.add(
|
|
FlagProperty(
|
|
'softWrap',
|
|
value: softWrap,
|
|
ifTrue: 'wrapping at box width',
|
|
ifFalse: 'no wrapping except at line break characters',
|
|
showName: true,
|
|
),
|
|
);
|
|
properties.add(EnumProperty<TextOverflow>('overflow', overflow));
|
|
properties.add(
|
|
DiagnosticsProperty<TextScaler>(
|
|
'textScaler',
|
|
textScaler,
|
|
defaultValue: TextScaler.noScaling,
|
|
),
|
|
);
|
|
properties.add(
|
|
DiagnosticsProperty<Locale>('locale', locale, defaultValue: null),
|
|
);
|
|
properties.add(IntProperty('maxLines', maxLines, ifNull: 'unlimited'));
|
|
}
|
|
}
|
|
|
|
/// A continuous, selectable piece of paragraph.
|
|
///
|
|
/// Since the selections in [PlaceholderSpan] are handled independently in its
|
|
/// subtree, a selection in [RenderParagraph] can't continue across a
|
|
/// [PlaceholderSpan]. The [RenderParagraph] splits itself on [PlaceholderSpan]
|
|
/// to create multiple `_SelectableFragment`s so that they can be selected
|
|
/// separately.
|
|
class _SelectableFragment
|
|
with Selectable, Diagnosticable, ChangeNotifier
|
|
implements TextLayoutMetrics {
|
|
_SelectableFragment({
|
|
required this.paragraph,
|
|
required this.fullText,
|
|
required this.range,
|
|
}) : assert(range.isValid && !range.isCollapsed && range.isNormalized) {
|
|
if (kFlutterMemoryAllocationsEnabled) {
|
|
ChangeNotifier.maybeDispatchObjectCreation(this);
|
|
}
|
|
_selectionGeometry = _getSelectionGeometry();
|
|
}
|
|
|
|
final TextRange range;
|
|
final RenderParagraph paragraph;
|
|
final String fullText;
|
|
|
|
TextPosition? _textSelectionStart;
|
|
TextPosition? _textSelectionEnd;
|
|
|
|
bool _selectableContainsOriginTextBoundary = false;
|
|
|
|
LayerLink? _startHandleLayerLink;
|
|
LayerLink? _endHandleLayerLink;
|
|
|
|
@override
|
|
SelectionGeometry get value => _selectionGeometry;
|
|
late SelectionGeometry _selectionGeometry;
|
|
void _updateSelectionGeometry() {
|
|
final SelectionGeometry newValue = _getSelectionGeometry();
|
|
|
|
if (_selectionGeometry == newValue) {
|
|
return;
|
|
}
|
|
_selectionGeometry = newValue;
|
|
notifyListeners();
|
|
}
|
|
|
|
SelectionGeometry _getSelectionGeometry() {
|
|
if (_textSelectionStart == null || _textSelectionEnd == null) {
|
|
return const SelectionGeometry(
|
|
status: SelectionStatus.none,
|
|
hasContent: true,
|
|
);
|
|
}
|
|
|
|
final int selectionStart = _textSelectionStart!.offset;
|
|
final int selectionEnd = _textSelectionEnd!.offset;
|
|
final bool isReversed = selectionStart > selectionEnd;
|
|
final Offset startOffsetInParagraphCoordinates = paragraph
|
|
._getOffsetForPosition(
|
|
TextPosition(offset: selectionStart),
|
|
);
|
|
final Offset endOffsetInParagraphCoordinates =
|
|
selectionStart == selectionEnd
|
|
? startOffsetInParagraphCoordinates
|
|
: paragraph._getOffsetForPosition(TextPosition(offset: selectionEnd));
|
|
final bool flipHandles =
|
|
isReversed != (TextDirection.rtl == paragraph.textDirection);
|
|
final TextSelection selection = TextSelection(
|
|
baseOffset: selectionStart,
|
|
extentOffset: selectionEnd,
|
|
);
|
|
final List<Rect> selectionRects = <Rect>[];
|
|
for (final TextBox textBox in paragraph.getBoxesForSelection(selection)) {
|
|
selectionRects.add(textBox.toRect());
|
|
}
|
|
final bool selectionCollapsed = selectionStart == selectionEnd;
|
|
final (
|
|
TextSelectionHandleType startSelectionHandleType,
|
|
TextSelectionHandleType endSelectionHandleType,
|
|
) = switch ((selectionCollapsed, flipHandles)) {
|
|
// Always prefer collapsed handle when selection is collapsed.
|
|
(true, _) => (
|
|
TextSelectionHandleType.collapsed,
|
|
TextSelectionHandleType.collapsed,
|
|
),
|
|
(false, true) => (
|
|
TextSelectionHandleType.right,
|
|
TextSelectionHandleType.left,
|
|
),
|
|
(false, false) => (
|
|
TextSelectionHandleType.left,
|
|
TextSelectionHandleType.right,
|
|
),
|
|
};
|
|
return SelectionGeometry(
|
|
startSelectionPoint: SelectionPoint(
|
|
localPosition: startOffsetInParagraphCoordinates,
|
|
lineHeight: paragraph._textPainter.preferredLineHeight,
|
|
handleType: startSelectionHandleType,
|
|
),
|
|
endSelectionPoint: SelectionPoint(
|
|
localPosition: endOffsetInParagraphCoordinates,
|
|
lineHeight: paragraph._textPainter.preferredLineHeight,
|
|
handleType: endSelectionHandleType,
|
|
),
|
|
selectionRects: selectionRects,
|
|
status: selectionCollapsed
|
|
? SelectionStatus.collapsed
|
|
: SelectionStatus.uncollapsed,
|
|
hasContent: true,
|
|
);
|
|
}
|
|
|
|
@override
|
|
SelectionResult dispatchSelectionEvent(SelectionEvent event) {
|
|
late final SelectionResult result;
|
|
final TextPosition? existingSelectionStart = _textSelectionStart;
|
|
final TextPosition? existingSelectionEnd = _textSelectionEnd;
|
|
switch (event.type) {
|
|
case SelectionEventType.startEdgeUpdate:
|
|
case SelectionEventType.endEdgeUpdate:
|
|
final SelectionEdgeUpdateEvent edgeUpdate =
|
|
event as SelectionEdgeUpdateEvent;
|
|
final TextGranularity granularity = event.granularity;
|
|
|
|
switch (granularity) {
|
|
case TextGranularity.character:
|
|
result = _updateSelectionEdge(
|
|
edgeUpdate.globalPosition,
|
|
isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate,
|
|
);
|
|
case TextGranularity.word:
|
|
result = _updateSelectionEdgeByTextBoundary(
|
|
edgeUpdate.globalPosition,
|
|
isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate,
|
|
getTextBoundary: _getWordBoundaryAtPosition,
|
|
);
|
|
case TextGranularity.paragraph:
|
|
result = _updateSelectionEdgeByMultiSelectableTextBoundary(
|
|
edgeUpdate.globalPosition,
|
|
isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate,
|
|
getTextBoundary: _getParagraphBoundaryAtPosition,
|
|
getClampedTextBoundary: _getClampedParagraphBoundaryAtPosition,
|
|
);
|
|
case TextGranularity.document:
|
|
case TextGranularity.line:
|
|
assert(
|
|
false,
|
|
'Moving the selection edge by line or document is not supported.',
|
|
);
|
|
}
|
|
case SelectionEventType.clear:
|
|
result = _handleClearSelection();
|
|
case SelectionEventType.selectAll:
|
|
result = _handleSelectAll();
|
|
case SelectionEventType.selectWord:
|
|
final SelectWordSelectionEvent selectWord =
|
|
event as SelectWordSelectionEvent;
|
|
result = _handleSelectWord(selectWord.globalPosition);
|
|
case SelectionEventType.selectParagraph:
|
|
final SelectParagraphSelectionEvent selectParagraph =
|
|
event as SelectParagraphSelectionEvent;
|
|
if (selectParagraph.absorb) {
|
|
_handleSelectAll();
|
|
result = SelectionResult.next;
|
|
_selectableContainsOriginTextBoundary = true;
|
|
} else {
|
|
result = _handleSelectParagraph(selectParagraph.globalPosition);
|
|
}
|
|
case SelectionEventType.granularlyExtendSelection:
|
|
final GranularlyExtendSelectionEvent granularlyExtendSelection =
|
|
event as GranularlyExtendSelectionEvent;
|
|
result = _handleGranularlyExtendSelection(
|
|
granularlyExtendSelection.forward,
|
|
granularlyExtendSelection.isEnd,
|
|
granularlyExtendSelection.granularity,
|
|
);
|
|
case SelectionEventType.directionallyExtendSelection:
|
|
final DirectionallyExtendSelectionEvent directionallyExtendSelection =
|
|
event as DirectionallyExtendSelectionEvent;
|
|
result = _handleDirectionallyExtendSelection(
|
|
directionallyExtendSelection.dx,
|
|
directionallyExtendSelection.isEnd,
|
|
directionallyExtendSelection.direction,
|
|
);
|
|
}
|
|
|
|
if (existingSelectionStart != _textSelectionStart ||
|
|
existingSelectionEnd != _textSelectionEnd) {
|
|
_didChangeSelection();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
@override
|
|
SelectedContent? getSelectedContent() {
|
|
if (_textSelectionStart == null || _textSelectionEnd == null) {
|
|
return null;
|
|
}
|
|
final int start = math.min(
|
|
_textSelectionStart!.offset,
|
|
_textSelectionEnd!.offset,
|
|
);
|
|
final int end = math.max(
|
|
_textSelectionStart!.offset,
|
|
_textSelectionEnd!.offset,
|
|
);
|
|
return SelectedContent(plainText: fullText.substring(start, end));
|
|
}
|
|
|
|
@override
|
|
SelectedContentRange? getSelection() {
|
|
if (_textSelectionStart == null || _textSelectionEnd == null) {
|
|
return null;
|
|
}
|
|
return SelectedContentRange(
|
|
startOffset: _textSelectionStart!.offset,
|
|
endOffset: _textSelectionEnd!.offset,
|
|
);
|
|
}
|
|
|
|
void _didChangeSelection() {
|
|
paragraph.markNeedsPaint();
|
|
_updateSelectionGeometry();
|
|
}
|
|
|
|
TextPosition _updateSelectionStartEdgeByTextBoundary(
|
|
_TextBoundaryRecord? textBoundary,
|
|
_TextBoundaryAtPosition getTextBoundary,
|
|
TextPosition position,
|
|
TextPosition? existingSelectionStart,
|
|
TextPosition? existingSelectionEnd,
|
|
) {
|
|
TextPosition? targetPosition;
|
|
if (textBoundary != null) {
|
|
assert(
|
|
textBoundary.boundaryStart.offset >= range.start &&
|
|
textBoundary.boundaryEnd.offset <= range.end,
|
|
);
|
|
if (_selectableContainsOriginTextBoundary &&
|
|
existingSelectionStart != null &&
|
|
existingSelectionEnd != null) {
|
|
final bool isSamePosition =
|
|
position.offset == existingSelectionEnd.offset;
|
|
final bool isSelectionInverted =
|
|
existingSelectionStart.offset > existingSelectionEnd.offset;
|
|
final bool shouldSwapEdges =
|
|
!isSamePosition &&
|
|
(isSelectionInverted !=
|
|
(position.offset > existingSelectionEnd.offset));
|
|
if (shouldSwapEdges) {
|
|
if (position.offset < existingSelectionEnd.offset) {
|
|
targetPosition = textBoundary.boundaryStart;
|
|
} else {
|
|
targetPosition = textBoundary.boundaryEnd;
|
|
}
|
|
// When the selection is inverted by the new position it is necessary to
|
|
// swap the start edge (moving edge) with the end edge (static edge) to
|
|
// maintain the origin text boundary within the selection.
|
|
final _TextBoundaryRecord localTextBoundary = getTextBoundary(
|
|
existingSelectionEnd,
|
|
);
|
|
assert(
|
|
localTextBoundary.boundaryStart.offset >= range.start &&
|
|
localTextBoundary.boundaryEnd.offset <= range.end,
|
|
);
|
|
_setSelectionPosition(
|
|
existingSelectionEnd.offset ==
|
|
localTextBoundary.boundaryStart.offset
|
|
? localTextBoundary.boundaryEnd
|
|
: localTextBoundary.boundaryStart,
|
|
isEnd: true,
|
|
);
|
|
} else {
|
|
if (position.offset < existingSelectionEnd.offset) {
|
|
targetPosition = textBoundary.boundaryStart;
|
|
} else if (position.offset > existingSelectionEnd.offset) {
|
|
targetPosition = textBoundary.boundaryEnd;
|
|
} else {
|
|
// Keep the origin text boundary in bounds when position is at the static edge.
|
|
targetPosition = existingSelectionStart;
|
|
}
|
|
}
|
|
} else {
|
|
if (existingSelectionEnd != null) {
|
|
// If the end edge exists and the start edge is being moved, then the
|
|
// start edge is moved to encompass the entire text boundary at the new position.
|
|
if (position.offset < existingSelectionEnd.offset) {
|
|
targetPosition = textBoundary.boundaryStart;
|
|
} else {
|
|
targetPosition = textBoundary.boundaryEnd;
|
|
}
|
|
} else {
|
|
// Move the start edge to the closest text boundary.
|
|
targetPosition = _closestTextBoundary(textBoundary, position);
|
|
}
|
|
}
|
|
} else {
|
|
// The position is not contained within the current rect. The targetPosition
|
|
// will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset]
|
|
// for a more in depth explanation on this adjustment.
|
|
if (_selectableContainsOriginTextBoundary &&
|
|
existingSelectionStart != null &&
|
|
existingSelectionEnd != null) {
|
|
// When the selection is inverted by the new position it is necessary to
|
|
// swap the start edge (moving edge) with the end edge (static edge) to
|
|
// maintain the origin text boundary within the selection.
|
|
final bool isSamePosition =
|
|
position.offset == existingSelectionEnd.offset;
|
|
final bool isSelectionInverted =
|
|
existingSelectionStart.offset > existingSelectionEnd.offset;
|
|
final bool shouldSwapEdges =
|
|
!isSamePosition &&
|
|
(isSelectionInverted !=
|
|
(position.offset > existingSelectionEnd.offset));
|
|
|
|
if (shouldSwapEdges) {
|
|
final _TextBoundaryRecord localTextBoundary = getTextBoundary(
|
|
existingSelectionEnd,
|
|
);
|
|
assert(
|
|
localTextBoundary.boundaryStart.offset >= range.start &&
|
|
localTextBoundary.boundaryEnd.offset <= range.end,
|
|
);
|
|
_setSelectionPosition(
|
|
isSelectionInverted
|
|
? localTextBoundary.boundaryEnd
|
|
: localTextBoundary.boundaryStart,
|
|
isEnd: true,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return targetPosition ?? position;
|
|
}
|
|
|
|
TextPosition _updateSelectionEndEdgeByTextBoundary(
|
|
_TextBoundaryRecord? textBoundary,
|
|
_TextBoundaryAtPosition getTextBoundary,
|
|
TextPosition position,
|
|
TextPosition? existingSelectionStart,
|
|
TextPosition? existingSelectionEnd,
|
|
) {
|
|
TextPosition? targetPosition;
|
|
if (textBoundary != null) {
|
|
assert(
|
|
textBoundary.boundaryStart.offset >= range.start &&
|
|
textBoundary.boundaryEnd.offset <= range.end,
|
|
);
|
|
if (_selectableContainsOriginTextBoundary &&
|
|
existingSelectionStart != null &&
|
|
existingSelectionEnd != null) {
|
|
final bool isSamePosition =
|
|
position.offset == existingSelectionStart.offset;
|
|
final bool isSelectionInverted =
|
|
existingSelectionStart.offset > existingSelectionEnd.offset;
|
|
final bool shouldSwapEdges =
|
|
!isSamePosition &&
|
|
(isSelectionInverted !=
|
|
(position.offset < existingSelectionStart.offset));
|
|
if (shouldSwapEdges) {
|
|
if (position.offset < existingSelectionStart.offset) {
|
|
targetPosition = textBoundary.boundaryStart;
|
|
} else {
|
|
targetPosition = textBoundary.boundaryEnd;
|
|
}
|
|
// When the selection is inverted by the new position it is necessary to
|
|
// swap the end edge (moving edge) with the start edge (static edge) to
|
|
// maintain the origin text boundary within the selection.
|
|
final _TextBoundaryRecord localTextBoundary = getTextBoundary(
|
|
existingSelectionStart,
|
|
);
|
|
assert(
|
|
localTextBoundary.boundaryStart.offset >= range.start &&
|
|
localTextBoundary.boundaryEnd.offset <= range.end,
|
|
);
|
|
_setSelectionPosition(
|
|
existingSelectionStart.offset ==
|
|
localTextBoundary.boundaryStart.offset
|
|
? localTextBoundary.boundaryEnd
|
|
: localTextBoundary.boundaryStart,
|
|
isEnd: false,
|
|
);
|
|
} else {
|
|
if (position.offset < existingSelectionStart.offset) {
|
|
targetPosition = textBoundary.boundaryStart;
|
|
} else if (position.offset > existingSelectionStart.offset) {
|
|
targetPosition = textBoundary.boundaryEnd;
|
|
} else {
|
|
// Keep the origin text boundary in bounds when position is at the static edge.
|
|
targetPosition = existingSelectionEnd;
|
|
}
|
|
}
|
|
} else {
|
|
if (existingSelectionStart != null) {
|
|
// If the start edge exists and the end edge is being moved, then the
|
|
// end edge is moved to encompass the entire text boundary at the new position.
|
|
if (position.offset < existingSelectionStart.offset) {
|
|
targetPosition = textBoundary.boundaryStart;
|
|
} else {
|
|
targetPosition = textBoundary.boundaryEnd;
|
|
}
|
|
} else {
|
|
// Move the end edge to the closest text boundary.
|
|
targetPosition = _closestTextBoundary(textBoundary, position);
|
|
}
|
|
}
|
|
} else {
|
|
// The position is not contained within the current rect. The targetPosition
|
|
// will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset]
|
|
// for a more in depth explanation on this adjustment.
|
|
if (_selectableContainsOriginTextBoundary &&
|
|
existingSelectionStart != null &&
|
|
existingSelectionEnd != null) {
|
|
// When the selection is inverted by the new position it is necessary to
|
|
// swap the end edge (moving edge) with the start edge (static edge) to
|
|
// maintain the origin text boundary within the selection.
|
|
final bool isSamePosition =
|
|
position.offset == existingSelectionStart.offset;
|
|
final bool isSelectionInverted =
|
|
existingSelectionStart.offset > existingSelectionEnd.offset;
|
|
final bool shouldSwapEdges =
|
|
isSelectionInverted !=
|
|
(position.offset < existingSelectionStart.offset) ||
|
|
isSamePosition;
|
|
if (shouldSwapEdges) {
|
|
final _TextBoundaryRecord localTextBoundary = getTextBoundary(
|
|
existingSelectionStart,
|
|
);
|
|
assert(
|
|
localTextBoundary.boundaryStart.offset >= range.start &&
|
|
localTextBoundary.boundaryEnd.offset <= range.end,
|
|
);
|
|
_setSelectionPosition(
|
|
isSelectionInverted
|
|
? localTextBoundary.boundaryStart
|
|
: localTextBoundary.boundaryEnd,
|
|
isEnd: false,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return targetPosition ?? position;
|
|
}
|
|
|
|
SelectionResult _updateSelectionEdgeByTextBoundary(
|
|
Offset globalPosition, {
|
|
required bool isEnd,
|
|
required _TextBoundaryAtPosition getTextBoundary,
|
|
}) {
|
|
// When the start/end edges are swapped, i.e. the start is after the end, and
|
|
// the scrollable synthesizes an event for the opposite edge, this will potentially
|
|
// move the opposite edge outside of the origin text boundary and we are unable to recover.
|
|
final TextPosition? existingSelectionStart = _textSelectionStart;
|
|
final TextPosition? existingSelectionEnd = _textSelectionEnd;
|
|
|
|
_setSelectionPosition(null, isEnd: isEnd);
|
|
final Matrix4 transform = paragraph.getTransformTo(null);
|
|
transform.invert();
|
|
final Offset localPosition = MatrixUtils.transformPoint(
|
|
transform,
|
|
globalPosition,
|
|
);
|
|
if (_rect.isEmpty) {
|
|
return SelectionUtils.getResultBasedOnRect(_rect, localPosition);
|
|
}
|
|
final Offset adjustedOffset = SelectionUtils.adjustDragOffset(
|
|
_rect,
|
|
localPosition,
|
|
direction: paragraph.textDirection,
|
|
);
|
|
|
|
final TextPosition position = paragraph.getPositionForOffset(
|
|
adjustedOffset,
|
|
);
|
|
// Check if the original local position is within the rect, if it is not then
|
|
// we do not need to look up the text boundary for that position. This is to
|
|
// maintain a selectables selection collapsed at 0 when the local position is
|
|
// not located inside its rect.
|
|
_TextBoundaryRecord? textBoundary = _rect.contains(localPosition)
|
|
? getTextBoundary(position)
|
|
: null;
|
|
if (textBoundary != null &&
|
|
(textBoundary.boundaryStart.offset < range.start &&
|
|
textBoundary.boundaryEnd.offset <= range.start ||
|
|
textBoundary.boundaryStart.offset >= range.end &&
|
|
textBoundary.boundaryEnd.offset > range.end)) {
|
|
// When the position is located at a placeholder inside of the text, then we may compute
|
|
// a text boundary that does not belong to the current selectable fragment. In this case
|
|
// we should invalidate the text boundary so that it is not taken into account when
|
|
// computing the target position.
|
|
textBoundary = null;
|
|
}
|
|
final TextPosition targetPosition = _clampTextPosition(
|
|
isEnd
|
|
? _updateSelectionEndEdgeByTextBoundary(
|
|
textBoundary,
|
|
getTextBoundary,
|
|
position,
|
|
existingSelectionStart,
|
|
existingSelectionEnd,
|
|
)
|
|
: _updateSelectionStartEdgeByTextBoundary(
|
|
textBoundary,
|
|
getTextBoundary,
|
|
position,
|
|
existingSelectionStart,
|
|
existingSelectionEnd,
|
|
),
|
|
);
|
|
|
|
_setSelectionPosition(targetPosition, isEnd: isEnd);
|
|
if (targetPosition.offset == range.end) {
|
|
return SelectionResult.next;
|
|
}
|
|
|
|
if (targetPosition.offset == range.start) {
|
|
return SelectionResult.previous;
|
|
}
|
|
// TODO(chunhtai): The geometry information should not be used to determine
|
|
// selection result. This is a workaround to RenderParagraph, where it does
|
|
// not have a way to get accurate text length if its text is truncated due to
|
|
// layout constraint.
|
|
return SelectionUtils.getResultBasedOnRect(_rect, localPosition);
|
|
}
|
|
|
|
SelectionResult _updateSelectionEdge(
|
|
Offset globalPosition, {
|
|
required bool isEnd,
|
|
}) {
|
|
_setSelectionPosition(null, isEnd: isEnd);
|
|
final Matrix4 transform = paragraph.getTransformTo(null);
|
|
transform.invert();
|
|
final Offset localPosition = MatrixUtils.transformPoint(
|
|
transform,
|
|
globalPosition,
|
|
);
|
|
if (_rect.isEmpty) {
|
|
return SelectionUtils.getResultBasedOnRect(_rect, localPosition);
|
|
}
|
|
final Offset adjustedOffset = SelectionUtils.adjustDragOffset(
|
|
_rect,
|
|
localPosition,
|
|
direction: paragraph.textDirection,
|
|
);
|
|
|
|
final TextPosition position = _clampTextPosition(
|
|
paragraph.getPositionForOffset(adjustedOffset),
|
|
);
|
|
_setSelectionPosition(position, isEnd: isEnd);
|
|
if (position.offset == range.end) {
|
|
return SelectionResult.next;
|
|
}
|
|
if (position.offset == range.start) {
|
|
return SelectionResult.previous;
|
|
}
|
|
// TODO(chunhtai): The geometry information should not be used to determine
|
|
// selection result. This is a workaround to RenderParagraph, where it does
|
|
// not have a way to get accurate text length if its text is truncated due to
|
|
// layout constraint.
|
|
return SelectionUtils.getResultBasedOnRect(_rect, localPosition);
|
|
}
|
|
|
|
// This method handles updating the start edge by a text boundary that may
|
|
// not be contained within this selectable fragment. It is possible
|
|
// that a boundary spans multiple selectable fragments when the text contains
|
|
// [WidgetSpan]s.
|
|
//
|
|
// This method differs from [_updateSelectionStartEdgeByTextBoundary] in that
|
|
// to pivot offset used to swap selection edges and maintain the origin
|
|
// text boundary selected may be located outside of this selectable fragment.
|
|
//
|
|
// See [_updateSelectionEndEdgeByMultiSelectableTextBoundary] for the method
|
|
// that handles updating the end edge.
|
|
SelectionResult? _updateSelectionStartEdgeByMultiSelectableTextBoundary(
|
|
_TextBoundaryAtPositionInText getTextBoundary,
|
|
bool paragraphContainsPosition,
|
|
TextPosition position,
|
|
TextPosition? existingSelectionStart,
|
|
TextPosition? existingSelectionEnd,
|
|
) {
|
|
const bool isEnd = false;
|
|
if (_selectableContainsOriginTextBoundary &&
|
|
existingSelectionStart != null &&
|
|
existingSelectionEnd != null) {
|
|
// If this selectable contains the origin boundary, maintain the existing
|
|
// selection.
|
|
final bool forwardSelection =
|
|
existingSelectionEnd.offset >= existingSelectionStart.offset;
|
|
if (paragraphContainsPosition) {
|
|
// When the position is within the root paragraph, swap the start and end
|
|
// edges when the selection is inverted.
|
|
final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(
|
|
position,
|
|
fullText,
|
|
);
|
|
// To accurately retrieve the origin text boundary when the selection
|
|
// is forward, use existingSelectionEnd.offset - 1. This is necessary
|
|
// because in a forwards selection, existingSelectionEnd marks the end
|
|
// of the origin text boundary. Using the unmodified offset incorrectly
|
|
// targets the subsequent text boundary.
|
|
final _TextBoundaryRecord originTextBoundary = getTextBoundary(
|
|
forwardSelection
|
|
? TextPosition(
|
|
offset: existingSelectionEnd.offset - 1,
|
|
affinity: existingSelectionEnd.affinity,
|
|
)
|
|
: existingSelectionEnd,
|
|
fullText,
|
|
);
|
|
final TextPosition targetPosition;
|
|
final int pivotOffset = forwardSelection
|
|
? originTextBoundary.boundaryEnd.offset
|
|
: originTextBoundary.boundaryStart.offset;
|
|
final bool shouldSwapEdges =
|
|
!forwardSelection != (position.offset > pivotOffset);
|
|
if (position.offset < pivotOffset) {
|
|
targetPosition = boundaryAtPosition.boundaryStart;
|
|
} else if (position.offset > pivotOffset) {
|
|
targetPosition = boundaryAtPosition.boundaryEnd;
|
|
} else {
|
|
// Keep the origin text boundary in bounds when position is at the static edge.
|
|
targetPosition = forwardSelection
|
|
? existingSelectionStart
|
|
: existingSelectionEnd;
|
|
}
|
|
if (shouldSwapEdges) {
|
|
_setSelectionPosition(
|
|
_clampTextPosition(
|
|
forwardSelection
|
|
? originTextBoundary.boundaryStart
|
|
: originTextBoundary.boundaryEnd,
|
|
),
|
|
isEnd: true,
|
|
);
|
|
}
|
|
_setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd);
|
|
final bool finalSelectionIsForward =
|
|
_textSelectionEnd!.offset >= _textSelectionStart!.offset;
|
|
if (boundaryAtPosition.boundaryStart.offset > range.end &&
|
|
boundaryAtPosition.boundaryEnd.offset > range.end) {
|
|
return SelectionResult.next;
|
|
}
|
|
if (boundaryAtPosition.boundaryStart.offset < range.start &&
|
|
boundaryAtPosition.boundaryEnd.offset < range.start) {
|
|
return SelectionResult.previous;
|
|
}
|
|
if (finalSelectionIsForward) {
|
|
if (boundaryAtPosition.boundaryStart.offset >=
|
|
originTextBoundary.boundaryStart.offset) {
|
|
return SelectionResult.end;
|
|
}
|
|
if (boundaryAtPosition.boundaryStart.offset <
|
|
originTextBoundary.boundaryStart.offset) {
|
|
return SelectionResult.previous;
|
|
}
|
|
} else {
|
|
if (boundaryAtPosition.boundaryEnd.offset <=
|
|
originTextBoundary.boundaryEnd.offset) {
|
|
return SelectionResult.end;
|
|
}
|
|
if (boundaryAtPosition.boundaryEnd.offset >
|
|
originTextBoundary.boundaryEnd.offset) {
|
|
return SelectionResult.next;
|
|
}
|
|
}
|
|
} else {
|
|
// When the drag position is not contained within the root paragraph,
|
|
// swap the edges when the selection changes direction.
|
|
final TextPosition clampedPosition = _clampTextPosition(position);
|
|
// To accurately retrieve the origin text boundary when the selection
|
|
// is forward, use existingSelectionEnd.offset - 1. This is necessary
|
|
// because in a forwards selection, existingSelectionEnd marks the end
|
|
// of the origin text boundary. Using the unmodified offset incorrectly
|
|
// targets the subsequent text boundary.
|
|
final _TextBoundaryRecord originTextBoundary = getTextBoundary(
|
|
forwardSelection
|
|
? TextPosition(
|
|
offset: existingSelectionEnd.offset - 1,
|
|
affinity: existingSelectionEnd.affinity,
|
|
)
|
|
: existingSelectionEnd,
|
|
fullText,
|
|
);
|
|
if (forwardSelection && clampedPosition.offset == range.start) {
|
|
_setSelectionPosition(clampedPosition, isEnd: isEnd);
|
|
return SelectionResult.previous;
|
|
}
|
|
if (!forwardSelection && clampedPosition.offset == range.end) {
|
|
_setSelectionPosition(clampedPosition, isEnd: isEnd);
|
|
return SelectionResult.next;
|
|
}
|
|
if (forwardSelection && clampedPosition.offset == range.end) {
|
|
_setSelectionPosition(
|
|
_clampTextPosition(originTextBoundary.boundaryStart),
|
|
isEnd: true,
|
|
);
|
|
_setSelectionPosition(clampedPosition, isEnd: isEnd);
|
|
return SelectionResult.next;
|
|
}
|
|
if (!forwardSelection && clampedPosition.offset == range.start) {
|
|
_setSelectionPosition(
|
|
_clampTextPosition(originTextBoundary.boundaryEnd),
|
|
isEnd: true,
|
|
);
|
|
_setSelectionPosition(clampedPosition, isEnd: isEnd);
|
|
return SelectionResult.previous;
|
|
}
|
|
}
|
|
} else {
|
|
// A paragraph boundary may not be completely contained within this root
|
|
// selectable fragment. Keep searching until we find the end of the
|
|
// boundary. Do not search when the current drag position is on a placeholder
|
|
// to allow traversal to reach that placeholder.
|
|
final bool positionOnPlaceholder =
|
|
paragraph.getWordBoundary(position).textInside(fullText) ==
|
|
_placeholderCharacter;
|
|
if (!paragraphContainsPosition || positionOnPlaceholder) {
|
|
return null;
|
|
}
|
|
if (existingSelectionEnd != null) {
|
|
final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(
|
|
position,
|
|
fullText,
|
|
);
|
|
final bool backwardSelection =
|
|
existingSelectionStart == null &&
|
|
existingSelectionEnd.offset == range.start ||
|
|
existingSelectionStart == existingSelectionEnd &&
|
|
existingSelectionEnd.offset == range.start ||
|
|
existingSelectionStart != null &&
|
|
existingSelectionStart.offset > existingSelectionEnd.offset;
|
|
if (boundaryAtPosition.boundaryStart.offset < range.start &&
|
|
boundaryAtPosition.boundaryEnd.offset < range.start) {
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.start),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.previous;
|
|
}
|
|
if (boundaryAtPosition.boundaryStart.offset > range.end &&
|
|
boundaryAtPosition.boundaryEnd.offset > range.end) {
|
|
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
|
|
return SelectionResult.next;
|
|
}
|
|
if (backwardSelection) {
|
|
if (boundaryAtPosition.boundaryEnd.offset <= range.end) {
|
|
_setSelectionPosition(
|
|
_clampTextPosition(boundaryAtPosition.boundaryEnd),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.end;
|
|
}
|
|
if (boundaryAtPosition.boundaryEnd.offset > range.end) {
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.end),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.next;
|
|
}
|
|
} else {
|
|
_setSelectionPosition(
|
|
_clampTextPosition(boundaryAtPosition.boundaryStart),
|
|
isEnd: isEnd,
|
|
);
|
|
if (boundaryAtPosition.boundaryStart.offset < range.start) {
|
|
return SelectionResult.previous;
|
|
}
|
|
if (boundaryAtPosition.boundaryStart.offset >= range.start) {
|
|
return SelectionResult.end;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// This method handles updating the end edge by a text boundary that may
|
|
// not be contained within this selectable fragment. It is possible
|
|
// that a boundary spans multiple selectable fragments when the text contains
|
|
// [WidgetSpan]s.
|
|
//
|
|
// This method differs from [_updateSelectionEndEdgeByTextBoundary] in that
|
|
// to pivot offset used to swap selection edges and maintain the origin
|
|
// text boundary selected may be located outside of this selectable fragment.
|
|
//
|
|
// See [_updateSelectionStartEdgeByMultiSelectableTextBoundary] for the method
|
|
// that handles updating the end edge.
|
|
SelectionResult? _updateSelectionEndEdgeByMultiSelectableTextBoundary(
|
|
_TextBoundaryAtPositionInText getTextBoundary,
|
|
bool paragraphContainsPosition,
|
|
TextPosition position,
|
|
TextPosition? existingSelectionStart,
|
|
TextPosition? existingSelectionEnd,
|
|
) {
|
|
const bool isEnd = true;
|
|
if (_selectableContainsOriginTextBoundary &&
|
|
existingSelectionStart != null &&
|
|
existingSelectionEnd != null) {
|
|
// If this selectable contains the origin boundary, maintain the existing
|
|
// selection.
|
|
final bool forwardSelection =
|
|
existingSelectionEnd.offset >= existingSelectionStart.offset;
|
|
if (paragraphContainsPosition) {
|
|
// When the position is within the root paragraph, swap the start and end
|
|
// edges when the selection is inverted.
|
|
final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(
|
|
position,
|
|
fullText,
|
|
);
|
|
// To accurately retrieve the origin text boundary when the selection
|
|
// is backwards, use existingSelectionStart.offset - 1. This is necessary
|
|
// because in a backwards selection, existingSelectionStart marks the end
|
|
// of the origin text boundary. Using the unmodified offset incorrectly
|
|
// targets the subsequent text boundary.
|
|
final _TextBoundaryRecord originTextBoundary = getTextBoundary(
|
|
forwardSelection
|
|
? existingSelectionStart
|
|
: TextPosition(
|
|
offset: existingSelectionStart.offset - 1,
|
|
affinity: existingSelectionStart.affinity,
|
|
),
|
|
fullText,
|
|
);
|
|
final TextPosition targetPosition;
|
|
final int pivotOffset = forwardSelection
|
|
? originTextBoundary.boundaryStart.offset
|
|
: originTextBoundary.boundaryEnd.offset;
|
|
final bool shouldSwapEdges =
|
|
!forwardSelection != (position.offset < pivotOffset);
|
|
if (position.offset < pivotOffset) {
|
|
targetPosition = boundaryAtPosition.boundaryStart;
|
|
} else if (position.offset > pivotOffset) {
|
|
targetPosition = boundaryAtPosition.boundaryEnd;
|
|
} else {
|
|
// Keep the origin text boundary in bounds when position is at the static edge.
|
|
targetPosition = forwardSelection
|
|
? existingSelectionEnd
|
|
: existingSelectionStart;
|
|
}
|
|
if (shouldSwapEdges) {
|
|
_setSelectionPosition(
|
|
_clampTextPosition(
|
|
forwardSelection
|
|
? originTextBoundary.boundaryEnd
|
|
: originTextBoundary.boundaryStart,
|
|
),
|
|
isEnd: false,
|
|
);
|
|
}
|
|
_setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd);
|
|
final bool finalSelectionIsForward =
|
|
_textSelectionEnd!.offset >= _textSelectionStart!.offset;
|
|
if (boundaryAtPosition.boundaryStart.offset > range.end &&
|
|
boundaryAtPosition.boundaryEnd.offset > range.end) {
|
|
return SelectionResult.next;
|
|
}
|
|
if (boundaryAtPosition.boundaryStart.offset < range.start &&
|
|
boundaryAtPosition.boundaryEnd.offset < range.start) {
|
|
return SelectionResult.previous;
|
|
}
|
|
if (finalSelectionIsForward) {
|
|
if (boundaryAtPosition.boundaryEnd.offset <=
|
|
originTextBoundary.boundaryEnd.offset) {
|
|
return SelectionResult.end;
|
|
}
|
|
if (boundaryAtPosition.boundaryEnd.offset >
|
|
originTextBoundary.boundaryEnd.offset) {
|
|
return SelectionResult.next;
|
|
}
|
|
} else {
|
|
if (boundaryAtPosition.boundaryStart.offset >=
|
|
originTextBoundary.boundaryStart.offset) {
|
|
return SelectionResult.end;
|
|
}
|
|
if (boundaryAtPosition.boundaryStart.offset <
|
|
originTextBoundary.boundaryStart.offset) {
|
|
return SelectionResult.previous;
|
|
}
|
|
}
|
|
} else {
|
|
// When the drag position is not contained within the root paragraph,
|
|
// swap the edges when the selection changes direction.
|
|
final TextPosition clampedPosition = _clampTextPosition(position);
|
|
// To accurately retrieve the origin text boundary when the selection
|
|
// is backwards, use existingSelectionStart.offset - 1. This is necessary
|
|
// because in a backwards selection, existingSelectionStart marks the end
|
|
// of the origin text boundary. Using the unmodified offset incorrectly
|
|
// targets the subsequent text boundary.
|
|
final _TextBoundaryRecord originTextBoundary = getTextBoundary(
|
|
forwardSelection
|
|
? existingSelectionStart
|
|
: TextPosition(
|
|
offset: existingSelectionStart.offset - 1,
|
|
affinity: existingSelectionStart.affinity,
|
|
),
|
|
fullText,
|
|
);
|
|
if (forwardSelection && clampedPosition.offset == range.start) {
|
|
_setSelectionPosition(
|
|
_clampTextPosition(originTextBoundary.boundaryEnd),
|
|
isEnd: false,
|
|
);
|
|
_setSelectionPosition(clampedPosition, isEnd: isEnd);
|
|
return SelectionResult.previous;
|
|
}
|
|
if (!forwardSelection && clampedPosition.offset == range.end) {
|
|
_setSelectionPosition(
|
|
_clampTextPosition(originTextBoundary.boundaryStart),
|
|
isEnd: false,
|
|
);
|
|
_setSelectionPosition(clampedPosition, isEnd: isEnd);
|
|
return SelectionResult.next;
|
|
}
|
|
if (forwardSelection && clampedPosition.offset == range.end) {
|
|
_setSelectionPosition(clampedPosition, isEnd: isEnd);
|
|
return SelectionResult.next;
|
|
}
|
|
if (!forwardSelection && clampedPosition.offset == range.start) {
|
|
_setSelectionPosition(clampedPosition, isEnd: isEnd);
|
|
return SelectionResult.previous;
|
|
}
|
|
}
|
|
} else {
|
|
// A paragraph boundary may not be completely contained within this root
|
|
// selectable fragment. Keep searching until we find the end of the
|
|
// boundary. Do not search when the current drag position is on a placeholder
|
|
// to allow traversal to reach that placeholder.
|
|
final bool positionOnPlaceholder =
|
|
paragraph.getWordBoundary(position).textInside(fullText) ==
|
|
_placeholderCharacter;
|
|
if (!paragraphContainsPosition || positionOnPlaceholder) {
|
|
return null;
|
|
}
|
|
if (existingSelectionStart != null) {
|
|
final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(
|
|
position,
|
|
fullText,
|
|
);
|
|
final bool backwardSelection =
|
|
existingSelectionEnd == null &&
|
|
existingSelectionStart.offset == range.end ||
|
|
existingSelectionStart == existingSelectionEnd &&
|
|
existingSelectionStart.offset == range.end ||
|
|
existingSelectionEnd != null &&
|
|
existingSelectionStart.offset > existingSelectionEnd.offset;
|
|
if (boundaryAtPosition.boundaryStart.offset < range.start &&
|
|
boundaryAtPosition.boundaryEnd.offset < range.start) {
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.start),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.previous;
|
|
}
|
|
if (boundaryAtPosition.boundaryStart.offset > range.end &&
|
|
boundaryAtPosition.boundaryEnd.offset > range.end) {
|
|
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
|
|
return SelectionResult.next;
|
|
}
|
|
if (backwardSelection) {
|
|
_setSelectionPosition(
|
|
_clampTextPosition(boundaryAtPosition.boundaryStart),
|
|
isEnd: isEnd,
|
|
);
|
|
if (boundaryAtPosition.boundaryStart.offset < range.start) {
|
|
return SelectionResult.previous;
|
|
}
|
|
if (boundaryAtPosition.boundaryStart.offset >= range.start) {
|
|
return SelectionResult.end;
|
|
}
|
|
} else {
|
|
if (boundaryAtPosition.boundaryEnd.offset <= range.end) {
|
|
_setSelectionPosition(
|
|
_clampTextPosition(boundaryAtPosition.boundaryEnd),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.end;
|
|
}
|
|
if (boundaryAtPosition.boundaryEnd.offset > range.end) {
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.end),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.next;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// The placeholder character used by [RenderParagraph].
|
|
static final String _placeholderCharacter = String.fromCharCode(
|
|
PlaceholderSpan.placeholderCodeUnit,
|
|
);
|
|
static final int _placeholderLength = _placeholderCharacter.length;
|
|
// This method handles updating the start edge by a text boundary that may
|
|
// not be contained within this selectable fragment. It is possible
|
|
// that a boundary spans multiple selectable fragments when the text contains
|
|
// [WidgetSpan]s.
|
|
//
|
|
// This method differs from [_updateSelectionStartEdgeByMultiSelectableBoundary]
|
|
// in that to maintain the origin text boundary selected at a placeholder,
|
|
// this selectable fragment must be aware of the [RenderParagraph] that closely
|
|
// encompasses the complete origin text boundary.
|
|
//
|
|
// See [_updateSelectionEndEdgeAtPlaceholderByMultiSelectableTextBoundary] for the method
|
|
// that handles updating the end edge.
|
|
SelectionResult?
|
|
_updateSelectionStartEdgeAtPlaceholderByMultiSelectableTextBoundary(
|
|
_TextBoundaryAtPositionInText getTextBoundary,
|
|
Offset globalPosition,
|
|
bool paragraphContainsPosition,
|
|
TextPosition position,
|
|
TextPosition? existingSelectionStart,
|
|
TextPosition? existingSelectionEnd,
|
|
) {
|
|
const bool isEnd = false;
|
|
if (_selectableContainsOriginTextBoundary &&
|
|
existingSelectionStart != null &&
|
|
existingSelectionEnd != null) {
|
|
// If this selectable contains the origin boundary, maintain the existing
|
|
// selection.
|
|
final bool forwardSelection =
|
|
existingSelectionEnd.offset >= existingSelectionStart.offset;
|
|
final RenderParagraph originParagraph = _getOriginParagraph();
|
|
final bool fragmentBelongsToOriginParagraph =
|
|
originParagraph == paragraph;
|
|
if (fragmentBelongsToOriginParagraph) {
|
|
return _updateSelectionStartEdgeByMultiSelectableTextBoundary(
|
|
getTextBoundary,
|
|
paragraphContainsPosition,
|
|
position,
|
|
existingSelectionStart,
|
|
existingSelectionEnd,
|
|
);
|
|
}
|
|
final Matrix4 originTransform = originParagraph.getTransformTo(null);
|
|
originTransform.invert();
|
|
final Offset originParagraphLocalPosition = MatrixUtils.transformPoint(
|
|
originTransform,
|
|
globalPosition,
|
|
);
|
|
final bool positionWithinOriginParagraph = originParagraph.paintBounds
|
|
.contains(
|
|
originParagraphLocalPosition,
|
|
);
|
|
final TextPosition positionRelativeToOriginParagraph = originParagraph
|
|
.getPositionForOffset(
|
|
originParagraphLocalPosition,
|
|
);
|
|
if (positionWithinOriginParagraph) {
|
|
// When the selection is inverted by the new position it is necessary to
|
|
// swap the start edge (moving edge) with the end edge (static edge) to
|
|
// maintain the origin text boundary within the selection.
|
|
final String originText = originParagraph.text.toPlainText(
|
|
includeSemanticsLabels: false,
|
|
);
|
|
final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(
|
|
positionRelativeToOriginParagraph,
|
|
originText,
|
|
);
|
|
final _TextBoundaryRecord originTextBoundary = getTextBoundary(
|
|
_getPositionInParagraph(originParagraph),
|
|
originText,
|
|
);
|
|
final TextPosition targetPosition;
|
|
final int pivotOffset = forwardSelection
|
|
? originTextBoundary.boundaryEnd.offset
|
|
: originTextBoundary.boundaryStart.offset;
|
|
final bool shouldSwapEdges =
|
|
!forwardSelection !=
|
|
(positionRelativeToOriginParagraph.offset > pivotOffset);
|
|
if (positionRelativeToOriginParagraph.offset < pivotOffset) {
|
|
targetPosition = boundaryAtPosition.boundaryStart;
|
|
} else if (positionRelativeToOriginParagraph.offset > pivotOffset) {
|
|
targetPosition = boundaryAtPosition.boundaryEnd;
|
|
} else {
|
|
// Keep the origin text boundary in bounds when position is at the static edge.
|
|
targetPosition = existingSelectionStart;
|
|
}
|
|
if (shouldSwapEdges) {
|
|
_setSelectionPosition(existingSelectionStart, isEnd: true);
|
|
}
|
|
_setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd);
|
|
final bool finalSelectionIsForward =
|
|
_textSelectionEnd!.offset >= _textSelectionStart!.offset;
|
|
final TextPosition originParagraphPlaceholderTextPosition =
|
|
_getPositionInParagraph(
|
|
originParagraph,
|
|
);
|
|
final TextRange originParagraphPlaceholderRange = TextRange(
|
|
start: originParagraphPlaceholderTextPosition.offset,
|
|
end:
|
|
originParagraphPlaceholderTextPosition.offset +
|
|
_placeholderLength,
|
|
);
|
|
if (boundaryAtPosition.boundaryStart.offset >
|
|
originParagraphPlaceholderRange.end &&
|
|
boundaryAtPosition.boundaryEnd.offset >
|
|
originParagraphPlaceholderRange.end) {
|
|
return SelectionResult.next;
|
|
}
|
|
if (boundaryAtPosition.boundaryStart.offset <
|
|
originParagraphPlaceholderRange.start &&
|
|
boundaryAtPosition.boundaryEnd.offset <
|
|
originParagraphPlaceholderRange.start) {
|
|
return SelectionResult.previous;
|
|
}
|
|
if (finalSelectionIsForward) {
|
|
if (boundaryAtPosition.boundaryEnd.offset <=
|
|
originTextBoundary.boundaryEnd.offset) {
|
|
return SelectionResult.end;
|
|
}
|
|
if (boundaryAtPosition.boundaryEnd.offset >
|
|
originTextBoundary.boundaryEnd.offset) {
|
|
return SelectionResult.next;
|
|
}
|
|
} else {
|
|
if (boundaryAtPosition.boundaryStart.offset >=
|
|
originTextBoundary.boundaryStart.offset) {
|
|
return SelectionResult.end;
|
|
}
|
|
if (boundaryAtPosition.boundaryStart.offset <
|
|
originTextBoundary.boundaryStart.offset) {
|
|
return SelectionResult.previous;
|
|
}
|
|
}
|
|
} else {
|
|
// When the drag position is not contained within the origin paragraph,
|
|
// swap the edges when the selection changes direction.
|
|
//
|
|
// [SelectionUtils.adjustDragOffset] will adjust the given [Offset] to the
|
|
// beginning or end of the provided [Rect] based on whether the [Offset]
|
|
// is located within the given [Rect].
|
|
final Offset adjustedOffset = SelectionUtils.adjustDragOffset(
|
|
originParagraph.paintBounds,
|
|
originParagraphLocalPosition,
|
|
direction: paragraph.textDirection,
|
|
);
|
|
final TextPosition adjustedPositionRelativeToOriginParagraph =
|
|
originParagraph.getPositionForOffset(adjustedOffset);
|
|
final TextPosition originParagraphPlaceholderTextPosition =
|
|
_getPositionInParagraph(
|
|
originParagraph,
|
|
);
|
|
final TextRange originParagraphPlaceholderRange = TextRange(
|
|
start: originParagraphPlaceholderTextPosition.offset,
|
|
end:
|
|
originParagraphPlaceholderTextPosition.offset +
|
|
_placeholderLength,
|
|
);
|
|
if (forwardSelection &&
|
|
adjustedPositionRelativeToOriginParagraph.offset <=
|
|
originParagraphPlaceholderRange.start) {
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.start),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.previous;
|
|
}
|
|
if (!forwardSelection &&
|
|
adjustedPositionRelativeToOriginParagraph.offset >=
|
|
originParagraphPlaceholderRange.end) {
|
|
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
|
|
return SelectionResult.next;
|
|
}
|
|
if (forwardSelection &&
|
|
adjustedPositionRelativeToOriginParagraph.offset >=
|
|
originParagraphPlaceholderRange.end) {
|
|
_setSelectionPosition(existingSelectionStart, isEnd: true);
|
|
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
|
|
return SelectionResult.next;
|
|
}
|
|
if (!forwardSelection &&
|
|
adjustedPositionRelativeToOriginParagraph.offset <=
|
|
originParagraphPlaceholderRange.start) {
|
|
_setSelectionPosition(existingSelectionStart, isEnd: true);
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.start),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.previous;
|
|
}
|
|
}
|
|
} else {
|
|
// When the drag position is somewhere on the root text and not a placeholder,
|
|
// traverse the selectable fragments relative to the [RenderParagraph] that
|
|
// contains the drag position.
|
|
if (paragraphContainsPosition) {
|
|
return _updateSelectionStartEdgeByMultiSelectableTextBoundary(
|
|
getTextBoundary,
|
|
paragraphContainsPosition,
|
|
position,
|
|
existingSelectionStart,
|
|
existingSelectionEnd,
|
|
);
|
|
}
|
|
if (existingSelectionEnd != null) {
|
|
final ({RenderParagraph paragraph, Offset localPosition})?
|
|
targetDetails = _getParagraphContainingPosition(globalPosition);
|
|
if (targetDetails == null) {
|
|
return null;
|
|
}
|
|
final RenderParagraph targetParagraph = targetDetails.paragraph;
|
|
final TextPosition positionRelativeToTargetParagraph = targetParagraph
|
|
.getPositionForOffset(
|
|
targetDetails.localPosition,
|
|
);
|
|
final String targetText = targetParagraph.text.toPlainText(
|
|
includeSemanticsLabels: false,
|
|
);
|
|
final bool positionOnPlaceholder =
|
|
targetParagraph
|
|
.getWordBoundary(positionRelativeToTargetParagraph)
|
|
.textInside(targetText) ==
|
|
_placeholderCharacter;
|
|
if (positionOnPlaceholder) {
|
|
return null;
|
|
}
|
|
final bool backwardSelection =
|
|
existingSelectionStart == null &&
|
|
existingSelectionEnd.offset == range.start ||
|
|
existingSelectionStart == existingSelectionEnd &&
|
|
existingSelectionEnd.offset == range.start ||
|
|
existingSelectionStart != null &&
|
|
existingSelectionStart.offset > existingSelectionEnd.offset;
|
|
final _TextBoundaryRecord boundaryAtPositionRelativeToTargetParagraph =
|
|
getTextBoundary(
|
|
positionRelativeToTargetParagraph,
|
|
targetText,
|
|
);
|
|
final TextPosition targetParagraphPlaceholderTextPosition =
|
|
_getPositionInParagraph(
|
|
targetParagraph,
|
|
);
|
|
final TextRange targetParagraphPlaceholderRange = TextRange(
|
|
start: targetParagraphPlaceholderTextPosition.offset,
|
|
end:
|
|
targetParagraphPlaceholderTextPosition.offset +
|
|
_placeholderLength,
|
|
);
|
|
if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset <
|
|
targetParagraphPlaceholderRange.start &&
|
|
boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset <
|
|
targetParagraphPlaceholderRange.start) {
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.start),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.previous;
|
|
}
|
|
if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset >
|
|
targetParagraphPlaceholderRange.end &&
|
|
boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset >
|
|
targetParagraphPlaceholderRange.end) {
|
|
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
|
|
return SelectionResult.next;
|
|
}
|
|
if (backwardSelection) {
|
|
if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset <=
|
|
targetParagraphPlaceholderRange.end) {
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.end),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.end;
|
|
}
|
|
if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset >
|
|
targetParagraphPlaceholderRange.end) {
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.end),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.next;
|
|
}
|
|
} else {
|
|
if (boundaryAtPositionRelativeToTargetParagraph
|
|
.boundaryStart
|
|
.offset >=
|
|
targetParagraphPlaceholderRange.start) {
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.start),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.end;
|
|
}
|
|
if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset <
|
|
targetParagraphPlaceholderRange.start) {
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.start),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.previous;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// This method handles updating the end edge by a text boundary that may
|
|
// not be contained within this selectable fragment. It is possible
|
|
// that a boundary spans multiple selectable fragments when the text contains
|
|
// [WidgetSpan]s.
|
|
//
|
|
// This method differs from [_updateSelectionEndEdgeByMultiSelectableBoundary]
|
|
// in that to maintain the origin text boundary selected at a placeholder, this
|
|
// selectable fragment must be aware of the [RenderParagraph] that closely
|
|
// encompasses the complete origin text boundary.
|
|
//
|
|
// See [_updateSelectionStartEdgeAtPlaceholderByMultiSelectableTextBoundary]
|
|
// for the method that handles updating the start edge.
|
|
SelectionResult?
|
|
_updateSelectionEndEdgeAtPlaceholderByMultiSelectableTextBoundary(
|
|
_TextBoundaryAtPositionInText getTextBoundary,
|
|
Offset globalPosition,
|
|
bool paragraphContainsPosition,
|
|
TextPosition position,
|
|
TextPosition? existingSelectionStart,
|
|
TextPosition? existingSelectionEnd,
|
|
) {
|
|
const bool isEnd = true;
|
|
if (_selectableContainsOriginTextBoundary &&
|
|
existingSelectionStart != null &&
|
|
existingSelectionEnd != null) {
|
|
// If this selectable contains the origin boundary, maintain the existing
|
|
// selection.
|
|
final bool forwardSelection =
|
|
existingSelectionEnd.offset >= existingSelectionStart.offset;
|
|
final RenderParagraph originParagraph = _getOriginParagraph();
|
|
final bool fragmentBelongsToOriginParagraph =
|
|
originParagraph == paragraph;
|
|
if (fragmentBelongsToOriginParagraph) {
|
|
return _updateSelectionEndEdgeByMultiSelectableTextBoundary(
|
|
getTextBoundary,
|
|
paragraphContainsPosition,
|
|
position,
|
|
existingSelectionStart,
|
|
existingSelectionEnd,
|
|
);
|
|
}
|
|
final Matrix4 originTransform = originParagraph.getTransformTo(null);
|
|
originTransform.invert();
|
|
final Offset originParagraphLocalPosition = MatrixUtils.transformPoint(
|
|
originTransform,
|
|
globalPosition,
|
|
);
|
|
final bool positionWithinOriginParagraph = originParagraph.paintBounds
|
|
.contains(
|
|
originParagraphLocalPosition,
|
|
);
|
|
final TextPosition positionRelativeToOriginParagraph = originParagraph
|
|
.getPositionForOffset(
|
|
originParagraphLocalPosition,
|
|
);
|
|
if (positionWithinOriginParagraph) {
|
|
// When the selection is inverted by the new position it is necessary to
|
|
// swap the end edge (moving edge) with the start edge (static edge) to
|
|
// maintain the origin text boundary within the selection.
|
|
final String originText = originParagraph.text.toPlainText(
|
|
includeSemanticsLabels: false,
|
|
);
|
|
final _TextBoundaryRecord boundaryAtPosition = getTextBoundary(
|
|
positionRelativeToOriginParagraph,
|
|
originText,
|
|
);
|
|
final _TextBoundaryRecord originTextBoundary = getTextBoundary(
|
|
_getPositionInParagraph(originParagraph),
|
|
originText,
|
|
);
|
|
final TextPosition targetPosition;
|
|
final int pivotOffset = forwardSelection
|
|
? originTextBoundary.boundaryStart.offset
|
|
: originTextBoundary.boundaryEnd.offset;
|
|
final bool shouldSwapEdges =
|
|
!forwardSelection !=
|
|
(positionRelativeToOriginParagraph.offset < pivotOffset);
|
|
if (positionRelativeToOriginParagraph.offset < pivotOffset) {
|
|
targetPosition = boundaryAtPosition.boundaryStart;
|
|
} else if (positionRelativeToOriginParagraph.offset > pivotOffset) {
|
|
targetPosition = boundaryAtPosition.boundaryEnd;
|
|
} else {
|
|
// Keep the origin text boundary in bounds when position is at the static edge.
|
|
targetPosition = existingSelectionEnd;
|
|
}
|
|
if (shouldSwapEdges) {
|
|
_setSelectionPosition(existingSelectionEnd, isEnd: false);
|
|
}
|
|
_setSelectionPosition(_clampTextPosition(targetPosition), isEnd: isEnd);
|
|
final bool finalSelectionIsForward =
|
|
_textSelectionEnd!.offset >= _textSelectionStart!.offset;
|
|
final TextPosition originParagraphPlaceholderTextPosition =
|
|
_getPositionInParagraph(
|
|
originParagraph,
|
|
);
|
|
final TextRange originParagraphPlaceholderRange = TextRange(
|
|
start: originParagraphPlaceholderTextPosition.offset,
|
|
end:
|
|
originParagraphPlaceholderTextPosition.offset +
|
|
_placeholderLength,
|
|
);
|
|
if (boundaryAtPosition.boundaryStart.offset >
|
|
originParagraphPlaceholderRange.end &&
|
|
boundaryAtPosition.boundaryEnd.offset >
|
|
originParagraphPlaceholderRange.end) {
|
|
return SelectionResult.next;
|
|
}
|
|
if (boundaryAtPosition.boundaryStart.offset <
|
|
originParagraphPlaceholderRange.start &&
|
|
boundaryAtPosition.boundaryEnd.offset <
|
|
originParagraphPlaceholderRange.start) {
|
|
return SelectionResult.previous;
|
|
}
|
|
if (finalSelectionIsForward) {
|
|
if (boundaryAtPosition.boundaryEnd.offset <=
|
|
originTextBoundary.boundaryEnd.offset) {
|
|
return SelectionResult.end;
|
|
}
|
|
if (boundaryAtPosition.boundaryEnd.offset >
|
|
originTextBoundary.boundaryEnd.offset) {
|
|
return SelectionResult.next;
|
|
}
|
|
} else {
|
|
if (boundaryAtPosition.boundaryStart.offset >=
|
|
originTextBoundary.boundaryStart.offset) {
|
|
return SelectionResult.end;
|
|
}
|
|
if (boundaryAtPosition.boundaryStart.offset <
|
|
originTextBoundary.boundaryStart.offset) {
|
|
return SelectionResult.previous;
|
|
}
|
|
}
|
|
} else {
|
|
// When the drag position is not contained within the origin paragraph,
|
|
// swap the edges when the selection changes direction.
|
|
//
|
|
// [SelectionUtils.adjustDragOffset] will adjust the given [Offset] to the
|
|
// beginning or end of the provided [Rect] based on whether the [Offset]
|
|
// is located within the given [Rect].
|
|
final Offset adjustedOffset = SelectionUtils.adjustDragOffset(
|
|
originParagraph.paintBounds,
|
|
originParagraphLocalPosition,
|
|
direction: paragraph.textDirection,
|
|
);
|
|
final TextPosition adjustedPositionRelativeToOriginParagraph =
|
|
originParagraph.getPositionForOffset(adjustedOffset);
|
|
final TextPosition originParagraphPlaceholderTextPosition =
|
|
_getPositionInParagraph(
|
|
originParagraph,
|
|
);
|
|
final TextRange originParagraphPlaceholderRange = TextRange(
|
|
start: originParagraphPlaceholderTextPosition.offset,
|
|
end:
|
|
originParagraphPlaceholderTextPosition.offset +
|
|
_placeholderLength,
|
|
);
|
|
if (forwardSelection &&
|
|
adjustedPositionRelativeToOriginParagraph.offset <=
|
|
originParagraphPlaceholderRange.start) {
|
|
_setSelectionPosition(existingSelectionEnd, isEnd: false);
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.start),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.previous;
|
|
}
|
|
if (!forwardSelection &&
|
|
adjustedPositionRelativeToOriginParagraph.offset >=
|
|
originParagraphPlaceholderRange.end) {
|
|
_setSelectionPosition(existingSelectionEnd, isEnd: false);
|
|
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
|
|
return SelectionResult.next;
|
|
}
|
|
if (forwardSelection &&
|
|
adjustedPositionRelativeToOriginParagraph.offset >=
|
|
originParagraphPlaceholderRange.end) {
|
|
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
|
|
return SelectionResult.next;
|
|
}
|
|
if (!forwardSelection &&
|
|
adjustedPositionRelativeToOriginParagraph.offset <=
|
|
originParagraphPlaceholderRange.start) {
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.start),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.previous;
|
|
}
|
|
}
|
|
} else {
|
|
// When the drag position is somewhere on the root text and not a placeholder,
|
|
// traverse the selectable fragments relative to the [RenderParagraph] that
|
|
// contains the drag position.
|
|
if (paragraphContainsPosition) {
|
|
return _updateSelectionEndEdgeByMultiSelectableTextBoundary(
|
|
getTextBoundary,
|
|
paragraphContainsPosition,
|
|
position,
|
|
existingSelectionStart,
|
|
existingSelectionEnd,
|
|
);
|
|
}
|
|
if (existingSelectionStart != null) {
|
|
final ({RenderParagraph paragraph, Offset localPosition})?
|
|
targetDetails = _getParagraphContainingPosition(globalPosition);
|
|
if (targetDetails == null) {
|
|
return null;
|
|
}
|
|
final RenderParagraph targetParagraph = targetDetails.paragraph;
|
|
final TextPosition positionRelativeToTargetParagraph = targetParagraph
|
|
.getPositionForOffset(
|
|
targetDetails.localPosition,
|
|
);
|
|
final String targetText = targetParagraph.text.toPlainText(
|
|
includeSemanticsLabels: false,
|
|
);
|
|
final bool positionOnPlaceholder =
|
|
targetParagraph
|
|
.getWordBoundary(positionRelativeToTargetParagraph)
|
|
.textInside(targetText) ==
|
|
_placeholderCharacter;
|
|
if (positionOnPlaceholder) {
|
|
return null;
|
|
}
|
|
final bool backwardSelection =
|
|
existingSelectionEnd == null &&
|
|
existingSelectionStart.offset == range.end ||
|
|
existingSelectionStart == existingSelectionEnd &&
|
|
existingSelectionStart.offset == range.end ||
|
|
existingSelectionEnd != null &&
|
|
existingSelectionStart.offset > existingSelectionEnd.offset;
|
|
final _TextBoundaryRecord boundaryAtPositionRelativeToTargetParagraph =
|
|
getTextBoundary(
|
|
positionRelativeToTargetParagraph,
|
|
targetText,
|
|
);
|
|
final TextPosition targetParagraphPlaceholderTextPosition =
|
|
_getPositionInParagraph(
|
|
targetParagraph,
|
|
);
|
|
final TextRange targetParagraphPlaceholderRange = TextRange(
|
|
start: targetParagraphPlaceholderTextPosition.offset,
|
|
end:
|
|
targetParagraphPlaceholderTextPosition.offset +
|
|
_placeholderLength,
|
|
);
|
|
if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset <
|
|
targetParagraphPlaceholderRange.start &&
|
|
boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset <
|
|
targetParagraphPlaceholderRange.start) {
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.start),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.previous;
|
|
}
|
|
if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset >
|
|
targetParagraphPlaceholderRange.end &&
|
|
boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset >
|
|
targetParagraphPlaceholderRange.end) {
|
|
_setSelectionPosition(TextPosition(offset: range.end), isEnd: isEnd);
|
|
return SelectionResult.next;
|
|
}
|
|
if (backwardSelection) {
|
|
if (boundaryAtPositionRelativeToTargetParagraph
|
|
.boundaryStart
|
|
.offset >=
|
|
targetParagraphPlaceholderRange.start) {
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.start),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.end;
|
|
}
|
|
if (boundaryAtPositionRelativeToTargetParagraph.boundaryStart.offset <
|
|
targetParagraphPlaceholderRange.start) {
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.start),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.previous;
|
|
}
|
|
} else {
|
|
if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset <=
|
|
targetParagraphPlaceholderRange.end) {
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.end),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.end;
|
|
}
|
|
if (boundaryAtPositionRelativeToTargetParagraph.boundaryEnd.offset >
|
|
targetParagraphPlaceholderRange.end) {
|
|
_setSelectionPosition(
|
|
TextPosition(offset: range.end),
|
|
isEnd: isEnd,
|
|
);
|
|
return SelectionResult.next;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
SelectionResult _updateSelectionEdgeByMultiSelectableTextBoundary(
|
|
Offset globalPosition, {
|
|
required bool isEnd,
|
|
required _TextBoundaryAtPositionInText getTextBoundary,
|
|
required _TextBoundaryAtPosition getClampedTextBoundary,
|
|
}) {
|
|
// When the start/end edges are swapped, i.e. the start is after the end, and
|
|
// the scrollable synthesizes an event for the opposite edge, this will potentially
|
|
// move the opposite edge outside of the origin text boundary and we are unable to recover.
|
|
final TextPosition? existingSelectionStart = _textSelectionStart;
|
|
final TextPosition? existingSelectionEnd = _textSelectionEnd;
|
|
|
|
_setSelectionPosition(null, isEnd: isEnd);
|
|
final Matrix4 transform = paragraph.getTransformTo(null);
|
|
transform.invert();
|
|
final Offset localPosition = MatrixUtils.transformPoint(
|
|
transform,
|
|
globalPosition,
|
|
);
|
|
if (_rect.isEmpty) {
|
|
return SelectionUtils.getResultBasedOnRect(_rect, localPosition);
|
|
}
|
|
final Offset adjustedOffset = SelectionUtils.adjustDragOffset(
|
|
_rect,
|
|
localPosition,
|
|
direction: paragraph.textDirection,
|
|
);
|
|
final Offset adjustedOffsetRelativeToParagraph =
|
|
SelectionUtils.adjustDragOffset(
|
|
paragraph.paintBounds,
|
|
localPosition,
|
|
direction: paragraph.textDirection,
|
|
);
|
|
|
|
final TextPosition position = paragraph.getPositionForOffset(
|
|
adjustedOffset,
|
|
);
|
|
final TextPosition positionInFullText = paragraph.getPositionForOffset(
|
|
adjustedOffsetRelativeToParagraph,
|
|
);
|
|
|
|
final SelectionResult? result;
|
|
if (_isPlaceholder()) {
|
|
result = isEnd
|
|
? _updateSelectionEndEdgeAtPlaceholderByMultiSelectableTextBoundary(
|
|
getTextBoundary,
|
|
globalPosition,
|
|
paragraph.paintBounds.contains(localPosition),
|
|
positionInFullText,
|
|
existingSelectionStart,
|
|
existingSelectionEnd,
|
|
)
|
|
: _updateSelectionStartEdgeAtPlaceholderByMultiSelectableTextBoundary(
|
|
getTextBoundary,
|
|
globalPosition,
|
|
paragraph.paintBounds.contains(localPosition),
|
|
positionInFullText,
|
|
existingSelectionStart,
|
|
existingSelectionEnd,
|
|
);
|
|
} else {
|
|
result = isEnd
|
|
? _updateSelectionEndEdgeByMultiSelectableTextBoundary(
|
|
getTextBoundary,
|
|
paragraph.paintBounds.contains(localPosition),
|
|
positionInFullText,
|
|
existingSelectionStart,
|
|
existingSelectionEnd,
|
|
)
|
|
: _updateSelectionStartEdgeByMultiSelectableTextBoundary(
|
|
getTextBoundary,
|
|
paragraph.paintBounds.contains(localPosition),
|
|
positionInFullText,
|
|
existingSelectionStart,
|
|
existingSelectionEnd,
|
|
);
|
|
}
|
|
if (result != null) {
|
|
return result;
|
|
}
|
|
|
|
// Check if the original local position is within the rect, if it is not then
|
|
// we do not need to look up the text boundary for that position. This is to
|
|
// maintain a selectables selection collapsed at 0 when the local position is
|
|
// not located inside its rect.
|
|
_TextBoundaryRecord? textBoundary = _boundingBoxesContains(localPosition)
|
|
? getClampedTextBoundary(position)
|
|
: null;
|
|
if (textBoundary != null &&
|
|
(textBoundary.boundaryStart.offset < range.start &&
|
|
textBoundary.boundaryEnd.offset <= range.start ||
|
|
textBoundary.boundaryStart.offset >= range.end &&
|
|
textBoundary.boundaryEnd.offset > range.end)) {
|
|
// When the position is located at a placeholder inside of the text, then we may compute
|
|
// a text boundary that does not belong to the current selectable fragment. In this case
|
|
// we should invalidate the text boundary so that it is not taken into account when
|
|
// computing the target position.
|
|
textBoundary = null;
|
|
}
|
|
final TextPosition targetPosition = _clampTextPosition(
|
|
isEnd
|
|
? _updateSelectionEndEdgeByTextBoundary(
|
|
textBoundary,
|
|
getClampedTextBoundary,
|
|
position,
|
|
existingSelectionStart,
|
|
existingSelectionEnd,
|
|
)
|
|
: _updateSelectionStartEdgeByTextBoundary(
|
|
textBoundary,
|
|
getClampedTextBoundary,
|
|
position,
|
|
existingSelectionStart,
|
|
existingSelectionEnd,
|
|
),
|
|
);
|
|
|
|
_setSelectionPosition(targetPosition, isEnd: isEnd);
|
|
if (targetPosition.offset == range.end) {
|
|
return SelectionResult.next;
|
|
}
|
|
|
|
if (targetPosition.offset == range.start) {
|
|
return SelectionResult.previous;
|
|
}
|
|
// TODO(chunhtai): The geometry information should not be used to determine
|
|
// selection result. This is a workaround to RenderParagraph, where it does
|
|
// not have a way to get accurate text length if its text is truncated due to
|
|
// layout constraint.
|
|
return SelectionUtils.getResultBasedOnRect(_rect, localPosition);
|
|
}
|
|
|
|
TextPosition _closestTextBoundary(
|
|
_TextBoundaryRecord textBoundary,
|
|
TextPosition position,
|
|
) {
|
|
final int differenceA =
|
|
(position.offset - textBoundary.boundaryStart.offset).abs();
|
|
final int differenceB = (position.offset - textBoundary.boundaryEnd.offset)
|
|
.abs();
|
|
return differenceA < differenceB
|
|
? textBoundary.boundaryStart
|
|
: textBoundary.boundaryEnd;
|
|
}
|
|
|
|
bool _isPlaceholder() {
|
|
// Determine whether this selectable fragment is a placeholder.
|
|
RenderObject? current = paragraph.parent;
|
|
while (current != null) {
|
|
if (current is RenderParagraph) {
|
|
return true;
|
|
}
|
|
current = current.parent;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
RenderParagraph _getOriginParagraph() {
|
|
// This method should only be called from a fragment that contains
|
|
// the origin boundary. By traversing up the RenderTree, determine the
|
|
// highest RenderParagraph that contains the origin text boundary.
|
|
assert(_selectableContainsOriginTextBoundary);
|
|
// Begin at the parent because it is guaranteed the paragraph containing
|
|
// this selectable fragment contains the origin boundary.
|
|
RenderObject? current = paragraph.parent;
|
|
RenderParagraph? originParagraph;
|
|
while (current != null) {
|
|
if (current is RenderParagraph) {
|
|
if (current._lastSelectableFragments != null) {
|
|
bool paragraphContainsOriginTextBoundary = false;
|
|
for (final _SelectableFragment fragment
|
|
in current._lastSelectableFragments!) {
|
|
if (fragment._selectableContainsOriginTextBoundary) {
|
|
paragraphContainsOriginTextBoundary = true;
|
|
originParagraph = current;
|
|
break;
|
|
}
|
|
}
|
|
if (!paragraphContainsOriginTextBoundary) {
|
|
return originParagraph ?? paragraph;
|
|
}
|
|
}
|
|
}
|
|
current = current.parent;
|
|
}
|
|
return originParagraph ?? paragraph;
|
|
}
|
|
|
|
({RenderParagraph paragraph, Offset localPosition})?
|
|
_getParagraphContainingPosition(
|
|
Offset globalPosition,
|
|
) {
|
|
// This method will return the closest [RenderParagraph] whose rect
|
|
// contains the given `globalPosition` and the given `globalPosition`
|
|
// relative to that [RenderParagraph]. If no ancestor [RenderParagraph]
|
|
// contains the given `globalPosition` then this method will return null.
|
|
RenderObject? current = paragraph;
|
|
while (current != null) {
|
|
if (current is RenderParagraph) {
|
|
final Matrix4 currentTransform = current.getTransformTo(null);
|
|
currentTransform.invert();
|
|
final Offset currentParagraphLocalPosition = MatrixUtils.transformPoint(
|
|
currentTransform,
|
|
globalPosition,
|
|
);
|
|
final bool positionWithinCurrentParagraph = current.paintBounds
|
|
.contains(
|
|
currentParagraphLocalPosition,
|
|
);
|
|
if (positionWithinCurrentParagraph) {
|
|
return (
|
|
paragraph: current,
|
|
localPosition: currentParagraphLocalPosition,
|
|
);
|
|
}
|
|
}
|
|
current = current.parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
bool _boundingBoxesContains(Offset position) {
|
|
for (final Rect rect in boundingBoxes) {
|
|
if (rect.contains(position)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
TextPosition _clampTextPosition(TextPosition position) {
|
|
// Affinity of range.end is upstream.
|
|
if (position.offset > range.end ||
|
|
(position.offset == range.end &&
|
|
position.affinity == TextAffinity.downstream)) {
|
|
return TextPosition(offset: range.end, affinity: TextAffinity.upstream);
|
|
}
|
|
if (position.offset < range.start) {
|
|
return TextPosition(offset: range.start);
|
|
}
|
|
return position;
|
|
}
|
|
|
|
void _setSelectionPosition(TextPosition? position, {required bool isEnd}) {
|
|
if (isEnd) {
|
|
_textSelectionEnd = position;
|
|
} else {
|
|
_textSelectionStart = position;
|
|
}
|
|
}
|
|
|
|
SelectionResult _handleClearSelection() {
|
|
_textSelectionStart = null;
|
|
_textSelectionEnd = null;
|
|
_selectableContainsOriginTextBoundary = false;
|
|
return SelectionResult.none;
|
|
}
|
|
|
|
SelectionResult _handleSelectAll() {
|
|
_textSelectionStart = TextPosition(offset: range.start);
|
|
_textSelectionEnd = TextPosition(
|
|
offset: range.end,
|
|
affinity: TextAffinity.upstream,
|
|
);
|
|
return SelectionResult.none;
|
|
}
|
|
|
|
SelectionResult _handleSelectTextBoundary(_TextBoundaryRecord textBoundary) {
|
|
// This fragment may not contain the boundary, decide what direction the target
|
|
// fragment is located in. Because fragments are separated by placeholder
|
|
// spans, we also check if the beginning or end of the boundary is touching
|
|
// either edge of this fragment.
|
|
if (textBoundary.boundaryStart.offset < range.start &&
|
|
textBoundary.boundaryEnd.offset <= range.start) {
|
|
return SelectionResult.previous;
|
|
} else if (textBoundary.boundaryStart.offset >= range.end &&
|
|
textBoundary.boundaryEnd.offset > range.end) {
|
|
return SelectionResult.next;
|
|
}
|
|
// Fragments are separated by placeholder span, the text boundary shouldn't
|
|
// expand across fragments.
|
|
assert(
|
|
textBoundary.boundaryStart.offset >= range.start &&
|
|
textBoundary.boundaryEnd.offset <= range.end,
|
|
);
|
|
_textSelectionStart = textBoundary.boundaryStart;
|
|
_textSelectionEnd = textBoundary.boundaryEnd;
|
|
_selectableContainsOriginTextBoundary = true;
|
|
return SelectionResult.end;
|
|
}
|
|
|
|
TextRange? _intersect(TextRange a, TextRange b) {
|
|
assert(a.isNormalized);
|
|
assert(b.isNormalized);
|
|
final int startMax = math.max(a.start, b.start);
|
|
final int endMin = math.min(a.end, b.end);
|
|
if (startMax <= endMin) {
|
|
// Intersection.
|
|
return TextRange(start: startMax, end: endMin);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
SelectionResult _handleSelectMultiFragmentTextBoundary(
|
|
_TextBoundaryRecord textBoundary,
|
|
) {
|
|
// This fragment may not contain the boundary, decide what direction the target
|
|
// fragment is located in. Because fragments are separated by placeholder
|
|
// spans, we also check if the beginning or end of the boundary is touching
|
|
// either edge of this fragment.
|
|
if (textBoundary.boundaryStart.offset < range.start &&
|
|
textBoundary.boundaryEnd.offset <= range.start) {
|
|
return SelectionResult.previous;
|
|
} else if (textBoundary.boundaryStart.offset >= range.end &&
|
|
textBoundary.boundaryEnd.offset > range.end) {
|
|
return SelectionResult.next;
|
|
}
|
|
final TextRange boundaryAsRange = TextRange(
|
|
start: textBoundary.boundaryStart.offset,
|
|
end: textBoundary.boundaryEnd.offset,
|
|
);
|
|
final TextRange? intersectRange = _intersect(range, boundaryAsRange);
|
|
if (intersectRange != null) {
|
|
_textSelectionStart = TextPosition(offset: intersectRange.start);
|
|
_textSelectionEnd = TextPosition(offset: intersectRange.end);
|
|
_selectableContainsOriginTextBoundary = true;
|
|
if (range.end < textBoundary.boundaryEnd.offset) {
|
|
return SelectionResult.next;
|
|
}
|
|
return SelectionResult.end;
|
|
}
|
|
return SelectionResult.none;
|
|
}
|
|
|
|
_TextBoundaryRecord _adjustTextBoundaryAtPosition(
|
|
TextRange textBoundary,
|
|
TextPosition position,
|
|
) {
|
|
late final TextPosition start;
|
|
late final TextPosition end;
|
|
if (position.offset > textBoundary.end) {
|
|
start = end = TextPosition(offset: position.offset);
|
|
} else {
|
|
start = TextPosition(offset: textBoundary.start);
|
|
end = TextPosition(
|
|
offset: textBoundary.end,
|
|
affinity: TextAffinity.upstream,
|
|
);
|
|
}
|
|
return (boundaryStart: start, boundaryEnd: end);
|
|
}
|
|
|
|
SelectionResult _handleSelectWord(Offset globalPosition) {
|
|
final TextPosition position = paragraph.getPositionForOffset(
|
|
paragraph.globalToLocal(globalPosition),
|
|
);
|
|
if (_positionIsWithinCurrentSelection(position) &&
|
|
_textSelectionStart != _textSelectionEnd) {
|
|
return SelectionResult.end;
|
|
}
|
|
final _TextBoundaryRecord wordBoundary = _getWordBoundaryAtPosition(
|
|
position,
|
|
);
|
|
return _handleSelectTextBoundary(wordBoundary);
|
|
}
|
|
|
|
_TextBoundaryRecord _getWordBoundaryAtPosition(TextPosition position) {
|
|
final TextRange word = paragraph.getWordBoundary(position);
|
|
assert(word.isNormalized);
|
|
return _adjustTextBoundaryAtPosition(word, position);
|
|
}
|
|
|
|
SelectionResult _handleSelectParagraph(Offset globalPosition) {
|
|
final Offset localPosition = paragraph.globalToLocal(globalPosition);
|
|
final TextPosition position = paragraph.getPositionForOffset(localPosition);
|
|
final _TextBoundaryRecord paragraphBoundary =
|
|
_getParagraphBoundaryAtPosition(
|
|
position,
|
|
fullText,
|
|
);
|
|
return _handleSelectMultiFragmentTextBoundary(paragraphBoundary);
|
|
}
|
|
|
|
TextPosition _getPositionInParagraph(RenderParagraph targetParagraph) {
|
|
final Matrix4 transform = paragraph.getTransformTo(targetParagraph);
|
|
final Offset localCenter = paragraph.paintBounds.centerLeft;
|
|
final Offset localPos = MatrixUtils.transformPoint(transform, localCenter);
|
|
final TextPosition position = targetParagraph.getPositionForOffset(
|
|
localPos,
|
|
);
|
|
return position;
|
|
}
|
|
|
|
_TextBoundaryRecord _getParagraphBoundaryAtPosition(
|
|
TextPosition position,
|
|
String text,
|
|
) {
|
|
final ParagraphBoundary paragraphBoundary = ParagraphBoundary(text);
|
|
// Use position.offset - 1 when `position` is at the end of the selectable to retrieve
|
|
// the previous text boundary's location.
|
|
final int paragraphStart =
|
|
paragraphBoundary.getLeadingTextBoundaryAt(
|
|
position.offset == text.length ||
|
|
position.affinity == TextAffinity.upstream
|
|
? position.offset - 1
|
|
: position.offset,
|
|
) ??
|
|
0;
|
|
final int paragraphEnd =
|
|
paragraphBoundary.getTrailingTextBoundaryAt(position.offset) ??
|
|
text.length;
|
|
final TextRange paragraphRange = TextRange(
|
|
start: paragraphStart,
|
|
end: paragraphEnd,
|
|
);
|
|
assert(paragraphRange.isNormalized);
|
|
return _adjustTextBoundaryAtPosition(paragraphRange, position);
|
|
}
|
|
|
|
_TextBoundaryRecord _getClampedParagraphBoundaryAtPosition(
|
|
TextPosition position,
|
|
) {
|
|
final ParagraphBoundary paragraphBoundary = ParagraphBoundary(fullText);
|
|
// Use position.offset - 1 when `position` is at the end of the selectable to retrieve
|
|
// the previous text boundary's location.
|
|
int paragraphStart =
|
|
paragraphBoundary.getLeadingTextBoundaryAt(
|
|
position.offset == fullText.length ||
|
|
position.affinity == TextAffinity.upstream
|
|
? position.offset - 1
|
|
: position.offset,
|
|
) ??
|
|
0;
|
|
int paragraphEnd =
|
|
paragraphBoundary.getTrailingTextBoundaryAt(position.offset) ??
|
|
fullText.length;
|
|
paragraphStart = paragraphStart < range.start
|
|
? range.start
|
|
: paragraphStart > range.end
|
|
? range.end
|
|
: paragraphStart;
|
|
paragraphEnd = paragraphEnd > range.end
|
|
? range.end
|
|
: paragraphEnd < range.start
|
|
? range.start
|
|
: paragraphEnd;
|
|
final TextRange paragraphRange = TextRange(
|
|
start: paragraphStart,
|
|
end: paragraphEnd,
|
|
);
|
|
assert(paragraphRange.isNormalized);
|
|
return _adjustTextBoundaryAtPosition(paragraphRange, position);
|
|
}
|
|
|
|
SelectionResult _handleDirectionallyExtendSelection(
|
|
double horizontalBaseline,
|
|
bool isExtent,
|
|
SelectionExtendDirection movement,
|
|
) {
|
|
final Matrix4 transform = paragraph.getTransformTo(null);
|
|
if (transform.invert() == 0.0) {
|
|
switch (movement) {
|
|
case SelectionExtendDirection.previousLine:
|
|
case SelectionExtendDirection.backward:
|
|
return SelectionResult.previous;
|
|
case SelectionExtendDirection.nextLine:
|
|
case SelectionExtendDirection.forward:
|
|
return SelectionResult.next;
|
|
}
|
|
}
|
|
final double baselineInParagraphCoordinates = MatrixUtils.transformPoint(
|
|
transform,
|
|
Offset(horizontalBaseline, 0),
|
|
).dx;
|
|
assert(!baselineInParagraphCoordinates.isNaN);
|
|
final TextPosition newPosition;
|
|
final SelectionResult result;
|
|
switch (movement) {
|
|
case SelectionExtendDirection.previousLine:
|
|
case SelectionExtendDirection.nextLine:
|
|
assert(_textSelectionEnd != null && _textSelectionStart != null);
|
|
final TextPosition targetedEdge = isExtent
|
|
? _textSelectionEnd!
|
|
: _textSelectionStart!;
|
|
final MapEntry<TextPosition, SelectionResult> moveResult =
|
|
_handleVerticalMovement(
|
|
targetedEdge,
|
|
horizontalBaselineInParagraphCoordinates:
|
|
baselineInParagraphCoordinates,
|
|
below: movement == SelectionExtendDirection.nextLine,
|
|
);
|
|
newPosition = moveResult.key;
|
|
result = moveResult.value;
|
|
case SelectionExtendDirection.forward:
|
|
case SelectionExtendDirection.backward:
|
|
_textSelectionEnd ??= movement == SelectionExtendDirection.forward
|
|
? TextPosition(offset: range.start)
|
|
: TextPosition(offset: range.end, affinity: TextAffinity.upstream);
|
|
_textSelectionStart ??= _textSelectionEnd;
|
|
final TextPosition targetedEdge = isExtent
|
|
? _textSelectionEnd!
|
|
: _textSelectionStart!;
|
|
final Offset edgeOffsetInParagraphCoordinates = paragraph
|
|
._getOffsetForPosition(
|
|
targetedEdge,
|
|
);
|
|
final Offset baselineOffsetInParagraphCoordinates = Offset(
|
|
baselineInParagraphCoordinates,
|
|
// Use half of line height to point to the middle of the line.
|
|
edgeOffsetInParagraphCoordinates.dy -
|
|
paragraph._textPainter.preferredLineHeight / 2,
|
|
);
|
|
newPosition = paragraph.getPositionForOffset(
|
|
baselineOffsetInParagraphCoordinates,
|
|
);
|
|
result = SelectionResult.end;
|
|
}
|
|
if (isExtent) {
|
|
_textSelectionEnd = newPosition;
|
|
} else {
|
|
_textSelectionStart = newPosition;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
SelectionResult _handleGranularlyExtendSelection(
|
|
bool forward,
|
|
bool isExtent,
|
|
TextGranularity granularity,
|
|
) {
|
|
_textSelectionEnd ??= forward
|
|
? TextPosition(offset: range.start)
|
|
: TextPosition(offset: range.end, affinity: TextAffinity.upstream);
|
|
_textSelectionStart ??= _textSelectionEnd;
|
|
final TextPosition targetedEdge = isExtent
|
|
? _textSelectionEnd!
|
|
: _textSelectionStart!;
|
|
if (forward && (targetedEdge.offset == range.end)) {
|
|
return SelectionResult.next;
|
|
}
|
|
if (!forward && (targetedEdge.offset == range.start)) {
|
|
return SelectionResult.previous;
|
|
}
|
|
final SelectionResult result;
|
|
final TextPosition newPosition;
|
|
switch (granularity) {
|
|
case TextGranularity.character:
|
|
final String text = range.textInside(fullText);
|
|
newPosition = _moveBeyondTextBoundaryAtDirection(
|
|
targetedEdge,
|
|
forward,
|
|
CharacterBoundary(text),
|
|
);
|
|
result = SelectionResult.end;
|
|
case TextGranularity.word:
|
|
final TextBoundary textBoundary =
|
|
paragraph._textPainter.wordBoundaries.moveByWordBoundary;
|
|
newPosition = _moveBeyondTextBoundaryAtDirection(
|
|
targetedEdge,
|
|
forward,
|
|
textBoundary,
|
|
);
|
|
result = SelectionResult.end;
|
|
case TextGranularity.paragraph:
|
|
final String text = range.textInside(fullText);
|
|
newPosition = _moveBeyondTextBoundaryAtDirection(
|
|
targetedEdge,
|
|
forward,
|
|
ParagraphBoundary(text),
|
|
);
|
|
result = SelectionResult.end;
|
|
case TextGranularity.line:
|
|
newPosition = _moveToTextBoundaryAtDirection(
|
|
targetedEdge,
|
|
forward,
|
|
LineBoundary(this),
|
|
);
|
|
result = SelectionResult.end;
|
|
case TextGranularity.document:
|
|
final String text = range.textInside(fullText);
|
|
newPosition = _moveBeyondTextBoundaryAtDirection(
|
|
targetedEdge,
|
|
forward,
|
|
DocumentBoundary(text),
|
|
);
|
|
if (forward && newPosition.offset == range.end) {
|
|
result = SelectionResult.next;
|
|
} else if (!forward && newPosition.offset == range.start) {
|
|
result = SelectionResult.previous;
|
|
} else {
|
|
result = SelectionResult.end;
|
|
}
|
|
}
|
|
|
|
if (isExtent) {
|
|
_textSelectionEnd = newPosition;
|
|
} else {
|
|
_textSelectionStart = newPosition;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Move **beyond** the local boundary of the given type (unless range.start or
|
|
// range.end is reached). Used for most TextGranularity types except for
|
|
// TextGranularity.line, to ensure the selection movement doesn't get stuck at
|
|
// a local fixed point.
|
|
TextPosition _moveBeyondTextBoundaryAtDirection(
|
|
TextPosition end,
|
|
bool forward,
|
|
TextBoundary textBoundary,
|
|
) {
|
|
final int newOffset = forward
|
|
? textBoundary.getTrailingTextBoundaryAt(end.offset) ?? range.end
|
|
: textBoundary.getLeadingTextBoundaryAt(end.offset - 1) ?? range.start;
|
|
return TextPosition(offset: newOffset);
|
|
}
|
|
|
|
// Move **to** the local boundary of the given type. Typically used for line
|
|
// boundaries, such that performing "move to line start" more than once never
|
|
// moves the selection to the previous line.
|
|
TextPosition _moveToTextBoundaryAtDirection(
|
|
TextPosition end,
|
|
bool forward,
|
|
TextBoundary textBoundary,
|
|
) {
|
|
assert(end.offset >= 0);
|
|
final int caretOffset;
|
|
switch (end.affinity) {
|
|
case TextAffinity.upstream:
|
|
if (end.offset < 1 && !forward) {
|
|
assert(end.offset == 0);
|
|
return const TextPosition(offset: 0);
|
|
}
|
|
final CharacterBoundary characterBoundary = CharacterBoundary(fullText);
|
|
caretOffset =
|
|
math.max(
|
|
0,
|
|
characterBoundary.getLeadingTextBoundaryAt(
|
|
range.start + end.offset,
|
|
) ??
|
|
range.start,
|
|
) -
|
|
1;
|
|
case TextAffinity.downstream:
|
|
caretOffset = end.offset;
|
|
}
|
|
final int offset = forward
|
|
? textBoundary.getTrailingTextBoundaryAt(caretOffset) ?? range.end
|
|
: textBoundary.getLeadingTextBoundaryAt(caretOffset) ?? range.start;
|
|
return TextPosition(offset: offset);
|
|
}
|
|
|
|
MapEntry<TextPosition, SelectionResult> _handleVerticalMovement(
|
|
TextPosition position, {
|
|
required double horizontalBaselineInParagraphCoordinates,
|
|
required bool below,
|
|
}) {
|
|
final List<ui.LineMetrics> lines = paragraph._textPainter
|
|
.computeLineMetrics();
|
|
final Offset offset = paragraph.getOffsetForCaret(position, Rect.zero);
|
|
int currentLine = lines.length - 1;
|
|
for (final ui.LineMetrics lineMetrics in lines) {
|
|
if (lineMetrics.baseline > offset.dy) {
|
|
currentLine = lineMetrics.lineNumber;
|
|
break;
|
|
}
|
|
}
|
|
final TextPosition newPosition;
|
|
if (below && currentLine == lines.length - 1) {
|
|
newPosition = TextPosition(
|
|
offset: range.end,
|
|
affinity: TextAffinity.upstream,
|
|
);
|
|
} else if (!below && currentLine == 0) {
|
|
newPosition = TextPosition(offset: range.start);
|
|
} else {
|
|
final int newLine = below ? currentLine + 1 : currentLine - 1;
|
|
newPosition = _clampTextPosition(
|
|
paragraph.getPositionForOffset(
|
|
Offset(
|
|
horizontalBaselineInParagraphCoordinates,
|
|
lines[newLine].baseline,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
final SelectionResult result;
|
|
if (newPosition.offset == range.start) {
|
|
result = SelectionResult.previous;
|
|
} else if (newPosition.offset == range.end) {
|
|
result = SelectionResult.next;
|
|
} else {
|
|
result = SelectionResult.end;
|
|
}
|
|
assert(result != SelectionResult.next || below);
|
|
assert(result != SelectionResult.previous || !below);
|
|
return MapEntry<TextPosition, SelectionResult>(newPosition, result);
|
|
}
|
|
|
|
/// Whether the given text position is contained in current selection
|
|
/// range.
|
|
///
|
|
/// The parameter `start` must be smaller than `end`.
|
|
bool _positionIsWithinCurrentSelection(TextPosition position) {
|
|
if (_textSelectionStart == null || _textSelectionEnd == null) {
|
|
return false;
|
|
}
|
|
// Normalize current selection.
|
|
late TextPosition currentStart;
|
|
late TextPosition currentEnd;
|
|
if (_compareTextPositions(_textSelectionStart!, _textSelectionEnd!) > 0) {
|
|
currentStart = _textSelectionStart!;
|
|
currentEnd = _textSelectionEnd!;
|
|
} else {
|
|
currentStart = _textSelectionEnd!;
|
|
currentEnd = _textSelectionStart!;
|
|
}
|
|
return _compareTextPositions(currentStart, position) >= 0 &&
|
|
_compareTextPositions(currentEnd, position) <= 0;
|
|
}
|
|
|
|
/// Compares two text positions.
|
|
///
|
|
/// Returns 1 if `position` < `otherPosition`, -1 if `position` > `otherPosition`,
|
|
/// or 0 if they are equal.
|
|
static int _compareTextPositions(
|
|
TextPosition position,
|
|
TextPosition otherPosition,
|
|
) {
|
|
if (position.offset < otherPosition.offset) {
|
|
return 1;
|
|
} else if (position.offset > otherPosition.offset) {
|
|
return -1;
|
|
} else if (position.affinity == otherPosition.affinity) {
|
|
return 0;
|
|
} else {
|
|
return position.affinity == TextAffinity.upstream ? 1 : -1;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Matrix4 getTransformTo(RenderObject? ancestor) {
|
|
return paragraph.getTransformTo(ancestor);
|
|
}
|
|
|
|
@override
|
|
void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) {
|
|
if (!paragraph.attached) {
|
|
assert(
|
|
startHandle == null && endHandle == null,
|
|
'Only clean up can be called.',
|
|
);
|
|
return;
|
|
}
|
|
if (_startHandleLayerLink != startHandle) {
|
|
_startHandleLayerLink = startHandle;
|
|
paragraph.markNeedsPaint();
|
|
}
|
|
if (_endHandleLayerLink != endHandle) {
|
|
_endHandleLayerLink = endHandle;
|
|
paragraph.markNeedsPaint();
|
|
}
|
|
}
|
|
|
|
List<Rect>? _cachedBoundingBoxes;
|
|
@override
|
|
List<Rect> get boundingBoxes {
|
|
if (_cachedBoundingBoxes == null) {
|
|
final List<TextBox> boxes = paragraph.getBoxesForSelection(
|
|
TextSelection(baseOffset: range.start, extentOffset: range.end),
|
|
boxHeightStyle: ui.BoxHeightStyle.max,
|
|
);
|
|
if (boxes.isNotEmpty) {
|
|
_cachedBoundingBoxes = <Rect>[];
|
|
for (final TextBox textBox in boxes) {
|
|
_cachedBoundingBoxes!.add(textBox.toRect());
|
|
}
|
|
} else {
|
|
final Offset offset = paragraph._getOffsetForPosition(
|
|
TextPosition(offset: range.start),
|
|
);
|
|
final Rect rect = Rect.fromPoints(
|
|
offset,
|
|
offset.translate(0, -paragraph._textPainter.preferredLineHeight),
|
|
);
|
|
_cachedBoundingBoxes = <Rect>[rect];
|
|
}
|
|
}
|
|
return _cachedBoundingBoxes!;
|
|
}
|
|
|
|
Rect? _cachedRect;
|
|
Rect get _rect {
|
|
if (_cachedRect == null) {
|
|
final List<TextBox> boxes = paragraph.getBoxesForSelection(
|
|
TextSelection(baseOffset: range.start, extentOffset: range.end),
|
|
);
|
|
if (boxes.isNotEmpty) {
|
|
Rect result = boxes.first.toRect();
|
|
for (int index = 1; index < boxes.length; index += 1) {
|
|
result = result.expandToInclude(boxes[index].toRect());
|
|
}
|
|
_cachedRect = result;
|
|
} else {
|
|
final Offset offset = paragraph._getOffsetForPosition(
|
|
TextPosition(offset: range.start),
|
|
);
|
|
_cachedRect = Rect.fromPoints(
|
|
offset,
|
|
offset.translate(0, -paragraph._textPainter.preferredLineHeight),
|
|
);
|
|
}
|
|
}
|
|
return _cachedRect!;
|
|
}
|
|
|
|
void didChangeParagraphLayout() {
|
|
_cachedRect = null;
|
|
_cachedBoundingBoxes = null;
|
|
}
|
|
|
|
@override
|
|
int get contentLength => range.end - range.start;
|
|
|
|
@override
|
|
Size get size {
|
|
return _rect.size;
|
|
}
|
|
|
|
void paint(PaintingContext context, Offset offset) {
|
|
if (_textSelectionStart == null || _textSelectionEnd == null) {
|
|
return;
|
|
}
|
|
if (paragraph.selectionColor != null) {
|
|
final TextSelection selection = TextSelection(
|
|
baseOffset: _textSelectionStart!.offset,
|
|
extentOffset: _textSelectionEnd!.offset,
|
|
);
|
|
final Paint selectionPaint = Paint()
|
|
..style = PaintingStyle.fill
|
|
..color = paragraph.selectionColor!;
|
|
for (final TextBox textBox in paragraph.getBoxesForSelection(selection)) {
|
|
context.canvas.drawRect(textBox.toRect().shift(offset), selectionPaint);
|
|
}
|
|
}
|
|
if (_startHandleLayerLink != null && value.startSelectionPoint != null) {
|
|
context.pushLayer(
|
|
LeaderLayer(
|
|
link: _startHandleLayerLink!,
|
|
offset: offset + value.startSelectionPoint!.localPosition,
|
|
),
|
|
(PaintingContext context, Offset offset) {},
|
|
Offset.zero,
|
|
);
|
|
}
|
|
if (_endHandleLayerLink != null && value.endSelectionPoint != null) {
|
|
context.pushLayer(
|
|
LeaderLayer(
|
|
link: _endHandleLayerLink!,
|
|
offset: offset + value.endSelectionPoint!.localPosition,
|
|
),
|
|
(PaintingContext context, Offset offset) {},
|
|
Offset.zero,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
TextSelection getLineAtOffset(TextPosition position) {
|
|
final TextRange line = paragraph._getLineAtOffset(position);
|
|
final int start = line.start.clamp(range.start, range.end);
|
|
final int end = line.end.clamp(range.start, range.end);
|
|
return TextSelection(baseOffset: start, extentOffset: end);
|
|
}
|
|
|
|
@override
|
|
TextPosition getTextPositionAbove(TextPosition position) {
|
|
return _clampTextPosition(paragraph._getTextPositionAbove(position));
|
|
}
|
|
|
|
@override
|
|
TextPosition getTextPositionBelow(TextPosition position) {
|
|
return _clampTextPosition(paragraph._getTextPositionBelow(position));
|
|
}
|
|
|
|
@override
|
|
TextRange getWordBoundary(TextPosition position) =>
|
|
paragraph.getWordBoundary(position);
|
|
|
|
@override
|
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
|
super.debugFillProperties(properties);
|
|
properties.add(
|
|
DiagnosticsProperty<String>(
|
|
'textInsideRange',
|
|
range.textInside(fullText),
|
|
),
|
|
);
|
|
properties.add(DiagnosticsProperty<TextRange>('range', range));
|
|
properties.add(DiagnosticsProperty<String>('fullText', fullText));
|
|
}
|
|
}
|