mirror of
https://github.com/bggRGjQaUbCoE/PiliPlus.git
synced 2026-08-06 02:30:12 +08:00
@@ -14,14 +14,10 @@
|
||||
/// @docImport 'widget_span.dart';
|
||||
library;
|
||||
|
||||
import 'dart:math';
|
||||
import 'dart:ui' as ui show TextHeightBehavior;
|
||||
|
||||
import 'package:PiliPlus/common/widgets/flutter/text/paragraph.dart';
|
||||
import 'package:PiliPlus/common/widgets/flutter/text/rich_text.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart' hide Text, RichText;
|
||||
import 'package:flutter/rendering.dart' hide RenderParagraph;
|
||||
import 'package:PiliPlus/common/widgets/more_text/rich_text_more.dart';
|
||||
import 'package:flutter/material.dart' hide Text;
|
||||
|
||||
/// A run of text with a single style.
|
||||
///
|
||||
@@ -395,7 +391,7 @@ class Text extends StatelessWidget {
|
||||
);
|
||||
final double? wordSpacing = MediaQuery.maybeWordSpacingOverrideOf(context);
|
||||
final TextSpan effectiveTextSpan =
|
||||
_OverridingTextStyleTextSpanUtils.applyTextSpacingOverrides(
|
||||
OverridingTextStyleTextSpanUtils.applyTextSpacingOverrides(
|
||||
lineHeightScaleFactor: lineHeightScaleFactor,
|
||||
letterSpacing: letterSpacing,
|
||||
wordSpacing: wordSpacing,
|
||||
@@ -409,7 +405,6 @@ class Text extends StatelessWidget {
|
||||
final StrutStyle? effectiveStrutStyle = strutStyle?.merge(
|
||||
StrutStyle(height: lineHeightScaleFactor),
|
||||
);
|
||||
final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context);
|
||||
final TextScaler textScaler = switch ((this.textScaler, textScaleFactor)) {
|
||||
(final TextScaler textScaler, _) => textScaler,
|
||||
// For unmigrated apps, fall back to textScaleFactor.
|
||||
@@ -418,68 +413,31 @@ class Text extends StatelessWidget {
|
||||
),
|
||||
(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: effectiveStrutStyle,
|
||||
textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis,
|
||||
textHeightBehavior:
|
||||
textHeightBehavior ??
|
||||
defaultTextStyle.textHeightBehavior ??
|
||||
DefaultTextHeightBehavior.maybeOf(context),
|
||||
selectionColor:
|
||||
selectionColor ??
|
||||
DefaultSelectionStyle.of(context).selectionColor ??
|
||||
DefaultSelectionStyle.defaultColor,
|
||||
text: effectiveTextSpan,
|
||||
primary: primary,
|
||||
),
|
||||
);
|
||||
} 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: effectiveStrutStyle,
|
||||
textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis,
|
||||
textHeightBehavior:
|
||||
textHeightBehavior ??
|
||||
defaultTextStyle.textHeightBehavior ??
|
||||
DefaultTextHeightBehavior.maybeOf(context),
|
||||
selectionColor:
|
||||
selectionColor ??
|
||||
DefaultSelectionStyle.of(context).selectionColor ??
|
||||
DefaultSelectionStyle.defaultColor,
|
||||
text: effectiveTextSpan,
|
||||
primary: primary,
|
||||
onShowMore: onShowMore,
|
||||
);
|
||||
}
|
||||
Widget result = RichTextMore(
|
||||
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: effectiveStrutStyle,
|
||||
textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis,
|
||||
textHeightBehavior:
|
||||
textHeightBehavior ??
|
||||
defaultTextStyle.textHeightBehavior ??
|
||||
DefaultTextHeightBehavior.maybeOf(context),
|
||||
selectionColor:
|
||||
selectionColor ??
|
||||
DefaultSelectionStyle.of(context).selectionColor ??
|
||||
DefaultSelectionStyle.defaultColor,
|
||||
text: effectiveTextSpan,
|
||||
primary: primary,
|
||||
onShowMore: onShowMore,
|
||||
);
|
||||
if (semanticsLabel != null || semanticsIdentifier != null) {
|
||||
result = Semantics(
|
||||
textDirection: textDirection,
|
||||
@@ -493,853 +451,4 @@ class Text extends StatelessWidget {
|
||||
}
|
||||
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', textAlign, defaultValue: null),
|
||||
)
|
||||
..add(
|
||||
EnumProperty<TextDirection>(
|
||||
'textDirection',
|
||||
textDirection,
|
||||
defaultValue: null,
|
||||
),
|
||||
)
|
||||
..add(
|
||||
DiagnosticsProperty<Locale>('locale', locale, defaultValue: null),
|
||||
)
|
||||
..add(
|
||||
FlagProperty(
|
||||
'softWrap',
|
||||
value: softWrap,
|
||||
ifTrue: 'wrapping at box width',
|
||||
ifFalse: 'no wrapping except at line break characters',
|
||||
showName: true,
|
||||
),
|
||||
)
|
||||
..add(
|
||||
EnumProperty<TextOverflow>('overflow', overflow, defaultValue: null),
|
||||
)
|
||||
..add(
|
||||
DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null),
|
||||
)
|
||||
..add(IntProperty('maxLines', maxLines, defaultValue: null))
|
||||
..add(
|
||||
EnumProperty<TextWidthBasis>(
|
||||
'textWidthBasis',
|
||||
textWidthBasis,
|
||||
defaultValue: null,
|
||||
),
|
||||
)
|
||||
..add(
|
||||
DiagnosticsProperty<ui.TextHeightBehavior>(
|
||||
'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,
|
||||
required this.primary,
|
||||
});
|
||||
|
||||
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;
|
||||
final Color primary;
|
||||
|
||||
@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,
|
||||
primary: widget.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
required this.primary,
|
||||
});
|
||||
|
||||
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;
|
||||
final Color primary;
|
||||
|
||||
@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,
|
||||
primary: primary,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (var 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 (var 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;
|
||||
var foundStart = false;
|
||||
int? lastNextIndex;
|
||||
for (var 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],
|
||||
);
|
||||
var startIndex = 0;
|
||||
if (lastNextIndex != null && selectionAtStartOfSelectable) {
|
||||
startIndex = lastNextIndex + 1;
|
||||
} else {
|
||||
startIndex = lastNextIndex == null && selectionAtStartOfSelectable
|
||||
? 0
|
||||
: index;
|
||||
}
|
||||
for (var 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 (var 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 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 isCurrentEdgeWithinViewport = isEnd
|
||||
? value.endSelectionPoint != null
|
||||
: value.startSelectionPoint != null;
|
||||
final 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<Selectable> 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;
|
||||
}
|
||||
var startOffset = 0;
|
||||
var endOffset = 0;
|
||||
var foundStart = false;
|
||||
bool forwardSelection =
|
||||
currentSelectionEndIndex >= currentSelectionStartIndex;
|
||||
if (currentSelectionEndIndex == currentSelectionStartIndex) {
|
||||
// Determining selection direction is inaccurate 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 (var 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 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 (var 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});
|
||||
|
||||
/// A utility class for overriding the text styles of a [TextSpan] tree.
|
||||
// When changes are made to this class, the equivalent API in editable_text.dart
|
||||
// must also be updated.
|
||||
// TODO(Renzo-Olivares): Remove after investigating a solution for overriding all
|
||||
// styles for children in an [InlineSpan] tree, see: https://github.com/flutter/flutter/issues/177952.
|
||||
class _OverridingTextStyleTextSpanUtils {
|
||||
static TextSpan applyTextSpacingOverrides({
|
||||
double? lineHeightScaleFactor,
|
||||
double? letterSpacing,
|
||||
double? wordSpacing,
|
||||
required TextSpan textSpan,
|
||||
}) {
|
||||
if (lineHeightScaleFactor == null &&
|
||||
letterSpacing == null &&
|
||||
wordSpacing == null) {
|
||||
return textSpan;
|
||||
}
|
||||
return _applyTextStyleOverrides(
|
||||
TextStyle(
|
||||
height: lineHeightScaleFactor,
|
||||
letterSpacing: letterSpacing,
|
||||
wordSpacing: wordSpacing,
|
||||
),
|
||||
textSpan,
|
||||
);
|
||||
}
|
||||
|
||||
static TextSpan _applyTextStyleOverrides(
|
||||
TextStyle overrideTextStyle,
|
||||
TextSpan textSpan,
|
||||
) {
|
||||
return TextSpan(
|
||||
text: textSpan.text,
|
||||
children: textSpan.children?.map((InlineSpan child) {
|
||||
if (child is TextSpan && child.runtimeType == TextSpan) {
|
||||
return _applyTextStyleOverrides(overrideTextStyle, child);
|
||||
}
|
||||
return child;
|
||||
}).toList(),
|
||||
style: textSpan.style?.merge(overrideTextStyle) ?? overrideTextStyle,
|
||||
recognizer: textSpan.recognizer,
|
||||
mouseCursor: textSpan.mouseCursor,
|
||||
onEnter: textSpan.onEnter,
|
||||
onExit: textSpan.onExit,
|
||||
semanticsLabel: textSpan.semanticsLabel,
|
||||
semanticsIdentifier: textSpan.semanticsIdentifier,
|
||||
locale: textSpan.locale,
|
||||
spellOut: textSpan.spellOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user