dartlangeditorflutterflutter-appsflutter-examplesflutter-packageflutter-widgetquillquill-deltaquilljsreactquillrich-textrich-text-editorwysiwygwysiwyg-editor
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1018 lines
34 KiB
1018 lines
34 KiB
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); |
|
} |
|
} |
|
}
|
|
|