diff --git a/lib/common/constants.dart b/lib/common/constants.dart index a1603c69..9713f7af 100644 --- a/lib/common/constants.dart +++ b/lib/common/constants.dart @@ -6,6 +6,10 @@ class StyleString { static const BorderRadius mdRadius = BorderRadius.all(imgRadius); static const Radius imgRadius = Radius.circular(10); static const double aspectRatio = 16 / 10; + static const bottomSheetRadius = BorderRadius.only( + topLeft: Radius.circular(18), + topRight: Radius.circular(18), + ); } class Constants { diff --git a/lib/common/widgets/text/paragraph.dart b/lib/common/widgets/text/paragraph.dart new file mode 100644 index 00000000..fc057e96 --- /dev/null +++ b/lib/common/widgets/text/paragraph.dart @@ -0,0 +1,3604 @@ +// 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, + 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? children, + Color? selectionColor, + SelectionRegistrar? registrar, + required Color primary, + }) : assert(text.debugAssertIsValid()), + assert(maxLines == null || maxLines > 0), + 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, + _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? _cachedAttributedLabels; + + List? _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 get selections { + if (_lastSelectableFragments == null) { + return const []; + } + final List results = []; + 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(); + _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 = + 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 = + 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}) { + 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; + + 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; + } + } + + TextSpan get _moreTextSpan => TextSpan( + style: text.style!.copyWith(color: _primary), + text: '查看更多', + ); + TextPainter? _morePainter; + + @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); + + final bool 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, 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), + [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), + [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) { + if (_needsClipping) { + _morePainter?.paint( + context.canvas, offset + Offset(0, _textPainter.height)); + } + // 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(); + } + } + + /// 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 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 + /// . + /// + /// 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? _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 attributes = []; + 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(buffer.toString(), attributes: attributes), + ]; + } + config.attributedLabel = _cachedAttributedLabels![0]; + config.textDirection = textDirection; + } + } + + ChildSemanticsConfigurationsResult _childSemanticsConfigurationsDelegate( + List 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 cachedStrings = + _cachedAttributedLabels ??= []; + 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? _cachedChildNodes; + + @override + void assembleSemanticsNode( + SemanticsNode node, + SemanticsConfiguration config, + Iterable children, + ) { + assert(_semanticsInfo != null && _semanticsInfo!.isNotEmpty); + final List newChildren = []; + TextDirection currentDirection = textDirection; + Rect currentRect; + double ordinal = 0.0; + int start = 0; + int placeholderIndex = 0; + int childIndex = 0; + RenderBox? child = firstChild; + final LinkedHashMap newChildCache = + LinkedHashMap(); + _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 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 debugDescribeChildren() { + return [ + text.toDiagnosticsNode( + name: 'text', style: DiagnosticsTreeStyle.transition), + ]; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(EnumProperty('textAlign', textAlign)); + properties.add(EnumProperty('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('overflow', overflow)); + properties.add( + DiagnosticsProperty('textScaler', textScaler, + defaultValue: TextScaler.noScaling), + ); + properties + .add(DiagnosticsProperty('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 selectionRects = []; + 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 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 _handleVerticalMovement( + TextPosition position, { + required double horizontalBaselineInParagraphCoordinates, + required bool below, + }) { + final List 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(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? _cachedBoundingBoxes; + @override + List get boundingBoxes { + if (_cachedBoundingBoxes == null) { + final List boxes = paragraph.getBoxesForSelection( + TextSelection(baseOffset: range.start, extentOffset: range.end), + boxHeightStyle: ui.BoxHeightStyle.max, + ); + if (boxes.isNotEmpty) { + _cachedBoundingBoxes = []; + 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]; + } + } + return _cachedBoundingBoxes!; + } + + Rect? _cachedRect; + Rect get _rect { + if (_cachedRect == null) { + final List 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( + 'textInsideRange', range.textInside(fullText))); + properties.add(DiagnosticsProperty('range', range)); + properties.add(DiagnosticsProperty('fullText', fullText)); + } +} diff --git a/lib/common/widgets/text/rich_text.dart b/lib/common/widgets/text/rich_text.dart new file mode 100644 index 00000000..0917f1f8 --- /dev/null +++ b/lib/common/widgets/text/rich_text.dart @@ -0,0 +1,314 @@ +import 'dart:ui' as ui; + +import 'package:PiliPlus/common/widgets/text/paragraph.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' hide RenderParagraph; + +/// A paragraph of rich text. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=rykDVh-QFfw} +/// +/// The [RichText] widget displays text that uses multiple different styles. The +/// text to display is described using a tree of [TextSpan] objects, each of +/// which has an associated style that is used for that subtree. The text might +/// break across multiple lines or might all be displayed on the same line +/// depending on the layout constraints. +/// +/// Text displayed in a [RichText] widget must be explicitly styled. When +/// picking which style to use, consider using [DefaultTextStyle.of] the current +/// [BuildContext] to provide defaults. For more details on how to style text in +/// a [RichText] widget, see the documentation for [TextStyle]. +/// +/// Consider using the [Text] widget to integrate with the [DefaultTextStyle] +/// automatically. When all the text uses the same style, the default constructor +/// is less verbose. The [Text.rich] constructor allows you to style multiple +/// spans with the default text style while still allowing specified styles per +/// span. +/// +/// {@tool snippet} +/// +/// This sample demonstrates how to mix and match text with different text +/// styles using the [RichText] Widget. It displays the text "Hello bold world," +/// emphasizing the word "bold" using a bold font weight. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/widgets/rich_text.png) +/// +/// ```dart +/// RichText( +/// text: TextSpan( +/// text: 'Hello ', +/// style: DefaultTextStyle.of(context).style, +/// children: const [ +/// TextSpan(text: 'bold', style: TextStyle(fontWeight: FontWeight.bold)), +/// TextSpan(text: ' world!'), +/// ], +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Selections +/// +/// To make this [RichText] Selectable, the [RichText] needs to be in the +/// subtree of a [SelectionArea] or [SelectableRegion] and a +/// [SelectionRegistrar] needs to be assigned to the +/// [RichText.selectionRegistrar]. One can use +/// [SelectionContainer.maybeOf] to get the [SelectionRegistrar] from a +/// context. This enables users to select the text in [RichText]s with mice or +/// touch events. +/// +/// The [selectionColor] also needs to be set if the selection is enabled to +/// draw the selection highlights. +/// +/// {@tool snippet} +/// +/// This sample demonstrates how to assign a [SelectionRegistrar] for RichTexts +/// in the SelectionArea subtree. +/// +/// ![](https://flutter.github.io/assets-for-api-docs/assets/widgets/rich_text.png) +/// +/// ```dart +/// RichText( +/// text: const TextSpan(text: 'Hello'), +/// selectionRegistrar: SelectionContainer.maybeOf(context), +/// selectionColor: const Color(0xAF6694e8), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [TextStyle], which discusses how to style text. +/// * [TextSpan], which is used to describe the text in a paragraph. +/// * [Text], which automatically applies the ambient styles described by a +/// [DefaultTextStyle] to a single string. +/// * [Text.rich], a const text widget that provides similar functionality +/// as [RichText]. [Text.rich] will inherit [TextStyle] from [DefaultTextStyle]. +/// * [SelectableRegion], which provides an overview of the selection system. +class RichText extends MultiChildRenderObjectWidget { + /// Creates a paragraph of rich text. + /// + /// The [maxLines] property may be null (and indeed defaults to null), but if + /// it is not null, it must be greater than zero. + /// + /// The [textDirection], if null, defaults to the ambient [Directionality], + /// which in that case must not be null. + RichText({ + super.key, + required this.text, + this.textAlign = TextAlign.start, + this.textDirection, + this.softWrap = true, + this.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, + this.maxLines, + this.locale, + this.strutStyle, + this.textWidthBasis = TextWidthBasis.parent, + this.textHeightBehavior, + this.selectionRegistrar, + this.selectionColor, + }) : assert(maxLines == null || maxLines > 0), + assert(selectionRegistrar == null || selectionColor != null), + assert( + textScaleFactor == 1.0 || identical(textScaler, TextScaler.noScaling), + 'Use textScaler instead.', + ), + textScaler = _effectiveTextScalerFrom(textScaler, textScaleFactor), + super( + children: WidgetSpan.extractFromInlineSpan( + text, + _effectiveTextScalerFrom(textScaler, textScaleFactor), + ), + ); + + static TextScaler _effectiveTextScalerFrom( + TextScaler textScaler, double textScaleFactor) { + return switch ((textScaler, textScaleFactor)) { + (final TextScaler scaler, 1.0) => scaler, + (TextScaler.noScaling, final double textScaleFactor) => + TextScaler.linear(textScaleFactor), + (final TextScaler scaler, _) => scaler, + }; + } + + /// The text to display in this widget. + final InlineSpan text; + + /// How the text should be aligned horizontally. + final TextAlign textAlign; + + /// The directionality of the text. + /// + /// This decides how [textAlign] values like [TextAlign.start] and + /// [TextAlign.end] 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. + /// + /// Defaults to the ambient [Directionality], if any. If there is no ambient + /// [Directionality], then this must not be null. + final TextDirection? textDirection; + + /// 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. + final bool softWrap; + + /// How visual overflow should be handled. + final TextOverflow overflow; + + /// 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 => textScaler.textScaleFactor; + + /// {@macro flutter.painting.textPainter.textScaler} + final TextScaler textScaler; + + /// 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]. + /// + /// If this is 1, text will not wrap. Otherwise, text will be wrapped at the + /// edge of the box. + final int? maxLines; + + /// Used to select a font when the same Unicode character can + /// be rendered differently, depending on the locale. + /// + /// It's rarely necessary to set this property. By default its value + /// is inherited from the enclosing app with `Localizations.localeOf(context)`. + /// + /// See [RenderParagraph.locale] for more information. + final Locale? locale; + + /// {@macro flutter.painting.textPainter.strutStyle} + final StrutStyle? strutStyle; + + /// {@macro flutter.painting.textPainter.textWidthBasis} + final TextWidthBasis textWidthBasis; + + /// {@macro dart.ui.textHeightBehavior} + final ui.TextHeightBehavior? textHeightBehavior; + + /// The [SelectionRegistrar] this rich text is subscribed to. + /// + /// If this is set, [selectionColor] must be non-null. + final SelectionRegistrar? selectionRegistrar; + + /// The color to use when painting the selection. + /// + /// This is ignored if [selectionRegistrar] is null. + /// + /// See the section on selections in the [RichText] top-level API + /// documentation for more details on enabling selection in [RichText] + /// widgets. + final Color? selectionColor; + + @override + RenderParagraph createRenderObject(BuildContext context) { + assert(textDirection != null || debugCheckHasDirectionality(context)); + return RenderParagraph( + text, + textAlign: textAlign, + textDirection: textDirection ?? Directionality.of(context), + softWrap: softWrap, + overflow: overflow, + textScaler: textScaler, + maxLines: maxLines, + strutStyle: strutStyle, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior, + locale: locale ?? Localizations.maybeLocaleOf(context), + registrar: selectionRegistrar, + selectionColor: selectionColor, + primary: Theme.of(context).colorScheme.primary, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderParagraph renderObject) { + assert(textDirection != null || debugCheckHasDirectionality(context)); + renderObject + ..text = text + ..textAlign = textAlign + ..textDirection = textDirection ?? Directionality.of(context) + ..softWrap = softWrap + ..overflow = overflow + ..textScaler = textScaler + ..maxLines = maxLines + ..strutStyle = strutStyle + ..textWidthBasis = textWidthBasis + ..textHeightBehavior = textHeightBehavior + ..locale = locale ?? Localizations.maybeLocaleOf(context) + ..registrar = selectionRegistrar + ..selectionColor = selectionColor + ..primary = Theme.of(context).colorScheme.primary; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(EnumProperty('textAlign', textAlign, + defaultValue: TextAlign.start)); + properties.add(EnumProperty('textDirection', textDirection, + defaultValue: null)); + properties.add( + FlagProperty( + 'softWrap', + value: softWrap, + ifTrue: 'wrapping at box width', + ifFalse: 'no wrapping except at line break characters', + showName: true, + ), + ); + properties.add( + EnumProperty('overflow', overflow, + defaultValue: TextOverflow.clip), + ); + properties.add( + DiagnosticsProperty('textScaler', textScaler, + defaultValue: TextScaler.noScaling), + ); + properties.add(IntProperty('maxLines', maxLines, ifNull: 'unlimited')); + properties.add( + EnumProperty( + 'textWidthBasis', + textWidthBasis, + defaultValue: TextWidthBasis.parent, + ), + ); + properties.add(StringProperty('text', text.toPlainText())); + properties + .add(DiagnosticsProperty('locale', locale, defaultValue: null)); + properties.add(DiagnosticsProperty('strutStyle', strutStyle, + defaultValue: null)); + properties.add( + DiagnosticsProperty( + 'textHeightBehavior', + textHeightBehavior, + defaultValue: null, + ), + ); + } +} diff --git a/lib/common/widgets/text/text.dart b/lib/common/widgets/text/text.dart new file mode 100644 index 00000000..fbbe26c9 --- /dev/null +++ b/lib/common/widgets/text/text.dart @@ -0,0 +1,1180 @@ +// 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/gestures.dart'; +/// @docImport 'package:flutter/material.dart'; +/// +/// @docImport 'editable_text.dart'; +/// @docImport 'gesture_detector.dart'; +/// @docImport 'implicit_animations.dart'; +/// @docImport 'transitions.dart'; +/// @docImport 'widget_span.dart'; +library; + +import 'dart:math'; +import 'dart:ui' as ui show TextHeightBehavior; + +import 'package:PiliPlus/common/widgets/text/paragraph.dart'; +import 'package:PiliPlus/common/widgets/text/rich_text.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' hide RichText; +import 'package:flutter/rendering.dart' hide RenderParagraph; + +/// A run of text with a single style. +/// +/// The [Text] widget displays a string of text with single style. The string +/// might break across multiple lines or might all be displayed on the same line +/// depending on the layout constraints. +/// +/// The [style] argument is optional. When omitted, the text will use the style +/// from the closest enclosing [DefaultTextStyle]. If the given style's +/// [TextStyle.inherit] property is true (the default), the given style will +/// be merged with the closest enclosing [DefaultTextStyle]. This merging +/// behavior is useful, for example, to make the text bold while using the +/// default font family and size. +/// +/// {@tool snippet} +/// +/// This example shows how to display text using the [Text] widget with the +/// [overflow] set to [TextOverflow.ellipsis]. +/// +/// ![If the text overflows, the Text widget displays an ellipsis to trim the overflowing text](https://flutter.github.io/assets-for-api-docs/assets/widgets/text_ellipsis.png) +/// +/// ```dart +/// Container( +/// width: 100, +/// decoration: BoxDecoration(border: Border.all()), +/// child: Text(overflow: TextOverflow.ellipsis, 'Hello $_name, how are you?')) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Setting [maxLines] to `1` is not equivalent to disabling soft wrapping with +/// [softWrap]. This is apparent when using [TextOverflow.fade] as the following +/// examples show. +/// +/// ![If a second line overflows the Text widget displays a horizontal fade](https://flutter.github.io/assets-for-api-docs/assets/widgets/text_fade_max_lines.png) +/// +/// ```dart +/// Text( +/// overflow: TextOverflow.fade, +/// maxLines: 1, +/// 'Hello $_name, how are you?') +/// ``` +/// +/// Here soft wrapping is enabled and the [Text] widget tries to wrap the words +/// "how are you?" to a second line. This is prevented by the [maxLines] value +/// of `1`. The result is that a second line overflows and the fade appears in a +/// horizontal direction at the bottom. +/// +/// ![If a single line overflows the Text widget displays a horizontal fade](https://flutter.github.io/assets-for-api-docs/assets/widgets/text_fade_soft_wrap.png) +/// +/// ```dart +/// Text( +/// overflow: TextOverflow.fade, +/// softWrap: false, +/// 'Hello $_name, how are you?') +/// ``` +/// +/// Here soft wrapping is disabled with `softWrap: false` and the [Text] widget +/// attempts to display its text in a single unbroken line. The result is that +/// the single line overflows and the fade appears in a vertical direction at +/// the right. +/// +/// {@end-tool} +/// +/// Using the [Text.rich] constructor, the [Text] widget can +/// display a paragraph with differently styled [TextSpan]s. The sample +/// that follows displays "Hello beautiful world" with different styles +/// for each word. +/// +/// {@tool snippet} +/// +/// ![The word "Hello" is shown with the default text styles. The word "beautiful" is italicized. The word "world" is bold.](https://flutter.github.io/assets-for-api-docs/assets/widgets/text_rich.png) +/// +/// ```dart +/// const Text.rich( +/// TextSpan( +/// text: 'Hello', // default text style +/// children: [ +/// TextSpan(text: ' beautiful ', style: TextStyle(fontStyle: FontStyle.italic)), +/// TextSpan(text: 'world', style: TextStyle(fontWeight: FontWeight.bold)), +/// ], +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Interactivity +/// +/// To make [Text] react to touch events, wrap it in a [GestureDetector] widget +/// with a [GestureDetector.onTap] handler. +/// +/// In a Material Design application, consider using a [TextButton] instead, or +/// if that isn't appropriate, at least using an [InkWell] instead of +/// [GestureDetector]. +/// +/// To make sections of the text interactive, use [RichText] and specify a +/// [TapGestureRecognizer] as the [TextSpan.recognizer] of the relevant part of +/// the text. +/// +/// ## Selection +/// +/// [Text] is not selectable by default. To make a [Text] selectable, one can +/// wrap a subtree with a [SelectionArea] widget. To exclude a part of a subtree +/// under [SelectionArea] from selection, once can also wrap that part of the +/// subtree with [SelectionContainer.disabled]. +/// +/// {@tool dartpad} +/// This sample demonstrates how to disable selection for a Text under a +/// SelectionArea. +/// +/// ** See code in examples/api/lib/material/selection_container/selection_container_disabled.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [RichText], which gives you more control over the text styles. +/// * [DefaultTextStyle], which sets default styles for [Text] widgets. +/// * [SelectableRegion], which provides an overview of the selection system. +class Text extends StatelessWidget { + /// Creates a text widget. + /// + /// If the [style] argument is null, the text will use the style from the + /// closest enclosing [DefaultTextStyle]. + /// + /// The [overflow] property's behavior is affected by the [softWrap] argument. + /// If the [softWrap] is true or null, the glyph causing overflow, and those + /// that follow, will not be rendered. Otherwise, it will be shown with the + /// given overflow option. + const Text( + String this.data, { + super.key, + this.style, + this.strutStyle, + this.textAlign, + this.textDirection, + this.locale, + this.softWrap, + this.overflow, + @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.', + ) + this.textScaleFactor, + this.textScaler, + this.maxLines, + this.semanticsLabel, + this.semanticsIdentifier, + this.textWidthBasis, + this.textHeightBehavior, + this.selectionColor, + }) : textSpan = null, + assert( + textScaler == null || textScaleFactor == null, + 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.', + ); + + /// Creates a text widget with a [InlineSpan]. + /// + /// The following subclasses of [InlineSpan] may be used to build rich text: + /// + /// * [TextSpan]s define text and children [InlineSpan]s. + /// * [WidgetSpan]s define embedded inline widgets. + /// + /// See [RichText] which provides a lower-level way to draw text. + const Text.rich( + InlineSpan this.textSpan, { + super.key, + this.style, + this.strutStyle, + this.textAlign, + this.textDirection, + this.locale, + this.softWrap, + this.overflow, + @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.', + ) + this.textScaleFactor, + this.textScaler, + this.maxLines, + this.semanticsLabel, + this.semanticsIdentifier, + this.textWidthBasis, + this.textHeightBehavior, + this.selectionColor, + }) : data = null, + assert( + textScaler == null || textScaleFactor == null, + 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.', + ); + + /// The text to display. + /// + /// This will be null if a [textSpan] is provided instead. + final String? data; + + /// The text to display as a [InlineSpan]. + /// + /// This will be null if [data] is provided instead. + final InlineSpan? textSpan; + + /// If non-null, the style to use for this text. + /// + /// If the style's "inherit" property is true, the style will be merged with + /// the closest enclosing [DefaultTextStyle]. Otherwise, the style will + /// replace the closest enclosing [DefaultTextStyle]. + final TextStyle? style; + + /// {@macro flutter.painting.textPainter.strutStyle} + final StrutStyle? strutStyle; + + /// How the text should be aligned horizontally. + final TextAlign? textAlign; + + /// The directionality of the text. + /// + /// This decides how [textAlign] values like [TextAlign.start] and + /// [TextAlign.end] are interpreted. + /// + /// This is also used to disambiguate how to render bidirectional text. For + /// example, if the [data] 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. + /// + /// Defaults to the ambient [Directionality], if any. + final TextDirection? textDirection; + + /// Used to select a font when the same Unicode character can + /// be rendered differently, depending on the locale. + /// + /// It's rarely necessary to set this property. By default its value + /// is inherited from the enclosing app with `Localizations.localeOf(context)`. + /// + /// See [RenderParagraph.locale] for more information. + final Locale? locale; + + /// 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. + final bool? softWrap; + + /// How visual overflow should be handled. + /// + /// If this is null [TextStyle.overflow] will be used, otherwise the value + /// from the nearest [DefaultTextStyle] ancestor will be used. + final TextOverflow? overflow; + + /// 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. + /// + /// The value given to the constructor as textScaleFactor. If null, will + /// use the [MediaQueryData.textScaleFactor] obtained from the ambient + /// [MediaQuery], or 1.0 if there is no [MediaQuery] in scope. + @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.', + ) + final double? textScaleFactor; + + /// {@macro flutter.painting.textPainter.textScaler} + final TextScaler? textScaler; + + /// 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]. + /// + /// If this is 1, text will not wrap. Otherwise, text will be wrapped at the + /// edge of the box. + /// + /// If this is null, but there is an ambient [DefaultTextStyle] that specifies + /// an explicit number for its [DefaultTextStyle.maxLines], then the + /// [DefaultTextStyle] value will take precedence. You can use a [RichText] + /// widget directly to entirely override the [DefaultTextStyle]. + final int? maxLines; + + /// {@template flutter.widgets.Text.semanticsLabel} + /// An alternative semantics label for this text. + /// + /// If present, the semantics of this widget will contain this value instead + /// of the actual text. This will overwrite any of the semantics labels applied + /// directly to the [TextSpan]s. + /// + /// This is useful for replacing abbreviations or shorthands with the full + /// text value: + /// + /// ```dart + /// const Text(r'$$', semanticsLabel: 'Double dollars') + /// ``` + /// {@endtemplate} + final String? semanticsLabel; + + /// A unique identifier for the semantics node for this widget. + /// + /// This is useful for cases where the text widget needs to have a uniquely + /// identifiable ID that is recognized through the automation tools without + /// having a dependency on the actual content of the text that can possibly be + /// dynamic in nature. + final String? semanticsIdentifier; + + /// {@macro flutter.painting.textPainter.textWidthBasis} + final TextWidthBasis? textWidthBasis; + + /// {@macro dart.ui.textHeightBehavior} + final ui.TextHeightBehavior? textHeightBehavior; + + /// The color to use when painting the selection. + /// + /// This is ignored if [SelectionContainer.maybeOf] returns null + /// in the [BuildContext] of the [Text] widget. + /// + /// If null, the ambient [DefaultSelectionStyle] is used (if any); failing + /// that, the selection color defaults to [DefaultSelectionStyle.defaultColor] + /// (semi-transparent grey). + final Color? selectionColor; + + @override + Widget build(BuildContext context) { + final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context); + TextStyle? effectiveTextStyle = style; + if (style == null || style!.inherit) { + effectiveTextStyle = defaultTextStyle.style.merge(style); + } + if (MediaQuery.boldTextOf(context)) { + effectiveTextStyle = effectiveTextStyle! + .merge(const TextStyle(fontWeight: FontWeight.bold)); + } + final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context); + final TextScaler textScaler = switch ((this.textScaler, textScaleFactor)) { + (final TextScaler textScaler, _) => textScaler, + // For unmigrated apps, fall back to textScaleFactor. + (null, final double textScaleFactor) => + TextScaler.linear(textScaleFactor), + (null, null) => MediaQuery.textScalerOf(context), + }; + late Widget result; + if (registrar != null) { + result = MouseRegion( + cursor: DefaultSelectionStyle.of(context).mouseCursor ?? + SystemMouseCursors.text, + child: _SelectableTextContainer( + textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start, + textDirection: + textDirection, // RichText uses Directionality.of to obtain a default if this is null. + locale: + locale, // RichText uses Localizations.localeOf to obtain a default if this is null + softWrap: softWrap ?? defaultTextStyle.softWrap, + overflow: overflow ?? + effectiveTextStyle?.overflow ?? + defaultTextStyle.overflow, + textScaler: textScaler, + maxLines: maxLines ?? defaultTextStyle.maxLines, + strutStyle: strutStyle, + textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis, + textHeightBehavior: textHeightBehavior ?? + defaultTextStyle.textHeightBehavior ?? + DefaultTextHeightBehavior.maybeOf(context), + selectionColor: selectionColor ?? + DefaultSelectionStyle.of(context).selectionColor ?? + DefaultSelectionStyle.defaultColor, + text: TextSpan( + style: effectiveTextStyle, + text: data, + children: textSpan != null ? [textSpan!] : null, + ), + ), + ); + } else { + result = RichText( + textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start, + textDirection: + textDirection, // RichText uses Directionality.of to obtain a default if this is null. + locale: + locale, // RichText uses Localizations.localeOf to obtain a default if this is null + softWrap: softWrap ?? defaultTextStyle.softWrap, + overflow: overflow ?? + effectiveTextStyle?.overflow ?? + defaultTextStyle.overflow, + textScaler: textScaler, + maxLines: maxLines ?? defaultTextStyle.maxLines, + strutStyle: strutStyle, + textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis, + textHeightBehavior: textHeightBehavior ?? + defaultTextStyle.textHeightBehavior ?? + DefaultTextHeightBehavior.maybeOf(context), + selectionColor: selectionColor ?? + DefaultSelectionStyle.of(context).selectionColor ?? + DefaultSelectionStyle.defaultColor, + text: TextSpan( + style: effectiveTextStyle, + text: data, + children: textSpan != null ? [textSpan!] : null, + ), + ); + } + if (semanticsLabel != null || semanticsIdentifier != null) { + result = Semantics( + textDirection: textDirection, + label: semanticsLabel, + identifier: semanticsIdentifier, + child: + ExcludeSemantics(excluding: semanticsLabel != null, child: result), + ); + } + return result; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('data', data, showName: false)); + if (textSpan != null) { + properties.add( + textSpan!.toDiagnosticsNode( + name: 'textSpan', style: DiagnosticsTreeStyle.transition), + ); + } + style?.debugFillProperties(properties); + properties.add( + EnumProperty('textAlign', textAlign, defaultValue: null)); + properties.add(EnumProperty('textDirection', textDirection, + defaultValue: null)); + properties + .add(DiagnosticsProperty('locale', locale, defaultValue: null)); + properties.add( + FlagProperty( + 'softWrap', + value: softWrap, + ifTrue: 'wrapping at box width', + ifFalse: 'no wrapping except at line break characters', + showName: true, + ), + ); + properties.add( + EnumProperty('overflow', overflow, defaultValue: null)); + properties.add( + DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null)); + properties.add(IntProperty('maxLines', maxLines, defaultValue: null)); + properties.add( + EnumProperty('textWidthBasis', textWidthBasis, + defaultValue: null), + ); + properties.add( + DiagnosticsProperty( + 'textHeightBehavior', + textHeightBehavior, + defaultValue: null, + ), + ); + if (semanticsLabel != null) { + properties.add(StringProperty('semanticsLabel', semanticsLabel)); + } + if (semanticsIdentifier != null) { + properties + .add(StringProperty('semanticsIdentifier', semanticsIdentifier)); + } + } +} + +class _SelectableTextContainer extends StatefulWidget { + const _SelectableTextContainer({ + required this.text, + required this.textAlign, + this.textDirection, + required this.softWrap, + required this.overflow, + required this.textScaler, + this.maxLines, + this.locale, + this.strutStyle, + required this.textWidthBasis, + this.textHeightBehavior, + required this.selectionColor, + }); + + final TextSpan text; + final TextAlign textAlign; + final TextDirection? textDirection; + final bool softWrap; + final TextOverflow overflow; + final TextScaler textScaler; + final int? maxLines; + final Locale? locale; + final StrutStyle? strutStyle; + final TextWidthBasis textWidthBasis; + final ui.TextHeightBehavior? textHeightBehavior; + final Color selectionColor; + + @override + State<_SelectableTextContainer> createState() => + _SelectableTextContainerState(); +} + +class _SelectableTextContainerState extends State<_SelectableTextContainer> { + late final _SelectableTextContainerDelegate _selectionDelegate; + final GlobalKey _textKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _selectionDelegate = _SelectableTextContainerDelegate(_textKey); + } + + @override + void dispose() { + _selectionDelegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SelectionContainer( + delegate: _selectionDelegate, + // Use [_RichText] wrapper so the underlying [RenderParagraph] can register + // its [Selectable]s to the [SelectionContainer] created by this widget. + child: _RichText( + textKey: _textKey, + textAlign: widget.textAlign, + textDirection: widget.textDirection, + locale: widget.locale, + softWrap: widget.softWrap, + overflow: widget.overflow, + textScaler: widget.textScaler, + maxLines: widget.maxLines, + strutStyle: widget.strutStyle, + textWidthBasis: widget.textWidthBasis, + textHeightBehavior: widget.textHeightBehavior, + selectionColor: widget.selectionColor, + text: widget.text, + ), + ); + } +} + +class _RichText extends StatelessWidget { + const _RichText({ + this.textKey, + required this.text, + required this.textAlign, + this.textDirection, + required this.softWrap, + required this.overflow, + required this.textScaler, + this.maxLines, + this.locale, + this.strutStyle, + required this.textWidthBasis, + this.textHeightBehavior, + required this.selectionColor, + }); + + final GlobalKey? textKey; + final InlineSpan text; + final TextAlign textAlign; + final TextDirection? textDirection; + final bool softWrap; + final TextOverflow overflow; + final TextScaler textScaler; + final int? maxLines; + final Locale? locale; + final StrutStyle? strutStyle; + final TextWidthBasis textWidthBasis; + final ui.TextHeightBehavior? textHeightBehavior; + final Color selectionColor; + + @override + Widget build(BuildContext context) { + final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context); + return RichText( + key: textKey, + textAlign: textAlign, + textDirection: textDirection, + locale: locale, + softWrap: softWrap, + overflow: overflow, + textScaler: textScaler, + maxLines: maxLines, + strutStyle: strutStyle, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior, + selectionRegistrar: registrar, + selectionColor: selectionColor, + text: text, + ); + } +} + +// In practice some selectables like widgetspan shift several pixels. So when +// the vertical position diff is within the threshold, compare the horizontal +// position to make the compareScreenOrder function more robust. +const double _kSelectableVerticalComparingThreshold = 3.0; + +class _SelectableTextContainerDelegate + extends StaticSelectionContainerDelegate { + _SelectableTextContainerDelegate(GlobalKey textKey) : _textKey = textKey; + + final GlobalKey _textKey; + RenderParagraph get paragraph => + _textKey.currentContext!.findRenderObject()! as RenderParagraph; + + @override + SelectionResult handleSelectParagraph(SelectParagraphSelectionEvent event) { + final SelectionResult result = _handleSelectParagraph(event); + super.didReceiveSelectionBoundaryEvents(); + return result; + } + + SelectionResult _handleSelectParagraph(SelectParagraphSelectionEvent event) { + if (event.absorb) { + for (int index = 0; index < selectables.length; index += 1) { + dispatchSelectionEventToChild(selectables[index], event); + } + currentSelectionStartIndex = 0; + currentSelectionEndIndex = selectables.length - 1; + return SelectionResult.next; + } + + // First pass, if the position is on a placeholder then dispatch the selection + // event to the [Selectable] at the location and terminate. + for (int index = 0; index < selectables.length; index += 1) { + final bool selectableIsPlaceholder = + !paragraph.selectableBelongsToParagraph(selectables[index]); + if (selectableIsPlaceholder && + selectables[index].boundingBoxes.isNotEmpty) { + for (final Rect rect in selectables[index].boundingBoxes) { + final Rect globalRect = MatrixUtils.transformRect( + selectables[index].getTransformTo(null), + rect, + ); + if (globalRect.contains(event.globalPosition)) { + currentSelectionStartIndex = currentSelectionEndIndex = index; + return dispatchSelectionEventToChild(selectables[index], event); + } + } + } + } + + SelectionResult? lastSelectionResult; + bool foundStart = false; + int? lastNextIndex; + for (int index = 0; index < selectables.length; index += 1) { + if (!paragraph.selectableBelongsToParagraph(selectables[index])) { + if (foundStart) { + final SelectionEvent synthesizedEvent = SelectParagraphSelectionEvent( + globalPosition: event.globalPosition, + absorb: true, + ); + final SelectionResult result = dispatchSelectionEventToChild( + selectables[index], + synthesizedEvent, + ); + if (selectables.length - 1 == index) { + currentSelectionEndIndex = index; + _flushInactiveSelections(); + return result; + } + } + continue; + } + final SelectionGeometry existingGeometry = selectables[index].value; + lastSelectionResult = + dispatchSelectionEventToChild(selectables[index], event); + if (index == selectables.length - 1 && + lastSelectionResult == SelectionResult.next) { + if (foundStart) { + currentSelectionEndIndex = index; + } else { + currentSelectionStartIndex = currentSelectionEndIndex = index; + } + return SelectionResult.next; + } + if (lastSelectionResult == SelectionResult.next) { + if (selectables[index].value == existingGeometry && !foundStart) { + lastNextIndex = index; + } + if (selectables[index].value != existingGeometry && !foundStart) { + assert(selectables[index].boundingBoxes.isNotEmpty); + assert(selectables[index].value.selectionRects.isNotEmpty); + final bool selectionAtStartOfSelectable = + selectables[index].boundingBoxes[0].overlaps( + selectables[index].value.selectionRects[0], + ); + int startIndex = 0; + if (lastNextIndex != null && selectionAtStartOfSelectable) { + startIndex = lastNextIndex + 1; + } else { + startIndex = lastNextIndex == null && selectionAtStartOfSelectable + ? 0 + : index; + } + for (int i = startIndex; i < index; i += 1) { + final SelectionEvent synthesizedEvent = + SelectParagraphSelectionEvent( + globalPosition: event.globalPosition, + absorb: true, + ); + dispatchSelectionEventToChild(selectables[i], synthesizedEvent); + } + currentSelectionStartIndex = startIndex; + foundStart = true; + } + continue; + } + if (index == 0 && lastSelectionResult == SelectionResult.previous) { + return SelectionResult.previous; + } + if (selectables[index].value != existingGeometry) { + if (!foundStart && lastNextIndex == null) { + currentSelectionStartIndex = 0; + for (int i = 0; i < index; i += 1) { + final SelectionEvent synthesizedEvent = + SelectParagraphSelectionEvent( + globalPosition: event.globalPosition, + absorb: true, + ); + dispatchSelectionEventToChild(selectables[i], synthesizedEvent); + } + } + currentSelectionEndIndex = index; + // Geometry has changed as a result of select paragraph, need to clear the + // selection of other selectables to keep selection in sync. + _flushInactiveSelections(); + } + return SelectionResult.end; + } + assert(lastSelectionResult == null); + return SelectionResult.end; + } + + /// Initializes the selection of the selectable children. + /// + /// The goal is to find the selectable child that contains the selection edge. + /// Returns [SelectionResult.end] if the selection edge ends on any of the + /// children. Otherwise, it returns [SelectionResult.previous] if the selection + /// does not reach any of its children. Returns [SelectionResult.next] + /// if the selection reaches the end of its children. + /// + /// Ideally, this method should only be called twice at the beginning of the + /// drag selection, once for start edge update event, once for end edge update + /// event. + SelectionResult _initSelection(SelectionEdgeUpdateEvent event, + {required bool isEnd}) { + assert( + (isEnd && currentSelectionEndIndex == -1) || + (!isEnd && currentSelectionStartIndex == -1), + ); + SelectionResult? finalResult; + // Begin the search for the selection edge at the opposite edge if it exists. + final bool hasOppositeEdge = isEnd + ? currentSelectionStartIndex != -1 + : currentSelectionEndIndex != -1; + int newIndex = switch ((isEnd, hasOppositeEdge)) { + (true, true) => currentSelectionStartIndex, + (true, false) => 0, + (false, true) => currentSelectionEndIndex, + (false, false) => 0, + }; + bool? forward; + late SelectionResult currentSelectableResult; + // This loop sends the selection event to one of the following to determine + // the direction of the search. + // - The opposite edge index if it exists. + // - Index 0 if the opposite edge index does not exist. + // + // If the result is `SelectionResult.next`, this loop look backward. + // Otherwise, it looks forward. + // + // The terminate condition are: + // 1. the selectable returns end, pending, none. + // 2. the selectable returns previous when looking forward. + // 2. the selectable returns next when looking backward. + while ( + newIndex < selectables.length && newIndex >= 0 && finalResult == null) { + currentSelectableResult = + dispatchSelectionEventToChild(selectables[newIndex], event); + switch (currentSelectableResult) { + case SelectionResult.end: + case SelectionResult.pending: + case SelectionResult.none: + finalResult = currentSelectableResult; + case SelectionResult.next: + if (forward == false) { + newIndex += 1; + finalResult = SelectionResult.end; + } else if (newIndex == selectables.length - 1) { + finalResult = currentSelectableResult; + } else { + forward = true; + newIndex += 1; + } + case SelectionResult.previous: + if (forward ?? false) { + newIndex -= 1; + finalResult = SelectionResult.end; + } else if (newIndex == 0) { + finalResult = currentSelectableResult; + } else { + forward = false; + newIndex -= 1; + } + } + } + if (isEnd) { + currentSelectionEndIndex = newIndex; + } else { + currentSelectionStartIndex = newIndex; + } + _flushInactiveSelections(); + return finalResult!; + } + + SelectionResult _adjustSelection(SelectionEdgeUpdateEvent event, + {required bool isEnd}) { + assert(() { + if (isEnd) { + assert(currentSelectionEndIndex < selectables.length && + currentSelectionEndIndex >= 0); + return true; + } + assert(currentSelectionStartIndex < selectables.length && + currentSelectionStartIndex >= 0); + return true; + }()); + SelectionResult? finalResult; + // Determines if the edge being adjusted is within the current viewport. + // - If so, we begin the search for the new selection edge position at the + // currentSelectionEndIndex/currentSelectionStartIndex. + // - If not, we attempt to locate the new selection edge starting from + // the opposite end. + // - If neither edge is in the current viewport, the search for the new + // selection edge position begins at 0. + // + // This can happen when there is a scrollable child and the edge being adjusted + // has been scrolled out of view. + final bool isCurrentEdgeWithinViewport = isEnd + ? value.endSelectionPoint != null + : value.startSelectionPoint != null; + final bool isOppositeEdgeWithinViewport = isEnd + ? value.startSelectionPoint != null + : value.endSelectionPoint != null; + int newIndex = switch (( + isEnd, + isCurrentEdgeWithinViewport, + isOppositeEdgeWithinViewport + )) { + (true, true, true) => currentSelectionEndIndex, + (true, true, false) => currentSelectionEndIndex, + (true, false, true) => currentSelectionStartIndex, + (true, false, false) => 0, + (false, true, true) => currentSelectionStartIndex, + (false, true, false) => currentSelectionStartIndex, + (false, false, true) => currentSelectionEndIndex, + (false, false, false) => 0, + }; + bool? forward; + late SelectionResult currentSelectableResult; + // This loop sends the selection event to one of the following to determine + // the direction of the search. + // - currentSelectionEndIndex/currentSelectionStartIndex if the current edge + // is in the current viewport. + // - The opposite edge index if the current edge is not in the current viewport. + // - Index 0 if neither edge is in the current viewport. + // + // If the result is `SelectionResult.next`, this loop look backward. + // Otherwise, it looks forward. + // + // The terminate condition are: + // 1. the selectable returns end, pending, none. + // 2. the selectable returns previous when looking forward. + // 2. the selectable returns next when looking backward. + while ( + newIndex < selectables.length && newIndex >= 0 && finalResult == null) { + currentSelectableResult = + dispatchSelectionEventToChild(selectables[newIndex], event); + switch (currentSelectableResult) { + case SelectionResult.end: + case SelectionResult.pending: + case SelectionResult.none: + finalResult = currentSelectableResult; + case SelectionResult.next: + if (forward == false) { + newIndex += 1; + finalResult = SelectionResult.end; + } else if (newIndex == selectables.length - 1) { + finalResult = currentSelectableResult; + } else { + forward = true; + newIndex += 1; + } + case SelectionResult.previous: + if (forward ?? false) { + newIndex -= 1; + finalResult = SelectionResult.end; + } else if (newIndex == 0) { + finalResult = currentSelectableResult; + } else { + forward = false; + newIndex -= 1; + } + } + } + if (isEnd) { + final bool forwardSelection = + currentSelectionEndIndex >= currentSelectionStartIndex; + if (forward != null && + ((!forwardSelection && + forward && + newIndex >= currentSelectionStartIndex) || + (forwardSelection && + !forward && + newIndex <= currentSelectionStartIndex))) { + currentSelectionStartIndex = currentSelectionEndIndex; + } + currentSelectionEndIndex = newIndex; + } else { + final bool forwardSelection = + currentSelectionEndIndex >= currentSelectionStartIndex; + if (forward != null && + ((!forwardSelection && + !forward && + newIndex <= currentSelectionEndIndex) || + (forwardSelection && + forward && + newIndex >= currentSelectionEndIndex))) { + currentSelectionEndIndex = currentSelectionStartIndex; + } + currentSelectionStartIndex = newIndex; + } + _flushInactiveSelections(); + return finalResult!; + } + + /// The compare function this delegate used for determining the selection + /// order of the [Selectable]s. + /// + /// Sorts the [Selectable]s by their top left [Rect]. + @override + Comparator get compareOrder => _compareScreenOrder; + + static int _compareScreenOrder(Selectable a, Selectable b) { + // Attempt to sort the selectables under a [_SelectableTextContainerDelegate] + // by the top left rect. + final Rect rectA = MatrixUtils.transformRect( + a.getTransformTo(null), a.boundingBoxes.first); + final Rect rectB = MatrixUtils.transformRect( + b.getTransformTo(null), b.boundingBoxes.first); + final int result = _compareVertically(rectA, rectB); + if (result != 0) { + return result; + } + return _compareHorizontally(rectA, rectB); + } + + /// Compares two rectangles in the screen order solely by their vertical + /// positions. + /// + /// Returns positive if a is lower, negative if a is higher, 0 if their + /// order can't be determine solely by their vertical position. + static int _compareVertically(Rect a, Rect b) { + // The rectangles overlap so defer to horizontal comparison. + if ((a.top - b.top < _kSelectableVerticalComparingThreshold && + a.bottom - b.bottom > -_kSelectableVerticalComparingThreshold) || + (b.top - a.top < _kSelectableVerticalComparingThreshold && + b.bottom - a.bottom > -_kSelectableVerticalComparingThreshold)) { + return 0; + } + if ((a.top - b.top).abs() > _kSelectableVerticalComparingThreshold) { + return a.top > b.top ? 1 : -1; + } + return a.bottom > b.bottom ? 1 : -1; + } + + /// Compares two rectangles in the screen order by their horizontal positions + /// assuming one of the rectangles enclose the other rect vertically. + /// + /// Returns positive if a is lower, negative if a is higher. + static int _compareHorizontally(Rect a, Rect b) { + // a encloses b. + if (a.left - b.left < precisionErrorTolerance && + a.right - b.right > -precisionErrorTolerance) { + return -1; + } + // b encloses a. + if (b.left - a.left < precisionErrorTolerance && + b.right - a.right > -precisionErrorTolerance) { + return 1; + } + if ((a.left - b.left).abs() > precisionErrorTolerance) { + return a.left > b.left ? 1 : -1; + } + return a.right > b.right ? 1 : -1; + } + + /// This method calculates a local [SelectedContentRange] based on the list + /// of [selections] that are accumulated from the [Selectable] children under this + /// delegate. This calculation takes into account the accumulated content + /// length before the active selection, and returns null when either selection + /// edge has not been set. + SelectedContentRange? _calculateLocalRange(List<_SelectionInfo> selections) { + if (currentSelectionStartIndex == -1 || currentSelectionEndIndex == -1) { + return null; + } + int startOffset = 0; + int endOffset = 0; + bool foundStart = false; + bool forwardSelection = + currentSelectionEndIndex >= currentSelectionStartIndex; + if (currentSelectionEndIndex == currentSelectionStartIndex) { + // Determining selection direction is innacurate if currentSelectionStartIndex == currentSelectionEndIndex. + // Use the range from the selectable within the selection as the source of truth for selection direction. + final SelectedContentRange rangeAtSelectableInSelection = + selectables[currentSelectionStartIndex].getSelection()!; + forwardSelection = rangeAtSelectableInSelection.endOffset >= + rangeAtSelectableInSelection.startOffset; + } + for (int index = 0; index < selections.length; index++) { + final _SelectionInfo selection = selections[index]; + if (selection.range == null) { + if (foundStart) { + return SelectedContentRange( + startOffset: forwardSelection ? startOffset : endOffset, + endOffset: forwardSelection ? endOffset : startOffset, + ); + } + startOffset += selection.contentLength; + endOffset = startOffset; + continue; + } + final int selectionStartNormalized = min( + selection.range!.startOffset, + selection.range!.endOffset, + ); + final int selectionEndNormalized = max( + selection.range!.startOffset, + selection.range!.endOffset, + ); + if (!foundStart) { + // Because a RenderParagraph may split its content into multiple selectables + // we have to consider at what offset a selectable starts at relative + // to the RenderParagraph, when the selectable is not the start of the content. + final bool shouldConsiderContentStart = index > 0 && + paragraph.selectableBelongsToParagraph(selectables[index]); + startOffset += (selectionStartNormalized - + (shouldConsiderContentStart + ? paragraph + .getPositionForOffset( + selectables[index].boundingBoxes.first.centerLeft) + .offset + : 0)) + .abs(); + endOffset = startOffset + + (selectionEndNormalized - selectionStartNormalized).abs(); + foundStart = true; + } else { + endOffset += (selectionEndNormalized - selectionStartNormalized).abs(); + } + } + assert( + foundStart, + 'The start of the selection has not been found despite this selection delegate having an existing currentSelectionStartIndex and currentSelectionEndIndex.', + ); + return SelectedContentRange( + startOffset: forwardSelection ? startOffset : endOffset, + endOffset: forwardSelection ? endOffset : startOffset, + ); + } + + /// Returns a [SelectedContentRange] considering the [SelectedContentRange] + /// from each [Selectable] child managed under this delegate. + /// + /// When nothing is selected or either selection edge has not been set, + /// this method will return `null`. + @override + SelectedContentRange? getSelection() { + final List<_SelectionInfo> selections = <_SelectionInfo>[ + for (final Selectable selectable in selectables) + ( + contentLength: selectable.contentLength, + range: selectable.getSelection() + ), + ]; + return _calculateLocalRange(selections); + } + + // From [SelectableRegion]. + + // Clears the selection on all selectables not in the range of + // currentSelectionStartIndex..currentSelectionEndIndex. + // + // If one of the edges does not exist, then this method will clear the selection + // in all selectables except the existing edge. + // + // If neither of the edges exist this method immediately returns. + void _flushInactiveSelections() { + if (currentSelectionStartIndex == -1 && currentSelectionEndIndex == -1) { + return; + } + if (currentSelectionStartIndex == -1 || currentSelectionEndIndex == -1) { + final int skipIndex = currentSelectionStartIndex == -1 + ? currentSelectionEndIndex + : currentSelectionStartIndex; + selectables + .where((Selectable target) => target != selectables[skipIndex]) + .forEach( + (Selectable target) => dispatchSelectionEventToChild( + target, const ClearSelectionEvent()), + ); + return; + } + final int skipStart = + min(currentSelectionStartIndex, currentSelectionEndIndex); + final int skipEnd = + max(currentSelectionStartIndex, currentSelectionEndIndex); + for (int index = 0; index < selectables.length; index += 1) { + if (index >= skipStart && index <= skipEnd) { + continue; + } + dispatchSelectionEventToChild( + selectables[index], const ClearSelectionEvent()); + } + } + + @override + SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) { + if (event.granularity != TextGranularity.paragraph) { + return super.handleSelectionEdgeUpdate(event); + } + updateLastSelectionEdgeLocation( + globalSelectionEdgeLocation: event.globalPosition, + forEnd: event.type == SelectionEventType.endEdgeUpdate, + ); + if (event.type == SelectionEventType.endEdgeUpdate) { + return currentSelectionEndIndex == -1 + ? _initSelection(event, isEnd: true) + : _adjustSelection(event, isEnd: true); + } + return currentSelectionStartIndex == -1 + ? _initSelection(event, isEnd: false) + : _adjustSelection(event, isEnd: false); + } +} + +/// The length of the content that can be selected, and the range that is +/// selected. +typedef _SelectionInfo = ({int contentLength, SelectedContentRange? range}); diff --git a/lib/pages/common/common_publish_page.dart b/lib/pages/common/common_publish_page.dart index 9c812d27..326ded82 100644 --- a/lib/pages/common/common_publish_page.dart +++ b/lib/pages/common/common_publish_page.dart @@ -495,10 +495,11 @@ abstract class CommonPublishPageState void onChanged(String value) { bool isEmpty = value.trim().isEmpty; - if (!isEmpty && !enablePublish.value) { - enablePublish.value = true; - } else if (isEmpty && enablePublish.value) { + if (isEmpty) { enablePublish.value = false; + mentions?.clear(); + } else { + enablePublish.value = true; } widget.onSave?.call((text: value, mentions: mentions)); } diff --git a/lib/pages/dynamics/widgets/author_panel.dart b/lib/pages/dynamics/widgets/author_panel.dart index 35590650..f0595479 100644 --- a/lib/pages/dynamics/widgets/author_panel.dart +++ b/lib/pages/dynamics/widgets/author_panel.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/dialog/report.dart'; import 'package:PiliPlus/common/widgets/pendant_avatar.dart'; import 'package:PiliPlus/http/constants.dart'; @@ -243,10 +244,7 @@ class AuthorPanel extends StatelessWidget { children: [ InkWell( onTap: Get.back, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(18), - topRight: Radius.circular(18), - ), + borderRadius: StyleString.bottomSheetRadius, child: Container( height: 35, padding: const EdgeInsets.only(bottom: 2), diff --git a/lib/pages/dynamics/widgets/content_panel.dart b/lib/pages/dynamics/widgets/content_panel.dart index 3d622dd6..259b2a96 100644 --- a/lib/pages/dynamics/widgets/content_panel.dart +++ b/lib/pages/dynamics/widgets/content_panel.dart @@ -1,6 +1,7 @@ // 内容 import 'package:PiliPlus/common/widgets/custom_icon.dart'; import 'package:PiliPlus/common/widgets/image/image_view.dart'; +import 'package:PiliPlus/common/widgets/text/text.dart' as custom_text; import 'package:PiliPlus/models/dynamics/result.dart'; import 'package:PiliPlus/pages/dynamics/widgets/rich_node_panel.dart'; import 'package:flutter/material.dart'; @@ -68,13 +69,12 @@ Widget content( ? const TextStyle(fontSize: 15) : const TextStyle(fontSize: 16), ) - : Text.rich( + : custom_text.Text.rich( style: floor == 1 ? const TextStyle(fontSize: 15) : const TextStyle(fontSize: 14), richNodes, maxLines: isSave ? null : 6, - overflow: isSave ? null : TextOverflow.ellipsis, ), if (item.modules.moduleDynamic?.major?.opus?.pics?.isNotEmpty == true) LayoutBuilder( diff --git a/lib/pages/setting/models/extra_settings.dart b/lib/pages/setting/models/extra_settings.dart index be84bb8f..48bb4f1b 100644 --- a/lib/pages/setting/models/extra_settings.dart +++ b/lib/pages/setting/models/extra_settings.dart @@ -205,11 +205,12 @@ List get extraSettings => [ TextButton( onPressed: () async { Get.back(); + int length = int.tryParse(replyLengthLimit) ?? 6; ReplyItemGrpc.replyLengthLimit = - int.tryParse(replyLengthLimit) ?? 6; + length == 0 ? null : length; await GStorage.setting.put( SettingBoxKey.replyLengthLimit, - ReplyItemGrpc.replyLengthLimit, + length, ); setState(); }, diff --git a/lib/pages/video/reply/widgets/reply_item_grpc.dart b/lib/pages/video/reply/widgets/reply_item_grpc.dart index de507eb6..e66610c2 100644 --- a/lib/pages/video/reply/widgets/reply_item_grpc.dart +++ b/lib/pages/video/reply/widgets/reply_item_grpc.dart @@ -6,6 +6,7 @@ import 'package:PiliPlus/common/widgets/dialog/report.dart'; import 'package:PiliPlus/common/widgets/image/image_view.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/pendant_avatar.dart'; +import 'package:PiliPlus/common/widgets/text/text.dart' as custom_text; import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' show ReplyInfo, ReplyControl, Content; import 'package:PiliPlus/http/init.dart'; @@ -13,7 +14,6 @@ import 'package:PiliPlus/http/video.dart'; import 'package:PiliPlus/models/common/badge_type.dart'; import 'package:PiliPlus/models/common/image_type.dart'; import 'package:PiliPlus/pages/dynamics/widgets/vote.dart'; -import 'package:PiliPlus/pages/save_panel/view.dart'; import 'package:PiliPlus/pages/video/controller.dart'; import 'package:PiliPlus/pages/video/reply/widgets/zan_grpc.dart'; import 'package:PiliPlus/utils/accounts.dart'; @@ -68,10 +68,10 @@ class ReplyItemGrpc extends StatelessWidget { final ValueChanged? onCheckReply; final ValueChanged? onToggleTop; - static final _voteRegExp = RegExp(r"\{vote:\d+?\}"); + static final _voteRegExp = RegExp(r"^\{vote:\d+?\}$"); static final _timeRegExp = RegExp(r'^\b(?:\d+[::])?\d+[::]\d+\b$'); static bool enableWordRe = Pref.enableWordRe; - static int replyLengthLimit = Pref.replyLengthLimit; + static int? replyLengthLimit = Pref.replyLengthLimit; @override Widget build(BuildContext context) { @@ -181,6 +181,10 @@ class ReplyItemGrpc extends StatelessWidget { ); Widget content(BuildContext context, ThemeData theme) { + final padding = EdgeInsets.only( + left: replyLevel == 0 ? 6 : 45, + right: 6, + ); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -218,7 +222,6 @@ class ReplyItemGrpc extends StatelessWidget { Image.asset( 'assets/images/lv/lv${replyItem.member.isSeniorMember == 1 ? '6_s' : replyItem.member.level}.png', height: 11, - semanticLabel: "等级:${replyItem.member.level}", ), if (replyItem.mid == upMid) const PBadge( @@ -255,69 +258,69 @@ class ReplyItemGrpc extends StatelessWidget { ], ), ), - // title + const SizedBox(height: 10), Padding( - padding: EdgeInsets.only( - top: 10, - left: replyLevel == 0 ? 6 : 45, - right: 6, - bottom: 4, - ), - child: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - String text = replyItem.content.message; - TextStyle style = TextStyle( - height: 1.75, - fontSize: theme.textTheme.bodyMedium!.fontSize, - ); - TextPainter? textPainter; - bool? didExceedMaxLines; - if (replyLevel == 1 && replyLengthLimit != 0) { - textPainter = TextPainter( - text: TextSpan(text: text, style: style), - maxLines: replyLengthLimit, - textDirection: Directionality.of(context), - )..layout(maxWidth: constraints.maxWidth); - didExceedMaxLines = textPainter.didExceedMaxLines; - } - return Semantics( - label: text, - child: Text.rich( - style: style, - TextSpan( - children: [ - if (replyItem.replyControl.isUpTop) ...[ - const WidgetSpan( - alignment: PlaceholderAlignment.top, - child: PBadge( - text: 'TOP', - size: PBadgeSize.small, - isStack: false, - type: PBadgeType.line_primary, - fontSize: 9, - textScaleFactor: 1, - ), - ), - const TextSpan(text: ' '), - ], - buildContent( - context, - theme, - replyItem, - null, - textPainter, - didExceedMaxLines, - ), - ], + padding: padding, + child: custom_text.Text.rich( + style: TextStyle( + height: 1.75, + fontSize: theme.textTheme.bodyMedium!.fontSize, + ), + maxLines: replyLevel == 1 ? replyLengthLimit : null, + TextSpan( + children: [ + if (replyItem.replyControl.isUpTop) ...[ + const WidgetSpan( + alignment: PlaceholderAlignment.top, + child: PBadge( + text: 'TOP', + size: PBadgeSize.small, + isStack: false, + type: PBadgeType.line_primary, + fontSize: 9, + textScaleFactor: 1, + ), ), + const TextSpan(text: ' '), + ], + buildContent( + context, + theme, + replyItem, + null, ), - ); - }, + ], + ), ), ), + if (replyItem.content.pictures.isNotEmpty) ...[ + const SizedBox(height: 4), + Padding( + padding: padding, + child: LayoutBuilder( + builder: (context, constraints) => imageView( + constraints.maxWidth, + replyItem.content.pictures + .map( + (item) => ImageModel( + width: item.imgWidth, + height: item.imgHeight, + url: item.imgSrc, + ), + ) + .toList(), + onViewImage: onViewImage, + onDismissed: onDismissed, + callback: callback, + ), + ), + ), + ], // 操作区域 - if (replyLevel != 0) + if (replyLevel != 0) ...[ + const SizedBox(height: 4), buttonAction(context, theme, replyItem.replyControl), + ], // 一楼的评论 if (replyLevel == 1 && replyItem.count > Int64.ZERO) ...[ Padding( @@ -472,64 +475,57 @@ class ReplyItemGrpc extends StatelessWidget { child: Container( width: double.infinity, padding: padding, - child: Semantics( - label: - '${childReply.member.name} ${childReply.content.message}', - excludeSemantics: true, - child: Text.rich( - style: TextStyle( - fontSize: theme.textTheme.bodyMedium!.fontSize, - color: theme.colorScheme.onSurface - .withValues(alpha: 0.85), - height: 1.6), - overflow: TextOverflow.ellipsis, - maxLines: 2, - TextSpan( - children: [ - TextSpan( - text: childReply.member.name, - style: TextStyle( - color: theme.colorScheme.primary, + child: Text.rich( + style: TextStyle( + fontSize: theme.textTheme.bodyMedium!.fontSize, + color: theme.colorScheme.onSurface + .withValues(alpha: 0.85), + height: 1.6), + overflow: TextOverflow.ellipsis, + maxLines: 2, + TextSpan( + children: [ + TextSpan( + text: childReply.member.name, + style: TextStyle( + color: theme.colorScheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + feedBack(); + Get.toNamed( + '/member?mid=${childReply.member.mid}', + ); + }, + ), + if (childReply.mid == upMid) ...[ + const TextSpan(text: ' '), + const WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: PBadge( + text: 'UP', + size: PBadgeSize.small, + isStack: false, + fontSize: 9, + textScaleFactor: 1, ), - recognizer: TapGestureRecognizer() - ..onTap = () { - feedBack(); - Get.toNamed( - '/member?mid=${childReply.member.mid}', - ); - }, - ), - if (childReply.mid == upMid) ...[ - const TextSpan(text: ' '), - const WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: PBadge( - text: 'UP', - size: PBadgeSize.small, - isStack: false, - fontSize: 9, - textScaleFactor: 1, - ), - ), - const TextSpan(text: ' '), - ], - TextSpan( - text: childReply.root == childReply.parent - ? ': ' - : childReply.mid == upMid - ? '' - : ' ', - ), - buildContent( - context, - theme, - childReply, - replyItem, - null, - null, ), + const TextSpan(text: ' '), ], - ), + TextSpan( + text: childReply.root == childReply.parent + ? ': ' + : childReply.mid == upMid + ? '' + : ' ', + ), + buildContent( + context, + theme, + childReply, + replyItem, + ), + ], ), ), ), @@ -580,91 +576,68 @@ class ReplyItemGrpc extends StatelessWidget { ThemeData theme, ReplyInfo replyItem, ReplyInfo? fReplyItem, - TextPainter? textPainter, - bool? didExceedMaxLines, ) { - final String routePath = Get.currentRoute; - bool isVideoPage = routePath.startsWith('/video'); - // replyItem 当前回复内容 // replyReply 查看二楼回复(回复详情)回调 // fReplyItem 父级回复内容,用作二楼回复(回复详情)展示 final Content content = replyItem.content; - String message = content.message; final List spanChildren = []; - if (didExceedMaxLines == true) { - final textSize = textPainter!.size; - final double maxHeight = textPainter.preferredLineHeight * 6; - var position = textPainter.getPositionForOffset( - Offset(textSize.width, maxHeight), + if (content.hasRichText()) { + spanChildren.add( + TextSpan( + text: '[笔记] ', + style: TextStyle( + color: theme.colorScheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = + () => PageUtils.handleWebview(content.richText.note.clickUrl), + ), ); - message = message.substring(0, position.offset); } - // 投票 - if (content.hasVote()) { - message = message.replaceAllMapped(_voteRegExp, (Match match) { - spanChildren.add( - TextSpan( - text: '投票: ${content.vote.title}', - style: TextStyle( - color: theme.colorScheme.primary, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => showVoteDialog(context, content.vote.id.toInt()), - ), - ); - return ''; - }); - } // 构建正则表达式 final List specialTokens = [ ...content.emotes.keys, ...content.topics.keys.map((e) => '#$e#'), ...content.atNameToMid.keys.map((e) => '@$e'), + ...content.urls.keys, ]; - List jumpUrlKeysList = content.urls.keys.map((String e) { - return e; - }).toList(); - specialTokens.sort((a, b) => b.length.compareTo(a.length)); - String patternStr = specialTokens.map(RegExp.escape).join('|'); - if (patternStr.isNotEmpty) { - patternStr += "|"; - } - patternStr += r'(\b(?:\d+[::])?\d+[::]\d+\b)'; - if (jumpUrlKeysList.isNotEmpty) { - patternStr += '|${jumpUrlKeysList.map(RegExp.escape).join('|')}'; - } - patternStr += '|${Constants.urlPattern}'; + String patternStr = [ + ...specialTokens.map(RegExp.escape), + r'(\b(?:\d+[::])?\d+[::]\d+\b)', + r'(\{vote:\d+?\})', + Constants.urlPattern, + ].join('|'); final RegExp pattern = RegExp(patternStr); - List matchedStrs = []; + + late List matchedStrs = []; + void addPlainTextSpan(str) { - spanChildren.add(TextSpan( - text: str, - )); + spanChildren.add(TextSpan(text: str)); } // 分割文本并处理每个部分 - message.splitMapJoin( + content.message.splitMapJoin( pattern, onMatch: (Match match) { String matchStr = match[0]!; if (content.emotes.containsKey(matchStr)) { // 处理表情 final int size = content.emotes[matchStr]!.size.toInt(); - spanChildren.add(WidgetSpan( - child: ExcludeSemantics( - child: NetworkImgLayer( - src: content.emotes[matchStr]?.hasGifUrl() == true - ? content.emotes[matchStr]?.gifUrl - : content.emotes[matchStr]?.url, - type: ImageType.emote, - width: size * 20, - height: size * 20, - semanticsLabel: matchStr, - )), - )); + spanChildren.add( + WidgetSpan( + child: NetworkImgLayer( + src: content.emotes[matchStr]?.hasGifUrl() == true + ? content.emotes[matchStr]?.gifUrl + : content.emotes[matchStr]?.url, + type: ImageType.emote, + width: size * 20, + height: size * 20, + ), + ), + ); } else if (matchStr.startsWith("@") && content.atNameToMid.containsKey(matchStr.substring(1))) { // 处理@用户 @@ -680,6 +653,18 @@ class ReplyItemGrpc extends StatelessWidget { ..onTap = () => Get.toNamed('/member?mid=$userId'), ), ); + } else if (_voteRegExp.hasMatch(matchStr)) { + spanChildren.add( + TextSpan( + text: '投票: ${content.vote.title}', + style: TextStyle( + color: theme.colorScheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = + () => showVoteDialog(context, content.vote.id.toInt()), + ), + ); } else if (_timeRegExp.hasMatch(matchStr)) { matchStr = matchStr.replaceAll(':', ':'); bool isValid = false; @@ -702,6 +687,7 @@ class ReplyItemGrpc extends StatelessWidget { } catch (e) { if (kDebugMode) debugPrint('failed to validate: $e'); } + bool isVideoPage = Get.currentRoute.startsWith('/video'); spanChildren.add( TextSpan( text: isValid ? ' $matchStr ' : matchStr, @@ -905,58 +891,6 @@ class ReplyItemGrpc extends StatelessWidget { } } - if (didExceedMaxLines == true) { - spanChildren.add( - TextSpan( - text: '\n查看更多', - style: TextStyle( - color: theme.colorScheme.primary, - ), - ), - ); - } - - // 图片渲染 - if (content.pictures.isNotEmpty) { - spanChildren - ..add(const TextSpan(text: '\n')) - ..add( - WidgetSpan( - child: LayoutBuilder( - builder: (context, constraints) => imageView( - constraints.maxWidth, - content.pictures - .map( - (item) => ImageModel( - width: item.imgWidth, - height: item.imgHeight, - url: item.imgSrc, - ), - ) - .toList(), - onViewImage: onViewImage, - onDismissed: onDismissed, - callback: callback, - ), - ), - ), - ); - } - - // 笔记链接 - if (content.hasRichText()) { - spanChildren.add( - TextSpan( - text: ' 笔记', - style: TextStyle( - color: theme.colorScheme.primary, - ), - recognizer: TapGestureRecognizer() - ..onTap = - () => PageUtils.handleWebview(content.richText.note.clickUrl), - ), - ); - } return TextSpan(children: spanChildren); } @@ -966,151 +900,22 @@ class ReplyItemGrpc extends StatelessWidget { required VoidCallback onDelete, required bool isSubReply, }) { + late String message = item.content.message; final ownerMid = Int64(Accounts.main.mid); - Future menuActionHandler(String type) async { - late String message = item.content.message; - switch (type) { - case 'report': - Get.back(); - autoWrapReportDialog( - context, - ReportOptions.commentReport, - (reasonType, reasonDesc, banUid) async { - final res = await Request().post( - '/x/v2/reply/report', - data: { - 'add_blacklist': banUid, - 'csrf': Accounts.main.csrf, - 'gaia_source': 'main_h5', - 'oid': item.oid, - 'platform': 'android', - 'reason': reasonType, - 'rpid': item.id, - 'scene': 'main', - 'type': 1, - if (reasonType == 0) 'content': reasonDesc! - }, - options: - Options(contentType: Headers.formUrlEncodedContentType), - ); - if (res.data['code'] == 0) { - onDelete(); - } - return res.data as Map; - }, - ); - break; - case 'copyAll': - Get.back(); - Utils.copyText(message); - break; - case 'copyFreedom': - Get.back(); - showDialog( - context: context, - builder: (context) { - return Dialog( - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: SelectableText(message), - ), - ); - }, - ); - break; - case 'delete': - Get.back(); - bool? isDelete = await showDialog( - context: context, - builder: (context) { - final theme = Theme.of(context); - return AlertDialog( - title: const Text('删除评论'), - content: Text.rich( - TextSpan( - children: [ - const TextSpan(text: '确定删除这条评论吗?\n\n'), - if (ownerMid != item.member.mid.toInt()) ...[ - TextSpan( - text: '@${item.member.name}', - style: TextStyle( - color: theme.colorScheme.primary, - ), - ), - const TextSpan(text: ':\n'), - ], - TextSpan(text: message), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Get.back(result: false), - child: Text( - '取消', - style: TextStyle( - color: theme.colorScheme.outline, - ), - ), - ), - TextButton( - onPressed: () => Get.back(result: true), - child: const Text('确定'), - ), - ], - ); - }, - ); - if (isDelete == null || !isDelete) { - return; - } - SmartDialog.showLoading(msg: '删除中...'); - var result = await VideoHttp.replyDel( - type: item.type.toInt(), - oid: item.oid.toInt(), - rpid: item.id.toInt(), - ); - SmartDialog.dismiss(); - if (result['status']) { - SmartDialog.showToast('删除成功'); - onDelete(); - } else { - SmartDialog.showToast('删除失败, ${result["msg"]}'); - } - break; - case 'checkReply': - Get.back(); - onCheckReply?.call(item); - break; - case 'top': - Get.back(); - onToggleTop?.call(item); - break; - case 'saveReply': - Get.back(); - SavePanel.toSavePanel(upMid: upMid, item: item); - break; - default: - } - } - final theme = Theme.of(context); final errorColor = theme.colorScheme.error; final style = theme.textTheme.titleSmall; return Padding( - padding: - EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom + 20), + padding: EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom + 20, + ), child: Column( mainAxisSize: MainAxisSize.min, children: [ InkWell( onTap: Get.back, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(18), - topRight: Radius.circular(18), - ), + borderRadius: StyleString.bottomSheetRadius, child: Container( height: 35, padding: const EdgeInsets.only(bottom: 2), @@ -1119,29 +924,120 @@ class ReplyItemGrpc extends StatelessWidget { width: 32, height: 3, decoration: BoxDecoration( - color: theme.colorScheme.outline, - borderRadius: const BorderRadius.all(Radius.circular(3))), + color: theme.colorScheme.outline, + borderRadius: const BorderRadius.all(Radius.circular(3)), + ), ), ), ), ), if (ownerMid == upMid || ownerMid == item.member.mid) ListTile( - onTap: () => menuActionHandler('delete'), + onTap: () async { + bool? isDelete = await showDialog( + context: context, + builder: (context) { + final theme = Theme.of(context); + return AlertDialog( + title: const Text('删除评论'), + content: Text.rich( + TextSpan( + children: [ + const TextSpan(text: '确定删除这条评论吗?\n\n'), + if (ownerMid != item.member.mid.toInt()) ...[ + TextSpan( + text: '@${item.member.name}', + style: TextStyle( + color: theme.colorScheme.primary, + ), + ), + const TextSpan(text: ':\n'), + ], + TextSpan(text: message), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: Text( + '取消', + style: TextStyle( + color: theme.colorScheme.outline, + ), + ), + ), + TextButton( + onPressed: () => Get.back(result: true), + child: const Text('确定'), + ), + ], + ); + }, + ); + if (isDelete == null || !isDelete) { + return; + } + SmartDialog.showLoading(msg: '删除中...'); + var result = await VideoHttp.replyDel( + type: item.type.toInt(), + oid: item.oid.toInt(), + rpid: item.id.toInt(), + ); + SmartDialog.dismiss(); + if (result['status']) { + SmartDialog.showToast('删除成功'); + onDelete(); + } else { + SmartDialog.showToast('删除失败, ${result["msg"]}'); + } + }, minLeadingWidth: 0, leading: Icon(Icons.delete_outlined, color: errorColor, size: 19), title: Text('删除', style: style!.copyWith(color: errorColor)), ), if (ownerMid != Int64.ZERO) ListTile( - onTap: () => menuActionHandler('report'), + onTap: () { + Get.back(); + autoWrapReportDialog( + context, + ReportOptions.commentReport, + (reasonType, reasonDesc, banUid) async { + final res = await Request().post( + '/x/v2/reply/report', + data: { + 'add_blacklist': banUid, + 'csrf': Accounts.main.csrf, + 'gaia_source': 'main_h5', + 'oid': item.oid, + 'platform': 'android', + 'reason': reasonType, + 'rpid': item.id, + 'scene': 'main', + 'type': 1, + if (reasonType == 0) 'content': reasonDesc! + }, + options: Options( + contentType: Headers.formUrlEncodedContentType), + ); + if (res.data['code'] == 0) { + onDelete(); + } + return res.data as Map; + }, + ); + }, minLeadingWidth: 0, leading: Icon(Icons.error_outline, color: errorColor, size: 19), title: Text('举报', style: style!.copyWith(color: errorColor)), ), if (replyLevel == 1 && !isSubReply && ownerMid == upMid) ListTile( - onTap: () => menuActionHandler('top'), + onTap: () { + Get.back(); + onToggleTop?.call(item); + }, minLeadingWidth: 0, leading: const Icon(Icons.vertical_align_top, size: 19), title: Text( @@ -1150,26 +1046,104 @@ class ReplyItemGrpc extends StatelessWidget { ), ), ListTile( - onTap: () => menuActionHandler('copyAll'), + onTap: () { + Get.back(); + Utils.copyText(message); + }, minLeadingWidth: 0, leading: const Icon(Icons.copy_all_outlined, size: 19), title: Text('复制全部', style: style), ), ListTile( - onTap: () => menuActionHandler('copyFreedom'), + onTap: () { + Get.back(); + showDialog( + context: context, + builder: (context) { + return Dialog( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 16), + child: SelectableText(message), + ), + ); + }, + ); + }, minLeadingWidth: 0, leading: const Icon(Icons.copy_outlined, size: 19), title: Text('自由复制', style: style), ), ListTile( - onTap: () => menuActionHandler('saveReply'), + onTap: () async { + Get.back(); + bool? isDelete = await showDialog( + context: context, + builder: (context) { + final theme = Theme.of(context); + return AlertDialog( + title: const Text('删除评论'), + content: Text.rich( + TextSpan( + children: [ + const TextSpan(text: '确定删除这条评论吗?\n\n'), + if (ownerMid != item.member.mid.toInt()) ...[ + TextSpan( + text: '@${item.member.name}', + style: TextStyle( + color: theme.colorScheme.primary, + ), + ), + const TextSpan(text: ':\n'), + ], + TextSpan(text: message), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: Text( + '取消', + style: TextStyle( + color: theme.colorScheme.outline, + ), + ), + ), + TextButton( + onPressed: () => Get.back(result: true), + child: const Text('确定'), + ), + ], + ); + }, + ); + if (isDelete == true) { + SmartDialog.showLoading(msg: '删除中...'); + var result = await VideoHttp.replyDel( + type: item.type.toInt(), + oid: item.oid.toInt(), + rpid: item.id.toInt(), + ); + SmartDialog.dismiss(); + if (result['status']) { + SmartDialog.showToast('删除成功'); + onDelete(); + } else { + SmartDialog.showToast('删除失败, ${result["msg"]}'); + } + } + }, minLeadingWidth: 0, leading: const Icon(Icons.save_alt, size: 19), title: Text('保存评论', style: style), ), if (item.mid == ownerMid) ListTile( - onTap: () => menuActionHandler('checkReply'), + onTap: () { + Get.back(); + onCheckReply?.call(item); + }, minLeadingWidth: 0, leading: const Stack( clipBehavior: Clip.none, diff --git a/lib/utils/storage_pref.dart b/lib/utils/storage_pref.dart index 0c19e2d3..46f2351b 100644 --- a/lib/utils/storage_pref.dart +++ b/lib/utils/storage_pref.dart @@ -305,8 +305,13 @@ class Pref { static bool get horizontalMemberPage => _setting.get(SettingBoxKey.horizontalMemberPage, defaultValue: false); - static int get replyLengthLimit => - _setting.get(SettingBoxKey.replyLengthLimit, defaultValue: 6); + static int? get replyLengthLimit { + int length = _setting.get(SettingBoxKey.replyLengthLimit, defaultValue: 6); + if (length <= 0) { + return null; + } + return length; + } static int get defaultPicQa => _setting.get(SettingBoxKey.defaultPicQa, defaultValue: 10); diff --git a/lib/utils/theme_utils.dart b/lib/utils/theme_utils.dart index e961f72b..5393e368 100644 --- a/lib/utils/theme_utils.dart +++ b/lib/utils/theme_utils.dart @@ -1,3 +1,4 @@ +import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/main.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; @@ -91,10 +92,7 @@ class ThemeUtils { bottomSheetTheme: BottomSheetThemeData( backgroundColor: colorScheme.surface, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(18), - topRight: Radius.circular(18), - ), + borderRadius: StyleString.bottomSheetRadius, ), ), // ignore: deprecated_member_use