|
|
|
import 'dart:async';
|
|
|
|
import 'dart:math' as math;
|
|
|
|
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'package:flutter/gestures.dart';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:flutter/scheduler.dart';
|
|
|
|
|
|
|
|
import '../../models/documents/nodes/node.dart';
|
|
|
|
import '../editor/editor.dart';
|
|
|
|
|
|
|
|
TextSelection localSelection(Node node, TextSelection selection, fromParent) {
|
|
|
|
final base = fromParent ? node.offset : node.documentOffset;
|
|
|
|
assert(base <= selection.end && selection.start <= base + node.length - 1);
|
|
|
|
|
|
|
|
final offset = fromParent ? node.offset : node.documentOffset;
|
|
|
|
return selection.copyWith(
|
|
|
|
baseOffset: math.max(selection.start - offset, 0),
|
|
|
|
extentOffset: math.min(selection.end - offset, node.length - 1));
|
|
|
|
}
|
|
|
|
|
|
|
|
/// The text position that a give selection handle manipulates. Dragging the
|
|
|
|
/// [start] handle always moves the [start]/[baseOffset] of the selection.
|
|
|
|
enum _TextSelectionHandlePosition { start, end }
|
|
|
|
|
|
|
|
/// internal use, used to get drag direction information
|
|
|
|
class DragTextSelection extends TextSelection {
|
|
|
|
const DragTextSelection({
|
|
|
|
super.affinity,
|
|
|
|
super.baseOffset = 0,
|
|
|
|
super.extentOffset = 0,
|
|
|
|
super.isDirectional,
|
|
|
|
this.first = true,
|
|
|
|
});
|
|
|
|
|
|
|
|
final bool first;
|
|
|
|
|
|
|
|
@override
|
|
|
|
DragTextSelection copyWith({
|
|
|
|
int? baseOffset,
|
|
|
|
int? extentOffset,
|
|
|
|
TextAffinity? affinity,
|
|
|
|
bool? isDirectional,
|
|
|
|
bool? first,
|
|
|
|
}) {
|
|
|
|
return DragTextSelection(
|
|
|
|
baseOffset: baseOffset ?? this.baseOffset,
|
|
|
|
extentOffset: extentOffset ?? this.extentOffset,
|
|
|
|
affinity: affinity ?? this.affinity,
|
|
|
|
isDirectional: isDirectional ?? this.isDirectional,
|
|
|
|
first: first ?? this.first,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// An object that manages a pair of text selection handles.
|
|
|
|
///
|
|
|
|
/// The selection handles are displayed in the [Overlay] that most closely
|
|
|
|
/// encloses the given [BuildContext].
|
|
|
|
class EditorTextSelectionOverlay {
|
|
|
|
/// Creates an object that manages overlay entries for selection handles.
|
|
|
|
///
|
|
|
|
/// The [context] must not be null and must have an [Overlay] as an ancestor.
|
|
|
|
EditorTextSelectionOverlay({
|
|
|
|
required this.value,
|
|
|
|
required this.context,
|
|
|
|
required this.startHandleLayerLink,
|
|
|
|
required this.endHandleLayerLink,
|
|
|
|
required this.renderObject,
|
|
|
|
required this.debugRequiredFor,
|
|
|
|
required this.selectionCtrls,
|
|
|
|
required this.selectionDelegate,
|
|
|
|
required this.contextMenuBuilder,
|
|
|
|
this.clipboardStatus,
|
|
|
|
this.onSelectionHandleTapped,
|
|
|
|
this.dragStartBehavior = DragStartBehavior.start,
|
|
|
|
this.handlesVisible = false,
|
|
|
|
}) {
|
|
|
|
// Clipboard status is only checked on first instance of
|
|
|
|
// ClipboardStatusNotifier
|
|
|
|
// if state has changed after creation, but prior to
|
|
|
|
// our listener being created
|
|
|
|
// we won't know the status unless there is forced update
|
|
|
|
// i.e. occasionally no paste
|
|
|
|
if (clipboardStatus != null) {
|
|
|
|
clipboardStatus!.update();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
TextEditingValue value;
|
|
|
|
|
|
|
|
/// Whether selection handles are visible.
|
|
|
|
///
|
|
|
|
/// Set to false if you want to hide the handles. Use this property to show or
|
|
|
|
/// hide the handle without rebuilding them.
|
|
|
|
///
|
|
|
|
/// If this method is called while the [SchedulerBinding.schedulerPhase] is
|
|
|
|
/// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or
|
|
|
|
/// paint phases (see [WidgetsBinding.drawFrame]), then the update is delayed
|
|
|
|
/// until the post-frame callbacks phase. Otherwise the update is done
|
|
|
|
/// synchronously. This means that it is safe to call during builds, but also
|
|
|
|
/// that if you do call this during a build, the UI will not update until the
|
|
|
|
/// next frame (i.e. many milliseconds later).
|
|
|
|
///
|
|
|
|
/// Defaults to false.
|
|
|
|
bool handlesVisible = false;
|
|
|
|
|
|
|
|
/// The context in which the selection handles should appear.
|
|
|
|
///
|
|
|
|
/// This context must have an [Overlay] as an ancestor because this object
|
|
|
|
/// will display the text selection handles in that [Overlay].
|
|
|
|
final BuildContext context;
|
|
|
|
|
|
|
|
/// Debugging information for explaining why the [Overlay] is required.
|
|
|
|
final Widget debugRequiredFor;
|
|
|
|
|
|
|
|
/// The objects supplied to the [CompositedTransformTarget] that wraps the
|
|
|
|
/// location of start selection handle.
|
|
|
|
final LayerLink startHandleLayerLink;
|
|
|
|
|
|
|
|
/// The objects supplied to the [CompositedTransformTarget] that wraps the
|
|
|
|
/// location of end selection handle.
|
|
|
|
final LayerLink endHandleLayerLink;
|
|
|
|
|
|
|
|
/// The editable line in which the selected text is being displayed.
|
|
|
|
final RenderEditor renderObject;
|
|
|
|
|
|
|
|
/// Builds text selection handles and toolbar.
|
|
|
|
final TextSelectionControls selectionCtrls;
|
|
|
|
|
|
|
|
/// The delegate for manipulating the current selection in the owning
|
|
|
|
/// text field.
|
|
|
|
final TextSelectionDelegate selectionDelegate;
|
|
|
|
|
|
|
|
/// {@macro flutter.widgets.EditableText.contextMenuBuilder}
|
|
|
|
///
|
|
|
|
/// If not provided, no context menu will be built.
|
|
|
|
final WidgetBuilder? contextMenuBuilder;
|
|
|
|
|
|
|
|
/// Determines the way that drag start behavior is handled.
|
|
|
|
///
|
|
|
|
/// If set to [DragStartBehavior.start], handle drag behavior will
|
|
|
|
/// begin upon the detection of a drag gesture. If set to
|
|
|
|
/// [DragStartBehavior.down] it will begin when a down event is first
|
|
|
|
/// detected.
|
|
|
|
///
|
|
|
|
/// In general, setting this to [DragStartBehavior.start] will make drag
|
|
|
|
/// animation smoother and setting it to [DragStartBehavior.down] will make
|
|
|
|
/// drag behavior feel slightly more reactive.
|
|
|
|
///
|
|
|
|
/// By default, the drag start behavior is [DragStartBehavior.start].
|
|
|
|
///
|
|
|
|
/// See also:
|
|
|
|
///
|
|
|
|
/// * [DragGestureRecognizer.dragStartBehavior],
|
|
|
|
/// which gives an example for the different behaviors.
|
|
|
|
final DragStartBehavior dragStartBehavior;
|
|
|
|
|
|
|
|
/// {@template flutter.widgets.textSelection.onSelectionHandleTapped}
|
|
|
|
/// A callback that's invoked when a selection handle is tapped.
|
|
|
|
///
|
|
|
|
/// Both regular taps and long presses invoke this callback, but a drag
|
|
|
|
/// gesture won't.
|
|
|
|
/// {@endtemplate}
|
|
|
|
final VoidCallback? onSelectionHandleTapped;
|
|
|
|
|
|
|
|
/// Maintains the status of the clipboard for determining if its contents can
|
|
|
|
/// be pasted or not.
|
|
|
|
///
|
|
|
|
/// Useful because the actual value of the clipboard can only be checked
|
|
|
|
/// asynchronously (see [Clipboard.getData]).
|
|
|
|
final ClipboardStatusNotifier? clipboardStatus;
|
|
|
|
|
|
|
|
/// A pair of handles. If this is non-null, there are always 2, though the
|
|
|
|
/// second is hidden when the selection is collapsed.
|
|
|
|
List<OverlayEntry>? _handles;
|
|
|
|
|
|
|
|
/// A copy/paste toolbar.
|
|
|
|
OverlayEntry? toolbar;
|
|
|
|
|
|
|
|
TextSelection get _selection => value.selection;
|
|
|
|
|
|
|
|
void setHandlesVisible(bool visible) {
|
|
|
|
if (handlesVisible == visible) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
handlesVisible = visible;
|
|
|
|
// If we are in build state, it will be too late to update visibility.
|
|
|
|
// We will need to schedule the build in next frame.
|
|
|
|
if (SchedulerBinding.instance.schedulerPhase ==
|
|
|
|
SchedulerPhase.persistentCallbacks) {
|
|
|
|
SchedulerBinding.instance.addPostFrameCallback(markNeedsBuild);
|
|
|
|
} else {
|
|
|
|
markNeedsBuild();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Destroys the handles by removing them from overlay.
|
|
|
|
void hideHandles() {
|
|
|
|
if (_handles == null) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
_handles![0].remove();
|
|
|
|
_handles![1].remove();
|
|
|
|
_handles = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Hides the toolbar part of the overlay.
|
|
|
|
///
|
|
|
|
/// To hide the whole overlay, see [hide].
|
|
|
|
void hideToolbar() {
|
|
|
|
assert(toolbar != null);
|
|
|
|
toolbar!.remove();
|
|
|
|
toolbar = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Shows the toolbar by inserting it into the [context]'s overlay.
|
|
|
|
void showToolbar() {
|
|
|
|
assert(toolbar == null);
|
|
|
|
if (contextMenuBuilder == null) return;
|
|
|
|
toolbar = OverlayEntry(builder: (context) {
|
|
|
|
return contextMenuBuilder!(context);
|
|
|
|
});
|
|
|
|
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)
|
|
|
|
.insert(toolbar!);
|
|
|
|
|
|
|
|
// make sure handles are visible as well
|
|
|
|
if (_handles == null) {
|
|
|
|
showHandles();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _buildHandle(
|
|
|
|
BuildContext context, _TextSelectionHandlePosition position) {
|
|
|
|
if (_selection.isCollapsed &&
|
|
|
|
position == _TextSelectionHandlePosition.end) {
|
|
|
|
return Container();
|
|
|
|
}
|
|
|
|
return Visibility(
|
|
|
|
visible: handlesVisible,
|
|
|
|
child: _TextSelectionHandleOverlay(
|
|
|
|
onSelectionHandleChanged: (newSelection) {
|
|
|
|
_handleSelectionHandleChanged(newSelection, position);
|
|
|
|
},
|
|
|
|
onSelectionHandleTapped: onSelectionHandleTapped,
|
|
|
|
startHandleLayerLink: startHandleLayerLink,
|
|
|
|
endHandleLayerLink: endHandleLayerLink,
|
|
|
|
renderObject: renderObject,
|
|
|
|
selection: _selection,
|
|
|
|
selectionControls: selectionCtrls,
|
|
|
|
position: position,
|
|
|
|
dragStartBehavior: dragStartBehavior,
|
|
|
|
));
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Updates the overlay after the selection has changed.
|
|
|
|
///
|
|
|
|
/// If this method is called while the [SchedulerBinding.schedulerPhase] is
|
|
|
|
/// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or
|
|
|
|
/// paint phases (see [WidgetsBinding.drawFrame]), then the update is delayed
|
|
|
|
/// until the post-frame callbacks phase. Otherwise the update is done
|
|
|
|
/// synchronously. This means that it is safe to call during builds, but also
|
|
|
|
/// that if you do call this during a build, the UI will not update until the
|
|
|
|
/// next frame (i.e. many milliseconds later).
|
|
|
|
void update(TextEditingValue newValue) {
|
|
|
|
if (value == newValue) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
value = newValue;
|
|
|
|
if (SchedulerBinding.instance.schedulerPhase ==
|
|
|
|
SchedulerPhase.persistentCallbacks) {
|
|
|
|
SchedulerBinding.instance.addPostFrameCallback(markNeedsBuild);
|
|
|
|
} else {
|
|
|
|
markNeedsBuild();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleSelectionHandleChanged(
|
|
|
|
TextSelection? newSelection,
|
|
|
|
_TextSelectionHandlePosition position,
|
|
|
|
) {
|
|
|
|
TextPosition textPosition;
|
|
|
|
switch (position) {
|
|
|
|
case _TextSelectionHandlePosition.start:
|
|
|
|
textPosition = newSelection != null
|
|
|
|
? newSelection.base
|
|
|
|
: const TextPosition(offset: 0);
|
|
|
|
break;
|
|
|
|
case _TextSelectionHandlePosition.end:
|
|
|
|
textPosition = newSelection != null
|
|
|
|
? newSelection.extent
|
|
|
|
: const TextPosition(offset: 0);
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
throw ArgumentError('Invalid position');
|
|
|
|
}
|
|
|
|
|
|
|
|
final currSelection = newSelection != null
|
|
|
|
? DragTextSelection(
|
|
|
|
baseOffset: newSelection.baseOffset,
|
|
|
|
extentOffset: newSelection.extentOffset,
|
|
|
|
affinity: newSelection.affinity,
|
|
|
|
isDirectional: newSelection.isDirectional,
|
|
|
|
first: position == _TextSelectionHandlePosition.start,
|
|
|
|
)
|
|
|
|
: null;
|
|
|
|
|
|
|
|
selectionDelegate
|
|
|
|
..userUpdateTextEditingValue(
|
|
|
|
value.copyWith(selection: currSelection, composing: TextRange.empty),
|
|
|
|
SelectionChangedCause.drag)
|
|
|
|
..bringIntoView(textPosition);
|
|
|
|
}
|
|
|
|
|
|
|
|
void markNeedsBuild([Duration? duration]) {
|
|
|
|
if (_handles != null) {
|
|
|
|
_handles![0].markNeedsBuild();
|
|
|
|
_handles![1].markNeedsBuild();
|
|
|
|
}
|
|
|
|
toolbar?.markNeedsBuild();
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Hides the entire overlay including the toolbar and the handles.
|
|
|
|
void hide() {
|
|
|
|
if (_handles != null) {
|
|
|
|
_handles![0].remove();
|
|
|
|
_handles![1].remove();
|
|
|
|
_handles = null;
|
|
|
|
}
|
|
|
|
if (toolbar != null) {
|
|
|
|
hideToolbar();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Final cleanup.
|
|
|
|
void dispose() {
|
|
|
|
hide();
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Builds the handles by inserting them into the [context]'s overlay.
|
|
|
|
void showHandles() {
|
|
|
|
assert(_handles == null);
|
|
|
|
_handles = <OverlayEntry>[
|
|
|
|
OverlayEntry(
|
|
|
|
builder: (context) =>
|
|
|
|
_buildHandle(context, _TextSelectionHandlePosition.start)),
|
|
|
|
OverlayEntry(
|
|
|
|
builder: (context) =>
|
|
|
|
_buildHandle(context, _TextSelectionHandlePosition.end)),
|
|
|
|
];
|
|
|
|
|
|
|
|
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)
|
|
|
|
.insertAll(_handles!);
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Causes the overlay to update its rendering.
|
|
|
|
///
|
|
|
|
/// This is intended to be called when the [renderObject] may have changed its
|
|
|
|
/// text metrics (e.g. because the text was scrolled).
|
|
|
|
void updateForScroll() {
|
|
|
|
markNeedsBuild();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// This widget represents a single draggable text selection handle.
|
|
|
|
class _TextSelectionHandleOverlay extends StatefulWidget {
|
|
|
|
const _TextSelectionHandleOverlay({
|
|
|
|
required this.selection,
|
|
|
|
required this.position,
|
|
|
|
required this.startHandleLayerLink,
|
|
|
|
required this.endHandleLayerLink,
|
|
|
|
required this.renderObject,
|
|
|
|
required this.onSelectionHandleChanged,
|
|
|
|
required this.onSelectionHandleTapped,
|
|
|
|
required this.selectionControls,
|
|
|
|
this.dragStartBehavior = DragStartBehavior.start,
|
|
|
|
});
|
|
|
|
|
|
|
|
final TextSelection selection;
|
|
|
|
final _TextSelectionHandlePosition position;
|
|
|
|
final LayerLink startHandleLayerLink;
|
|
|
|
final LayerLink endHandleLayerLink;
|
|
|
|
final RenderEditor renderObject;
|
|
|
|
final ValueChanged<TextSelection?> onSelectionHandleChanged;
|
|
|
|
final VoidCallback? onSelectionHandleTapped;
|
|
|
|
final TextSelectionControls selectionControls;
|
|
|
|
final DragStartBehavior dragStartBehavior;
|
|
|
|
|
|
|
|
@override
|
|
|
|
_TextSelectionHandleOverlayState createState() =>
|
|
|
|
_TextSelectionHandleOverlayState();
|
|
|
|
|
|
|
|
ValueListenable<bool> get _visibility {
|
|
|
|
switch (position) {
|
|
|
|
case _TextSelectionHandlePosition.start:
|
|
|
|
return renderObject.selectionStartInViewport;
|
|
|
|
case _TextSelectionHandlePosition.end:
|
|
|
|
return renderObject.selectionEndInViewport;
|
|
|
|
default:
|
|
|
|
throw ArgumentError('Invalid position');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _TextSelectionHandleOverlayState
|
|
|
|
extends State<_TextSelectionHandleOverlay>
|
|
|
|
with SingleTickerProviderStateMixin {
|
|
|
|
// ignore: unused_field
|
|
|
|
late Offset _dragPosition;
|
|
|
|
|
|
|
|
late AnimationController _controller;
|
|
|
|
|
|
|
|
Animation<double> get _opacity => _controller.view;
|
|
|
|
|
|
|
|
@override
|
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
|
|
|
|
_controller = AnimationController(
|
|
|
|
duration: const Duration(milliseconds: 150), vsync: this);
|
|
|
|
|
|
|
|
_handleVisibilityChanged();
|
|
|
|
widget._visibility.addListener(_handleVisibilityChanged);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleVisibilityChanged() {
|
|
|
|
if (widget._visibility.value) {
|
|
|
|
_controller.forward();
|
|
|
|
} else {
|
|
|
|
_controller.reverse();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void didUpdateWidget(_TextSelectionHandleOverlay oldWidget) {
|
|
|
|
super.didUpdateWidget(oldWidget);
|
|
|
|
oldWidget._visibility.removeListener(_handleVisibilityChanged);
|
|
|
|
_handleVisibilityChanged();
|
|
|
|
widget._visibility.addListener(_handleVisibilityChanged);
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void dispose() {
|
|
|
|
widget._visibility.removeListener(_handleVisibilityChanged);
|
|
|
|
_controller.dispose();
|
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleDragStart(DragStartDetails details) {
|
|
|
|
final textPosition = widget.position == _TextSelectionHandlePosition.start
|
|
|
|
? widget.selection.base
|
|
|
|
: widget.selection.extent;
|
|
|
|
final lineHeight = widget.renderObject.preferredLineHeight(textPosition);
|
|
|
|
final handleSize = widget.selectionControls.getHandleSize(lineHeight);
|
|
|
|
_dragPosition = details.globalPosition + Offset(0, -handleSize.height);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleDragUpdate(DragUpdateDetails details) {
|
|
|
|
_dragPosition += details.delta;
|
|
|
|
final position =
|
|
|
|
widget.renderObject.getPositionForOffset(details.globalPosition);
|
|
|
|
if (widget.selection.isCollapsed) {
|
|
|
|
widget.onSelectionHandleChanged(TextSelection.fromPosition(position));
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
final isNormalized =
|
|
|
|
widget.selection.extentOffset >= widget.selection.baseOffset;
|
|
|
|
TextSelection newSelection;
|
|
|
|
switch (widget.position) {
|
|
|
|
case _TextSelectionHandlePosition.start:
|
|
|
|
newSelection = TextSelection(
|
|
|
|
baseOffset:
|
|
|
|
isNormalized ? position.offset : widget.selection.baseOffset,
|
|
|
|
extentOffset:
|
|
|
|
isNormalized ? widget.selection.extentOffset : position.offset,
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
case _TextSelectionHandlePosition.end:
|
|
|
|
newSelection = TextSelection(
|
|
|
|
baseOffset:
|
|
|
|
isNormalized ? widget.selection.baseOffset : position.offset,
|
|
|
|
extentOffset:
|
|
|
|
isNormalized ? position.offset : widget.selection.extentOffset,
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
throw ArgumentError('Invalid widget.position');
|
|
|
|
}
|
|
|
|
|
|
|
|
if (newSelection.baseOffset >= newSelection.extentOffset) {
|
|
|
|
return; // don't allow order swapping.
|
|
|
|
}
|
|
|
|
|
|
|
|
widget.onSelectionHandleChanged(newSelection);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleTap() {
|
|
|
|
widget.onSelectionHandleTapped?.call();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
late LayerLink layerLink;
|
|
|
|
TextSelectionHandleType? type;
|
|
|
|
|
|
|
|
switch (widget.position) {
|
|
|
|
case _TextSelectionHandlePosition.start:
|
|
|
|
layerLink = widget.startHandleLayerLink;
|
|
|
|
type = _chooseType(
|
|
|
|
widget.renderObject.textDirection,
|
|
|
|
TextSelectionHandleType.left,
|
|
|
|
TextSelectionHandleType.right,
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
case _TextSelectionHandlePosition.end:
|
|
|
|
// For collapsed selections, we shouldn't be building the [end] handle.
|
|
|
|
assert(!widget.selection.isCollapsed);
|
|
|
|
layerLink = widget.endHandleLayerLink;
|
|
|
|
type = _chooseType(
|
|
|
|
widget.renderObject.textDirection,
|
|
|
|
TextSelectionHandleType.right,
|
|
|
|
TextSelectionHandleType.left,
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: This logic doesn't work for TextStyle.height larger 1.
|
|
|
|
// It makes the extent handle top end on iOS extend too high which makes
|
|
|
|
// stick out above the selection background.
|
|
|
|
// May have to use getSelectionBoxes instead of preferredLineHeight.
|
|
|
|
// or expose TextStyle on the render object and calculate
|
|
|
|
// preferredLineHeight / style.height
|
|
|
|
final textPosition = widget.position == _TextSelectionHandlePosition.start
|
|
|
|
? widget.selection.base
|
|
|
|
: widget.selection.extent;
|
|
|
|
final lineHeight = widget.renderObject.preferredLineHeight(textPosition);
|
|
|
|
final handleAnchor =
|
|
|
|
widget.selectionControls.getHandleAnchor(type!, lineHeight);
|
|
|
|
final handleSize = widget.selectionControls.getHandleSize(lineHeight);
|
|
|
|
|
|
|
|
final handleRect = Rect.fromLTWH(
|
|
|
|
-handleAnchor.dx,
|
|
|
|
-handleAnchor.dy,
|
|
|
|
handleSize.width,
|
|
|
|
handleSize.height,
|
|
|
|
);
|
|
|
|
|
|
|
|
// Make sure the GestureDetector is big enough to be easily interactive.
|
|
|
|
final interactiveRect = handleRect.expandToInclude(
|
|
|
|
Rect.fromCircle(
|
|
|
|
center: handleRect.center, radius: kMinInteractiveDimension / 2),
|
|
|
|
);
|
|
|
|
final padding = RelativeRect.fromLTRB(
|
|
|
|
math.max((interactiveRect.width - handleRect.width) / 2, 0),
|
|
|
|
math.max((interactiveRect.height - handleRect.height) / 2, 0),
|
|
|
|
math.max((interactiveRect.width - handleRect.width) / 2, 0),
|
|
|
|
math.max((interactiveRect.height - handleRect.height) / 2, 0),
|
|
|
|
);
|
|
|
|
|
|
|
|
return CompositedTransformFollower(
|
|
|
|
link: layerLink,
|
|
|
|
offset: interactiveRect.topLeft,
|
|
|
|
showWhenUnlinked: false,
|
|
|
|
child: FadeTransition(
|
|
|
|
opacity: _opacity,
|
|
|
|
child: Container(
|
|
|
|
alignment: Alignment.topLeft,
|
|
|
|
width: interactiveRect.width,
|
|
|
|
height: interactiveRect.height,
|
|
|
|
child: GestureDetector(
|
|
|
|
behavior: HitTestBehavior.translucent,
|
|
|
|
dragStartBehavior: widget.dragStartBehavior,
|
|
|
|
onPanStart: _handleDragStart,
|
|
|
|
onPanUpdate: _handleDragUpdate,
|
|
|
|
onTap: _handleTap,
|
|
|
|
child: Padding(
|
|
|
|
padding: EdgeInsets.only(
|
|
|
|
left: padding.left,
|
|
|
|
top: padding.top,
|
|
|
|
right: padding.right,
|
|
|
|
bottom: padding.bottom,
|
|
|
|
),
|
|
|
|
child: widget.selectionControls.buildHandle(
|
|
|
|
context,
|
|
|
|
type,
|
|
|
|
lineHeight,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
TextSelectionHandleType? _chooseType(
|
|
|
|
TextDirection textDirection,
|
|
|
|
TextSelectionHandleType ltrType,
|
|
|
|
TextSelectionHandleType rtlType,
|
|
|
|
) {
|
|
|
|
if (widget.selection.isCollapsed) return TextSelectionHandleType.collapsed;
|
|
|
|
|
|
|
|
switch (textDirection) {
|
|
|
|
case TextDirection.ltr:
|
|
|
|
return ltrType;
|
|
|
|
case TextDirection.rtl:
|
|
|
|
return rtlType;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// A gesture detector to respond to non-exclusive event chains for a
|
|
|
|
/// text field.
|
|
|
|
///
|
|
|
|
/// An ordinary [GestureDetector] configured to handle events like tap and
|
|
|
|
/// double tap will only recognize one or the other. This widget detects both:
|
|
|
|
/// first the tap and then, if another tap down occurs within a time limit, the
|
|
|
|
/// double tap.
|
|
|
|
///
|
|
|
|
/// See also:
|
|
|
|
///
|
|
|
|
/// * [TextField], a Material text field which uses this gesture detector.
|
|
|
|
/// * [CupertinoTextField], a Cupertino text field which uses this gesture
|
|
|
|
/// detector.
|
|
|
|
class EditorTextSelectionGestureDetector extends StatefulWidget {
|
|
|
|
/// Create a [EditorTextSelectionGestureDetector].
|
|
|
|
///
|
|
|
|
/// Multiple callbacks can be called for one sequence of input gesture.
|
|
|
|
/// The [child] parameter must not be null.
|
|
|
|
const EditorTextSelectionGestureDetector({
|
|
|
|
required this.child,
|
|
|
|
this.onTapDown,
|
|
|
|
this.onForcePressStart,
|
|
|
|
this.onForcePressEnd,
|
|
|
|
this.onSingleTapUp,
|
|
|
|
this.onSingleTapCancel,
|
|
|
|
this.onSecondaryTapDown,
|
|
|
|
this.onSecondarySingleTapUp,
|
|
|
|
this.onSecondarySingleTapCancel,
|
|
|
|
this.onSecondaryDoubleTapDown,
|
|
|
|
this.onSingleLongTapStart,
|
|
|
|
this.onSingleLongTapMoveUpdate,
|
|
|
|
this.onSingleLongTapEnd,
|
|
|
|
this.onDoubleTapDown,
|
|
|
|
this.onDragSelectionStart,
|
|
|
|
this.onDragSelectionUpdate,
|
|
|
|
this.onDragSelectionEnd,
|
|
|
|
this.behavior,
|
|
|
|
this.detectWordBoundary = true,
|
|
|
|
super.key,
|
|
|
|
});
|
|
|
|
|
|
|
|
/// Called for every tap down including every tap down that's part of a
|
|
|
|
/// double click or a long press, except touches that include enough movement
|
|
|
|
/// to not qualify as taps (e.g. pans and flings).
|
|
|
|
final GestureTapDownCallback? onTapDown;
|
|
|
|
|
|
|
|
/// Called when a pointer has tapped down and the force of the pointer has
|
|
|
|
/// just become greater than [ForcePressGestureRecognizer.startPressure].
|
|
|
|
final GestureForcePressStartCallback? onForcePressStart;
|
|
|
|
|
|
|
|
/// Called when a pointer that had previously triggered [onForcePressStart] is
|
|
|
|
/// lifted off the screen.
|
|
|
|
final GestureForcePressEndCallback? onForcePressEnd;
|
|
|
|
|
|
|
|
/// Called for each distinct tap except for every second tap of a double tap.
|
|
|
|
/// For example, if the detector was configured with [onTapDown] and
|
|
|
|
/// [onDoubleTapDown], three quick taps would be recognized as a single tap
|
|
|
|
/// down, followed by a double tap down, followed by a single tap down.
|
|
|
|
final GestureTapUpCallback? onSingleTapUp;
|
|
|
|
|
|
|
|
/// Called for each touch that becomes recognized as a gesture that is not a
|
|
|
|
/// short tap, such as a long tap or drag. It is called at the moment when
|
|
|
|
/// another gesture from the touch is recognized.
|
|
|
|
final GestureTapCancelCallback? onSingleTapCancel;
|
|
|
|
|
|
|
|
/// onTapDown for mouse right click
|
|
|
|
final GestureTapDownCallback? onSecondaryTapDown;
|
|
|
|
|
|
|
|
/// onTapUp for mouse right click
|
|
|
|
final GestureTapUpCallback? onSecondarySingleTapUp;
|
|
|
|
|
|
|
|
/// onTapCancel for mouse right click
|
|
|
|
final GestureTapCancelCallback? onSecondarySingleTapCancel;
|
|
|
|
|
|
|
|
/// onDoubleTap for mouse right click
|
|
|
|
final GestureTapDownCallback? onSecondaryDoubleTapDown;
|
|
|
|
|
|
|
|
/// Called for a single long tap that's sustained for longer than
|
|
|
|
/// [kLongPressTimeout] but not necessarily lifted. Not called for a
|
|
|
|
/// double-tap-hold, which calls [onDoubleTapDown] instead.
|
|
|
|
final GestureLongPressStartCallback? onSingleLongTapStart;
|
|
|
|
|
|
|
|
/// Called after [onSingleLongTapStart] when the pointer is dragged.
|
|
|
|
final GestureLongPressMoveUpdateCallback? onSingleLongTapMoveUpdate;
|
|
|
|
|
|
|
|
/// Called after [onSingleLongTapStart] when the pointer is lifted.
|
|
|
|
final GestureLongPressEndCallback? onSingleLongTapEnd;
|
|
|
|
|
|
|
|
/// Called after a momentary hold or a short tap that is close in space and
|
|
|
|
/// time (within [kDoubleTapTimeout]) to a previous short tap.
|
|
|
|
final GestureTapDownCallback? onDoubleTapDown;
|
|
|
|
|
|
|
|
/// Called when a mouse starts dragging to select text.
|
|
|
|
final GestureDragStartCallback? onDragSelectionStart;
|
|
|
|
|
|
|
|
/// Called repeatedly as a mouse moves while dragging.
|
|
|
|
///
|
|
|
|
/// The frequency of calls is throttled to avoid excessive text layout
|
|
|
|
/// operations in text fields. The throttling is controlled by the constant
|
|
|
|
/// [_kDragSelectionUpdateThrottle].
|
|
|
|
final GestureDragUpdateCallback? onDragSelectionUpdate;
|
|
|
|
|
|
|
|
/// Called when a mouse that was previously dragging is released.
|
|
|
|
final GestureDragEndCallback? onDragSelectionEnd;
|
|
|
|
|
|
|
|
/// How this gesture detector should behave during hit testing.
|
|
|
|
///
|
|
|
|
/// This defaults to [HitTestBehavior.deferToChild].
|
|
|
|
final HitTestBehavior? behavior;
|
|
|
|
|
|
|
|
/// Child below this widget.
|
|
|
|
final Widget child;
|
|
|
|
|
|
|
|
final bool detectWordBoundary;
|
|
|
|
|
|
|
|
@override
|
|
|
|
State<StatefulWidget> createState() =>
|
|
|
|
_EditorTextSelectionGestureDetectorState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _EditorTextSelectionGestureDetectorState
|
|
|
|
extends State<EditorTextSelectionGestureDetector> {
|
|
|
|
// Counts down for a short duration after a previous tap. Null otherwise.
|
|
|
|
Timer? _doubleTapTimer;
|
|
|
|
Offset? _lastTapOffset;
|
|
|
|
|
|
|
|
// True if a second tap down of a double tap is detected. Used to discard
|
|
|
|
// subsequent tap up / tap hold of the same tap.
|
|
|
|
bool _isDoubleTap = false;
|
|
|
|
|
|
|
|
// _isDoubleTap for mouse right click
|
|
|
|
bool _isSecondaryDoubleTap = false;
|
|
|
|
|
|
|
|
@override
|
|
|
|
void dispose() {
|
|
|
|
_doubleTapTimer?.cancel();
|
|
|
|
_dragUpdateThrottleTimer?.cancel();
|
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
// The down handler is force-run on success of a single tap and optimistically
|
|
|
|
// run before a long press success.
|
|
|
|
void _handleTapDown(TapDownDetails details) {
|
|
|
|
widget.onTapDown?.call(details);
|
|
|
|
|
|
|
|
// This isn't detected as a double tap gesture in the gesture recognizer
|
|
|
|
// because it's 2 single taps, each of which may do different things
|
|
|
|
// depending on whether it's a single tap, the first tap of a double tap,
|
|
|
|
// the second tap held down, a clean double tap etc.
|
|
|
|
if (_doubleTapTimer != null &&
|
|
|
|
_isWithinDoubleTapTolerance(details.globalPosition)) {
|
|
|
|
// If there was already a previous tap, the second down hold/tap is a
|
|
|
|
// double tap down.
|
|
|
|
|
|
|
|
widget.onDoubleTapDown?.call(details);
|
|
|
|
|
|
|
|
_doubleTapTimer!.cancel();
|
|
|
|
_doubleTapTimeout();
|
|
|
|
_isDoubleTap = true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleTapUp(TapUpDetails details) {
|
|
|
|
if (!_isDoubleTap) {
|
|
|
|
widget.onSingleTapUp?.call(details);
|
|
|
|
_lastTapOffset = details.globalPosition;
|
|
|
|
_doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout);
|
|
|
|
}
|
|
|
|
_isDoubleTap = false;
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleTapCancel() {
|
|
|
|
widget.onSingleTapCancel?.call();
|
|
|
|
}
|
|
|
|
|
|
|
|
// added secondary tap function for mouse right click to show toolbar
|
|
|
|
void _handleSecondaryTapDown(TapDownDetails details) {
|
|
|
|
if (widget.onSecondaryTapDown != null) {
|
|
|
|
widget.onSecondaryTapDown?.call(details);
|
|
|
|
}
|
|
|
|
if (_doubleTapTimer != null &&
|
|
|
|
_isWithinDoubleTapTolerance(details.globalPosition)) {
|
|
|
|
widget.onSecondaryDoubleTapDown?.call(details);
|
|
|
|
|
|
|
|
_doubleTapTimer!.cancel();
|
|
|
|
_doubleTapTimeout();
|
|
|
|
_isDoubleTap = true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleSecondaryTapUp(TapUpDetails details) {
|
|
|
|
if (!_isSecondaryDoubleTap) {
|
|
|
|
widget.onSecondarySingleTapUp?.call(details);
|
|
|
|
_lastTapOffset = details.globalPosition;
|
|
|
|
_doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout);
|
|
|
|
}
|
|
|
|
_isSecondaryDoubleTap = false;
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleSecondaryTapCancel() {
|
|
|
|
widget.onSecondarySingleTapCancel?.call();
|
|
|
|
}
|
|
|
|
|
|
|
|
DragStartDetails? _lastDragStartDetails;
|
|
|
|
DragUpdateDetails? _lastDragUpdateDetails;
|
|
|
|
Timer? _dragUpdateThrottleTimer;
|
|
|
|
|
|
|
|
void _handleDragStart(DragStartDetails details) {
|
|
|
|
assert(_lastDragStartDetails == null);
|
|
|
|
_lastDragStartDetails = details;
|
|
|
|
widget.onDragSelectionStart?.call(details);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleDragUpdate(DragUpdateDetails details) {
|
|
|
|
_lastDragUpdateDetails = details;
|
|
|
|
_dragUpdateThrottleTimer ??= Timer(
|
|
|
|
const Duration(milliseconds: 50),
|
|
|
|
_handleDragUpdateThrottled,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Drag updates are being throttled to avoid excessive text layouts in text
|
|
|
|
/// fields. The frequency of invocations is controlled by the constant
|
|
|
|
/// [_kDragSelectionUpdateThrottle].
|
|
|
|
///
|
|
|
|
/// Once the drag gesture ends, any pending drag update will be fired
|
|
|
|
/// immediately. See [_handleDragEnd].
|
|
|
|
void _handleDragUpdateThrottled() {
|
|
|
|
assert(_lastDragStartDetails != null);
|
|
|
|
assert(_lastDragUpdateDetails != null);
|
|
|
|
if (widget.onDragSelectionUpdate != null) {
|
|
|
|
widget.onDragSelectionUpdate!(
|
|
|
|
//_lastDragStartDetails!,
|
|
|
|
_lastDragUpdateDetails!);
|
|
|
|
}
|
|
|
|
_dragUpdateThrottleTimer = null;
|
|
|
|
_lastDragUpdateDetails = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleDragEnd(DragEndDetails details) {
|
|
|
|
assert(_lastDragStartDetails != null);
|
|
|
|
if (_dragUpdateThrottleTimer != null) {
|
|
|
|
// If there's already an update scheduled, trigger it immediately and
|
|
|
|
// cancel the timer.
|
|
|
|
_dragUpdateThrottleTimer!.cancel();
|
|
|
|
_handleDragUpdateThrottled();
|
|
|
|
}
|
|
|
|
|
|
|
|
widget.onDragSelectionEnd?.call(details);
|
|
|
|
|
|
|
|
_dragUpdateThrottleTimer = null;
|
|
|
|
_lastDragStartDetails = null;
|
|
|
|
_lastDragUpdateDetails = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
void _forcePressStarted(ForcePressDetails details) {
|
|
|
|
_doubleTapTimer?.cancel();
|
|
|
|
_doubleTapTimer = null;
|
|
|
|
widget.onForcePressStart?.call(details);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _forcePressEnded(ForcePressDetails details) {
|
|
|
|
if (widget.onForcePressEnd != null) {
|
|
|
|
widget.onForcePressEnd?.call(details);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleLongPressStart(LongPressStartDetails details) {
|
|
|
|
if (!_isDoubleTap) {
|
|
|
|
widget.onSingleLongTapStart?.call(details);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
|
|
|
|
if (!_isDoubleTap) {
|
|
|
|
widget.onSingleLongTapMoveUpdate?.call(details);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void _handleLongPressEnd(LongPressEndDetails details) {
|
|
|
|
if (!_isDoubleTap) {
|
|
|
|
widget.onSingleLongTapEnd?.call(details);
|
|
|
|
}
|
|
|
|
_isDoubleTap = false;
|
|
|
|
}
|
|
|
|
|
|
|
|
void _doubleTapTimeout() {
|
|
|
|
_doubleTapTimer = null;
|
|
|
|
_lastTapOffset = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
bool _isWithinDoubleTapTolerance(Offset secondTapOffset) {
|
|
|
|
if (_lastTapOffset == null) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
return (secondTapOffset - _lastTapOffset!).distance <= kDoubleTapSlop;
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final gestures = <Type, GestureRecognizerFactory>{};
|
|
|
|
|
|
|
|
// Use _TransparentTapGestureRecognizer so that TextSelectionGestureDetector
|
|
|
|
// can receive the same tap events that a selection handle placed visually
|
|
|
|
// on top of it also receives.
|
|
|
|
gestures[_TransparentTapGestureRecognizer] =
|
|
|
|
GestureRecognizerFactoryWithHandlers<_TransparentTapGestureRecognizer>(
|
|
|
|
() => _TransparentTapGestureRecognizer(debugOwner: this),
|
|
|
|
(instance) {
|
|
|
|
instance
|
|
|
|
..onTapDown = _handleTapDown
|
|
|
|
..onTapUp = _handleTapUp
|
|
|
|
..onTapCancel = _handleTapCancel
|
|
|
|
..onSecondaryTapDown = _handleSecondaryTapDown
|
|
|
|
..onSecondaryTapUp = _handleSecondaryTapUp
|
|
|
|
..onSecondaryTapCancel = _handleSecondaryTapCancel;
|
|
|
|
},
|
|
|
|
);
|
|
|
|
|
|
|
|
if (widget.onSingleLongTapStart != null ||
|
|
|
|
widget.onSingleLongTapMoveUpdate != null ||
|
|
|
|
widget.onSingleLongTapEnd != null) {
|
|
|
|
gestures[LongPressGestureRecognizer] =
|
|
|
|
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
|
|
|
() => LongPressGestureRecognizer(
|
|
|
|
debugOwner: this,
|
|
|
|
supportedDevices: <PointerDeviceKind>{PointerDeviceKind.touch}),
|
|
|
|
(instance) {
|
|
|
|
instance
|
|
|
|
..onLongPressStart = _handleLongPressStart
|
|
|
|
..onLongPressMoveUpdate = _handleLongPressMoveUpdate
|
|
|
|
..onLongPressEnd = _handleLongPressEnd;
|
|
|
|
},
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (widget.onDragSelectionStart != null ||
|
|
|
|
widget.onDragSelectionUpdate != null ||
|
|
|
|
widget.onDragSelectionEnd != null) {
|
|
|
|
gestures[HorizontalDragGestureRecognizer] =
|
|
|
|
GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
|
|
|
|
() => HorizontalDragGestureRecognizer(
|
|
|
|
debugOwner: this,
|
|
|
|
supportedDevices: <PointerDeviceKind>{PointerDeviceKind.mouse}),
|
|
|
|
(instance) {
|
|
|
|
// Text selection should start from the position of the first pointer
|
|
|
|
// down event.
|
|
|
|
instance
|
|
|
|
..dragStartBehavior = DragStartBehavior.down
|
|
|
|
..onStart = _handleDragStart
|
|
|
|
..onUpdate = _handleDragUpdate
|
|
|
|
..onEnd = _handleDragEnd;
|
|
|
|
},
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (widget.onForcePressStart != null || widget.onForcePressEnd != null) {
|
|
|
|
gestures[ForcePressGestureRecognizer] =
|
|
|
|
GestureRecognizerFactoryWithHandlers<ForcePressGestureRecognizer>(
|
|
|
|
() => ForcePressGestureRecognizer(debugOwner: this),
|
|
|
|
(instance) {
|
|
|
|
instance
|
|
|
|
..onStart =
|
|
|
|
widget.onForcePressStart != null ? _forcePressStarted : null
|
|
|
|
..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null;
|
|
|
|
},
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return RawGestureDetector(
|
|
|
|
gestures: gestures,
|
|
|
|
excludeFromSemantics: true,
|
|
|
|
behavior: widget.behavior,
|
|
|
|
child: widget.child,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// A TapGestureRecognizer which allows other GestureRecognizers to win in the
|
|
|
|
// GestureArena. This means both _TransparentTapGestureRecognizer and other
|
|
|
|
// GestureRecognizers can handle the same event.
|
|
|
|
//
|
|
|
|
// This enables proper handling of events on both the selection handle and the
|
|
|
|
// underlying input, since there is significant overlap between the two given
|
|
|
|
// the handle's padded hit area. For example, the selection handle needs to
|
|
|
|
// handle single taps on itself, but double taps need to be handled by the
|
|
|
|
// underlying input.
|
|
|
|
class _TransparentTapGestureRecognizer extends TapGestureRecognizer {
|
|
|
|
_TransparentTapGestureRecognizer({
|
|
|
|
super.debugOwner,
|
|
|
|
});
|
|
|
|
|
|
|
|
@override
|
|
|
|
void rejectGesture(int pointer) {
|
|
|
|
// Accept new gestures that another recognizer has already won.
|
|
|
|
// Specifically, this needs to accept taps on the text selection handle on
|
|
|
|
// behalf of the text field in order to handle double tap to select. It must
|
|
|
|
// not accept other gestures like longpresses and drags that end outside of
|
|
|
|
// the text field.
|
|
|
|
if (state == GestureRecognizerState.ready) {
|
|
|
|
acceptGesture(pointer);
|
|
|
|
} else {
|
|
|
|
super.rejectGesture(pointer);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|