Copy TapAndPanGestureRecognizer from TextField (#2128)

* [chore]: change gesture

* [chore]: change gesture

* [chore]: code fromatter

* [chore]: remove code

* [chore]: add code commentary

---------

Co-authored-by: xuyang <xuyang@qimao.com>
pull/2133/head v10.3.3
License name 8 months ago committed by GitHub
parent bb29a50050
commit 2937dc8c95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      lib/src/editor/config/editor_configurations.dart
  2. 27
      lib/src/editor/editor.dart
  3. 2
      lib/src/editor/raw_editor/raw_editor.dart
  4. 32
      lib/src/editor/raw_editor/raw_editor_state.dart
  5. 684
      lib/src/editor/widgets/delegate.dart
  6. 395
      lib/src/editor/widgets/text/text_selection.dart

@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart' show Brightness, Uint8List, immutable; import 'package:flutter/foundation.dart' show Brightness, Uint8List, immutable;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart' import 'package:flutter/material.dart'
show TextCapitalization, TextInputAction, TextSelectionThemeData; show TextCapitalization, TextInputAction, TextSelectionThemeData;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -258,11 +259,12 @@ class QuillEditorConfigurations extends Equatable {
// Returns whether gesture is handled // Returns whether gesture is handled
final bool Function( final bool Function(
TapDownDetails details, TextPosition Function(Offset offset))? onTapDown; TapDragDownDetails details, TextPosition Function(Offset offset))?
onTapDown;
// Returns whether gesture is handled // Returns whether gesture is handled
final bool Function( final bool Function(
TapUpDetails details, TextPosition Function(Offset offset))? onTapUp; TapDragUpDetails details, TextPosition Function(Offset offset))? onTapUp;
// Returns whether gesture is handled // Returns whether gesture is handled
final bool Function( final bool Function(

@ -4,7 +4,13 @@ import 'package:flutter/cupertino.dart'
show CupertinoTheme, cupertinoTextSelectionControls; show CupertinoTheme, cupertinoTextSelectionControls;
import 'package:flutter/foundation.dart' import 'package:flutter/foundation.dart'
show ValueListenable, defaultTargetPlatform; show ValueListenable, defaultTargetPlatform;
import 'package:flutter/gestures.dart' show PointerDeviceKind; import 'package:flutter/gestures.dart'
show
PointerDeviceKind,
TapDragDownDetails,
TapDragEndDetails,
TapDragStartDetails,
TapDragUpDetails;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -488,7 +494,7 @@ class _QuillEditorSelectionGestureDetectorBuilder
editor?.updateMagnifier(details.globalPosition); editor?.updateMagnifier(details.globalPosition);
} }
bool _isPositionSelected(TapUpDetails details) { bool _isPositionSelected(TapDragUpDetails details) {
if (_state.controller.document.isEmpty()) { if (_state.controller.document.isEmpty()) {
return false; return false;
} }
@ -511,7 +517,7 @@ class _QuillEditorSelectionGestureDetectorBuilder
} }
@override @override
void onTapDown(TapDownDetails details) { void onTapDown(TapDragDownDetails details) {
if (_state.configurations.onTapDown != null) { if (_state.configurations.onTapDown != null) {
if (renderEditor != null && if (renderEditor != null &&
_state.configurations.onTapDown!( _state.configurations.onTapDown!(
@ -532,7 +538,7 @@ class _QuillEditorSelectionGestureDetectorBuilder
} }
@override @override
void onSingleTapUp(TapUpDetails details) { void onSingleTapUp(TapDragUpDetails details) {
if (_state.configurations.onTapUp != null && if (_state.configurations.onTapUp != null &&
renderEditor != null && renderEditor != null &&
_state.configurations.onTapUp!( _state.configurations.onTapUp!(
@ -738,6 +744,7 @@ class RenderEditor extends RenderEditableContainerBox
Document document; Document document;
TextSelection selection; TextSelection selection;
bool _hasFocus = false; bool _hasFocus = false;
bool get hasFocus => _hasFocus;
LayerLink _startHandleLayerLink; LayerLink _startHandleLayerLink;
LayerLink _endHandleLayerLink; LayerLink _endHandleLayerLink;
@ -944,12 +951,20 @@ class RenderEditor extends RenderEditableContainerBox
} }
Offset? _lastTapDownPosition; Offset? _lastTapDownPosition;
Offset? _lastSecondaryTapDownPosition;
Offset? get lastSecondaryTapDownPosition => _lastSecondaryTapDownPosition;
// Used on Desktop (mouse and keyboard enabled platforms) as base offset // Used on Desktop (mouse and keyboard enabled platforms) as base offset
// for extending selection, either with combination of `Shift` + Click or // for extending selection, either with combination of `Shift` + Click or
// by dragging // by dragging
TextSelection? _extendSelectionOrigin; TextSelection? _extendSelectionOrigin;
void handleSecondaryTapDown(TapDownDetails details) {
_lastTapDownPosition = details.globalPosition;
_lastSecondaryTapDownPosition = details.globalPosition;
}
@override @override
void handleTapDown(TapDownDetails details) { void handleTapDown(TapDownDetails details) {
_lastTapDownPosition = details.globalPosition; _lastTapDownPosition = details.globalPosition;
@ -957,7 +972,7 @@ class RenderEditor extends RenderEditableContainerBox
bool _isDragging = false; bool _isDragging = false;
void handleDragStart(DragStartDetails details) { void handleDragStart(TapDragStartDetails details) {
_isDragging = true; _isDragging = true;
final newSelection = selectPositionAt( final newSelection = selectPositionAt(
@ -970,7 +985,7 @@ class RenderEditor extends RenderEditableContainerBox
_extendSelectionOrigin = newSelection; _extendSelectionOrigin = newSelection;
} }
void handleDragEnd(DragEndDetails details) { void handleDragEnd(TapDragEndDetails details) {
_isDragging = false; _isDragging = false;
onSelectionCompleted(); onSelectionCompleted();
} }

@ -100,4 +100,6 @@ abstract class EditorState extends State<QuillRawEditor>
void updateMagnifier(Offset positionToShow); void updateMagnifier(Offset positionToShow);
void hideMagnifier(); void hideMagnifier();
void toggleToolbar([bool hideHandles = true]);
} }

@ -906,7 +906,7 @@ class QuillRawEditorState extends EditorState
_selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); _selectionOverlay?.handlesVisible = _shouldShowSelectionHandles();
_selectionOverlay?.showHandles(); _selectionOverlay?.showHandles();
if (!_keyboardVisible) { if (!_hasFocus) {
// This will show the keyboard for all selection changes on the // This will show the keyboard for all selection changes on the
// editor, not just changes triggered by user gestures. // editor, not just changes triggered by user gestures.
requestKeyboard(); requestKeyboard();
@ -1419,11 +1419,11 @@ class QuillRawEditorState extends EditorState
void _updateOrDisposeSelectionOverlayIfNeeded() { void _updateOrDisposeSelectionOverlayIfNeeded() {
if (_selectionOverlay != null) { if (_selectionOverlay != null) {
if (!_hasFocus || textEditingValue.selection.isCollapsed) { if (_hasFocus) {
_selectionOverlay?.dispose();
_selectionOverlay = null;
} else if (_hasFocus) {
_selectionOverlay!.update(textEditingValue); _selectionOverlay!.update(textEditingValue);
} else {
_selectionOverlay!.dispose();
_selectionOverlay = null;
} }
} else if (_hasFocus) { } else if (_hasFocus) {
_selectionOverlay = _createSelectionOverlay(); _selectionOverlay = _createSelectionOverlay();
@ -1601,6 +1601,16 @@ class QuillRawEditorState extends EditorState
return true; return true;
} }
@override
void toggleToolbar([bool hideHandles = true]) {
final selectionOverlay = _selectionOverlay ??= _createSelectionOverlay();
if (selectionOverlay.handlesVisible) {
hideToolbar(hideHandles);
} else {
showToolbar();
}
}
void _replaceText(ReplaceTextIntent intent) { void _replaceText(ReplaceTextIntent intent) {
userUpdateTextEditingValue( userUpdateTextEditingValue(
intent.currentTextEditingValue intent.currentTextEditingValue
@ -1835,15 +1845,19 @@ class QuillRawEditorState extends EditorState
@override @override
void showMagnifier(ui.Offset positionToShow) { void showMagnifier(ui.Offset positionToShow) {
if (_hasFocus == false) return;
if (_selectionOverlay == null) return; if (_selectionOverlay == null) return;
final position = renderEditor.getPositionForOffset(positionToShow); final position = renderEditor.getPositionForOffset(positionToShow);
_selectionOverlay?.showMagnifier(position, positionToShow, renderEditor); if (_selectionOverlay!.magnifierIsVisible) {
_selectionOverlay!
.updateMagnifier(position, positionToShow, renderEditor);
} else {
_selectionOverlay!.showMagnifier(position, positionToShow, renderEditor);
}
} }
@override @override
void updateMagnifier(ui.Offset positionToShow) { void updateMagnifier(ui.Offset positionToShow) {
_updateOrDisposeSelectionOverlayIfNeeded(); showMagnifier(positionToShow);
final position = renderEditor.getPositionForOffset(positionToShow);
_selectionOverlay?.updateMagnifier(position, positionToShow, renderEditor);
} }
} }

@ -1,7 +1,9 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import '../../common/utils/platform.dart'; import '../../common/utils/platform.dart';
import '../../document/attribute.dart'; import '../../document/attribute.dart';
@ -107,6 +109,138 @@ class EditorTextSelectionGestureDetectorBuilder {
@protected @protected
RenderEditor? get renderEditor => editor?.renderEditor; RenderEditor? get renderEditor => editor?.renderEditor;
/// Whether the Shift key was pressed when the most recent [PointerDownEvent]
/// was tracked by the [BaseTapAndDragGestureRecognizer].
bool _isShiftPressed = false;
/// The viewport offset pixels of any [Scrollable] containing the
/// [RenderEditable] at the last drag start.
double _dragStartScrollOffset = 0;
/// The viewport offset pixels of the [RenderEditable] at the last drag start.
double _dragStartViewportOffset = 0;
double get _scrollPosition {
final scrollableState = delegate.editableTextKey.currentContext == null
? null
: Scrollable.maybeOf(delegate.editableTextKey.currentContext!);
return scrollableState == null ? 0.0 : scrollableState.position.pixels;
}
// For tap + drag gesture on iOS, whether the position where the drag started
// was on the previous TextSelection. iOS uses this value to determine if
// the cursor should move on drag update.
//
TextSelection? _dragStartSelection;
// If the drag started on the previous selection then the cursor will move on
// drag update. If the drag did not start on the previous selection then the
// cursor will not move on drag update.
bool? _dragBeganOnPreviousSelection;
/// Returns true if lastSecondaryTapDownPosition was on selection.
bool get _lastSecondaryTapWasOnSelection {
assert(renderEditor?.lastSecondaryTapDownPosition != null);
if (renderEditor?.selection == null) {
return false;
}
renderEditor?.lastSecondaryTapDownPosition;
final textPosition = renderEditor?.getPositionForOffset(
renderEditor!.lastSecondaryTapDownPosition!,
);
if (textPosition == null) return false;
return renderEditor!.selection.start <= textPosition.offset &&
renderEditor!.selection.end >= textPosition.offset;
}
/// Returns true if position was on selection.
bool _positionOnSelection(Offset position, TextSelection? targetSelection) {
if (targetSelection == null) return false;
final textPosition = renderEditor?.getPositionForOffset(position);
if (textPosition == null) return false;
return targetSelection.start <= textPosition.offset &&
targetSelection.end >= textPosition.offset;
}
// Expand the selection to the given global position.
//
// Either base or extent will be moved to the last tapped position, whichever
// is closest. The selection will never shrink or pivot, only grow.
//
// If fromSelection is given, will expand from that selection instead of the
// current selection in renderEditable.
//
// See also:
//
// * [_extendSelection], which is similar but pivots the selection around
// the base.
void _expandSelection(Offset offset, SelectionChangedCause cause,
[TextSelection? fromSelection]) {
final tappedPosition = renderEditor!.getPositionForOffset(offset);
final selection = fromSelection ?? renderEditor!.selection;
final baseIsCloser = (tappedPosition.offset - selection.baseOffset).abs() <
(tappedPosition.offset - selection.extentOffset).abs();
final nextSelection = selection.copyWith(
baseOffset: baseIsCloser ? selection.extentOffset : selection.baseOffset,
extentOffset: tappedPosition.offset,
);
editor?.userUpdateTextEditingValue(
editor!.textEditingValue.copyWith(selection: nextSelection), cause);
}
// Extend the selection to the given global position.
//
// Holds the base in place and moves the extent.
//
// See also:
//
// * [_expandSelection], which is similar but always increases the size of
// the selection.
void _extendSelection(Offset offset, SelectionChangedCause cause) {
assert(renderEditor?.selection.baseOffset != null);
final tappedPosition = renderEditor!.getPositionForOffset(offset);
final selection = renderEditor!.selection;
final nextSelection = selection.copyWith(
extentOffset: tappedPosition.offset,
);
editor?.userUpdateTextEditingValue(
editor!.textEditingValue.copyWith(selection: nextSelection), cause);
}
/// Handler for [TextSelectionGestureDetector.onTapTrackStart].
///
/// See also:
///
/// * [TextSelectionGestureDetector.onTapTrackStart], which triggers this
/// callback.
@protected
void onTapTrackStart() {
_isShiftPressed = HardwareKeyboard.instance.logicalKeysPressed
.intersection(<LogicalKeyboardKey>{
LogicalKeyboardKey.shiftLeft,
LogicalKeyboardKey.shiftRight
}).isNotEmpty;
}
/// Handler for [TextSelectionGestureDetector.onTapTrackReset].
///
/// See also:
///
/// * [TextSelectionGestureDetector.onTapTrackReset], which triggers this
/// callback.
@protected
void onTapTrackReset() {
_isShiftPressed = false;
}
/// Handler for [EditorTextSelectionGestureDetector.onTapDown]. /// Handler for [EditorTextSelectionGestureDetector.onTapDown].
/// ///
/// By default, it forwards the tap to [RenderEditable.handleTapDown] and sets /// By default, it forwards the tap to [RenderEditable.handleTapDown] and sets
@ -118,19 +252,45 @@ class EditorTextSelectionGestureDetectorBuilder {
/// * [EditorTextSelectionGestureDetector.onTapDown], /// * [EditorTextSelectionGestureDetector.onTapDown],
/// which triggers this callback. /// which triggers this callback.
@protected @protected
void onTapDown(TapDownDetails details) { void onTapDown(TapDragDownDetails details) {
renderEditor!.handleTapDown(details); if (!delegate.selectionEnabled) return;
// The selection overlay should only be shown when the user is interacting renderEditor!
// through a touch screen (via either a finger or a stylus). .handleTapDown(TapDownDetails(globalPosition: details.globalPosition));
// A mouse shouldn't trigger the selection overlay. final kind = details.kind;
// For backwards-compatibility, we treat a null kind the same as touch.
kind = details.kind;
shouldShowSelectionToolbar = kind == null || shouldShowSelectionToolbar = kind == null ||
kind ==
PointerDeviceKind
.mouse || // Enable word selection by mouse double tap
kind == PointerDeviceKind.touch || kind == PointerDeviceKind.touch ||
kind == PointerDeviceKind.stylus; kind == PointerDeviceKind.stylus;
final isShiftPressedValid =
_isShiftPressed && renderEditor?.selection.baseOffset != null;
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
editor?.hideToolbar(false);
case TargetPlatform.iOS:
// On mobile platforms the selection is set on tap up.
break;
case TargetPlatform.macOS:
editor?.hideToolbar();
// On macOS, a shift-tapped unfocused field expands from 0, not from the
// previous selection.
if (isShiftPressedValid) {
final fromSelection = renderEditor?.hasFocus == true
? null
: const TextSelection.collapsed(offset: 0);
_expandSelection(
details.globalPosition, SelectionChangedCause.tap, fromSelection);
return;
}
renderEditor?.selectPosition(cause: SelectionChangedCause.tap);
case TargetPlatform.linux:
case TargetPlatform.windows:
editor?.hideToolbar();
if (isShiftPressedValid) {
_extendSelection(details.globalPosition, SelectionChangedCause.tap);
return;
}
renderEditor?.selectPosition(cause: SelectionChangedCause.tap);
}
} }
/// Handler for [EditorTextSelectionGestureDetector.onForcePressStart]. /// Handler for [EditorTextSelectionGestureDetector.onForcePressStart].
@ -181,6 +341,27 @@ class EditorTextSelectionGestureDetectorBuilder {
} }
} }
/// Whether the provided [onUserTap] callback should be dispatched on every
/// tap or only non-consecutive taps.
///
/// Defaults to false.
@protected
bool get onUserTapAlwaysCalled => false;
/// Handler for [TextSelectionGestureDetector.onUserTap].
///
/// By default, it serves as placeholder to enable subclass override.
///
/// See also:
///
/// * [TextSelectionGestureDetector.onUserTap], which triggers this
/// callback.
/// * [TextSelectionGestureDetector.onUserTapAlwaysCalled], which controls
/// whether this callback is called only on the first tap in a series
/// of taps.
@protected
void onUserTap() {/* Subclass should override this method if needed. */}
/// Handler for [EditorTextSelectionGestureDetector.onSingleTapUp]. /// Handler for [EditorTextSelectionGestureDetector.onSingleTapUp].
/// ///
/// By default, it selects word edge if selection is enabled. /// By default, it selects word edge if selection is enabled.
@ -190,7 +371,7 @@ class EditorTextSelectionGestureDetectorBuilder {
/// * [EditorTextSelectionGestureDetector.onSingleTapUp], which triggers /// * [EditorTextSelectionGestureDetector.onSingleTapUp], which triggers
/// this callback. /// this callback.
@protected @protected
void onSingleTapUp(TapUpDetails details) { void onSingleTapUp(TapDragUpDetails details) {
if (delegate.selectionEnabled) { if (delegate.selectionEnabled) {
renderEditor!.selectWordEdge(SelectionChangedCause.tap); renderEditor!.selectWordEdge(SelectionChangedCause.tap);
} }
@ -269,6 +450,64 @@ class EditorTextSelectionGestureDetectorBuilder {
if (checkSelectionToolbarShouldShow(isAdditionalAction: false)) { if (checkSelectionToolbarShouldShow(isAdditionalAction: false)) {
editor!.showToolbar(); editor!.showToolbar();
} }
// Q: why ?
// A: cannot access QuillRawEditorState.updateFloatingCursor
//
// if (defaultTargetPlatform == TargetPlatform.iOS &&
// delegate.selectionEnabled &&
// editor?.textEditingValue.selection.isCollapsed == true) {
// // Update the floating cursor.
// final cursorPoint =
// RawFloatingCursorPoint(state: FloatingCursorDragState.End);
// // !.updateFloatingCursor(cursorPoint);
// (editor as QuillRawEditorState?)?.updateFloatingCursor(cursorPoint);
// }
}
/// Handler for [TextSelectionGestureDetector.onSecondaryTap].
///
/// By default, selects the word if possible and shows the toolbar.
@protected
void onSecondaryTap() {
if (!delegate.selectionEnabled) {
return;
}
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
if (!_lastSecondaryTapWasOnSelection ||
renderEditor?.hasFocus == false) {
renderEditor?.selectWord(SelectionChangedCause.tap);
}
if (shouldShowSelectionToolbar) {
editor?.hideToolbar();
editor?.showToolbar();
}
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
if (renderEditor?.hasFocus == false) {
renderEditor?.selectPosition(cause: SelectionChangedCause.tap);
}
editor?.toggleToolbar();
}
}
/// Handler for [TextSelectionGestureDetector.onDoubleTapDown].
///
/// By default, it selects a word through [RenderEditable.selectWord] if
/// selectionEnabled and shows toolbar if necessary.
///
/// See also:
///
/// * [TextSelectionGestureDetector.onDoubleTapDown], which triggers this
/// callback.
@protected
void onSecondaryTapDown(TapDownDetails details) {
renderEditor?.handleSecondaryTapDown(
TapDownDetails(globalPosition: details.globalPosition));
shouldShowSelectionToolbar = true;
} }
/// Handler for [EditorTextSelectionGestureDetector.onDoubleTapDown]. /// Handler for [EditorTextSelectionGestureDetector.onDoubleTapDown].
@ -281,7 +520,7 @@ class EditorTextSelectionGestureDetectorBuilder {
/// * [EditorTextSelectionGestureDetector.onDoubleTapDown], /// * [EditorTextSelectionGestureDetector.onDoubleTapDown],
/// which triggers this callback. /// which triggers this callback.
@protected @protected
void onDoubleTapDown(TapDownDetails details) { void onDoubleTapDown(TapDragDownDetails details) {
if (delegate.selectionEnabled) { if (delegate.selectionEnabled) {
renderEditor!.selectWord(SelectionChangedCause.tap); renderEditor!.selectWord(SelectionChangedCause.tap);
// allow the selection to get updated before trying to bring up // allow the selection to get updated before trying to bring up
@ -298,6 +537,105 @@ class EditorTextSelectionGestureDetectorBuilder {
} }
} }
// Selects the set of paragraphs in a document that intersect a given range of
// global positions.
void _selectParagraphsInRange(
{required Offset from, Offset? to, SelectionChangedCause? cause}) {
final TextBoundary paragraphBoundary =
ParagraphBoundary(editor!.textEditingValue.text);
_selectTextBoundariesInRange(
boundary: paragraphBoundary, from: from, to: to, cause: cause);
}
// Selects the set of lines in a document that intersect a given range of
// global positions.
void _selectLinesInRange(
{required Offset from, Offset? to, SelectionChangedCause? cause}) {
final TextBoundary lineBoundary = LineBoundary(renderEditor!);
_selectTextBoundariesInRange(
boundary: lineBoundary, from: from, to: to, cause: cause);
}
// Returns the location of a text boundary at `extent`. When `extent` is at
// the end of the text, returns the previous text boundary's location.
TextRange _moveToTextBoundary(
TextPosition extent, TextBoundary textBoundary) {
assert(extent.offset >= 0);
final start = textBoundary.getLeadingTextBoundaryAt(
extent.offset == editor!.textEditingValue.text.length
? extent.offset - 1
: extent.offset) ??
0;
final end = textBoundary.getTrailingTextBoundaryAt(extent.offset) ??
editor!.textEditingValue.text.length;
return TextRange(start: start, end: end);
}
// Selects the set of text boundaries in a document that intersect a given
// range of global positions.
//
// The set of text boundaries selected are not strictly bounded by the range
// of global positions.
//
// The first and last endpoints of the selection will always be at the
// beginning and end of a text boundary respectively.
void _selectTextBoundariesInRange(
{required TextBoundary boundary,
required Offset from,
Offset? to,
SelectionChangedCause? cause}) {
final fromPosition = renderEditor!.getPositionForOffset(from);
final fromRange = _moveToTextBoundary(fromPosition, boundary);
final toPosition =
to == null ? fromPosition : renderEditor!.getPositionForOffset(to);
final toRange = toPosition == fromPosition
? fromRange
: _moveToTextBoundary(toPosition, boundary);
final isFromBoundaryBeforeToBoundary = fromRange.start < toRange.end;
final newSelection = isFromBoundaryBeforeToBoundary
? TextSelection(baseOffset: fromRange.start, extentOffset: toRange.end)
: TextSelection(baseOffset: fromRange.end, extentOffset: toRange.start);
editor!.userUpdateTextEditingValue(
editor!.textEditingValue.copyWith(selection: newSelection),
cause ?? SelectionChangedCause.drag);
}
/// Handler for [TextSelectionGestureDetector.onTripleTapDown].
///
/// By default, it selects a paragraph if
/// [TextSelectionGestureDetectorBuilderDelegate.selectionEnabled] is true
/// and shows the toolbar if necessary.
///
/// See also:
///
/// * [TextSelectionGestureDetector.onTripleTapDown], which triggers this
/// callback.
@protected
void onTripleTapDown(TapDragDownDetails details) {
if (!delegate.selectionEnabled) {
return;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
case TargetPlatform.macOS:
case TargetPlatform.windows:
_selectParagraphsInRange(
from: details.globalPosition, cause: SelectionChangedCause.tap);
case TargetPlatform.linux:
_selectLinesInRange(
from: details.globalPosition, cause: SelectionChangedCause.tap);
}
if (shouldShowSelectionToolbar) {
editor?.showToolbar();
}
}
/// Handler for [EditorTextSelectionGestureDetector.onDragSelectionStart]. /// Handler for [EditorTextSelectionGestureDetector.onDragSelectionStart].
/// ///
/// By default, it selects a text position specified in [details]. /// By default, it selects a text position specified in [details].
@ -307,8 +645,106 @@ class EditorTextSelectionGestureDetectorBuilder {
/// * [EditorTextSelectionGestureDetector.onDragSelectionStart], /// * [EditorTextSelectionGestureDetector.onDragSelectionStart],
/// which triggers this callback. /// which triggers this callback.
@protected @protected
void onDragSelectionStart(DragStartDetails details) { void onDragSelectionStart(TapDragStartDetails details) {
renderEditor!.handleDragStart(details); if (delegate.selectionEnabled == false) return;
// underline show open on ios and android,
// when has isCollapsed, show not reposonse to tapdarg gesture
// so that will not change texteditingvalue,
// and same issue to TextField, tap selection area, will lost selection,
// if (editor?.textEditingValue.selection.isCollapsed == false) return;
final kind = details.kind;
shouldShowSelectionToolbar = kind == null ||
kind == PointerDeviceKind.touch ||
kind == PointerDeviceKind.stylus;
_dragStartSelection = renderEditor?.selection;
_dragStartScrollOffset = _scrollPosition;
_dragStartViewportOffset = renderEditor?.offset?.pixels ?? 0.0;
_dragBeganOnPreviousSelection =
_positionOnSelection(details.globalPosition, _dragStartSelection);
if (EditorTextSelectionGestureDetector.getEffectiveConsecutiveTapCount(
details.consecutiveTapCount) >
1) {
// Do not set the selection on a consecutive tap and drag.
return;
}
if (_isShiftPressed &&
renderEditor?.selection != null &&
renderEditor?.selection.isValid == true) {
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
renderEditor?.extendSelection(details.globalPosition,
cause: SelectionChangedCause.drag);
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
renderEditor?.extendSelection(details.globalPosition,
cause: SelectionChangedCause.drag);
}
} else {
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
switch (details.kind) {
case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad:
renderEditor?.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.drag,
);
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.touch:
case PointerDeviceKind.unknown:
// For iOS platforms, a touch drag does not initiate unless the
// editable has focus and the drag began on the previous selection.
assert(_dragBeganOnPreviousSelection != null);
if (renderEditor?.hasFocus == true &&
_dragBeganOnPreviousSelection!) {
renderEditor?.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.drag,
);
editor?.showMagnifier(details.globalPosition);
}
case null:
}
case TargetPlatform.android:
case TargetPlatform.fuchsia:
switch (details.kind) {
case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad:
renderEditor?.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.drag,
);
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.touch:
case PointerDeviceKind.unknown:
// For Android, Fucshia, and iOS platforms, a touch drag
// does not initiate unless the editable has focus.
if (renderEditor?.hasFocus == true) {
renderEditor?.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.drag,
);
editor?.showMagnifier(details.globalPosition);
}
case null:
}
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
renderEditor?.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.drag,
);
}
}
} }
/// Handler for [EditorTextSelectionGestureDetector.onDragSelectionUpdate]. /// Handler for [EditorTextSelectionGestureDetector.onDragSelectionUpdate].
@ -321,13 +757,206 @@ class EditorTextSelectionGestureDetectorBuilder {
/// * [EditorTextSelectionGestureDetector.onDragSelectionUpdate], /// * [EditorTextSelectionGestureDetector.onDragSelectionUpdate],
/// which triggers this callback./lib/src/material/text_field.dart /// which triggers this callback./lib/src/material/text_field.dart
@protected @protected
void onDragSelectionUpdate( void onDragSelectionUpdate(TapDragUpdateDetails updateDetails) {
//DragStartDetails startDetails, if (delegate.selectionEnabled == false) return;
DragUpdateDetails updateDetails) { // if (editor?.textEditingValue.selection.isCollapsed == false) return;
renderEditor!.extendSelection( if (!_isShiftPressed) {
updateDetails.globalPosition, // Adjust the drag start offset for possible viewport offset changes.
cause: SelectionChangedCause.drag, final editableOffset =
); Offset(0, renderEditor!.offset!.pixels - _dragStartViewportOffset);
final scrollableOffset =
Offset(0, _scrollPosition - _dragStartScrollOffset);
final dragStartGlobalPosition =
updateDetails.globalPosition - updateDetails.offsetFromOrigin;
// Select word by word.
if (EditorTextSelectionGestureDetector.getEffectiveConsecutiveTapCount(
updateDetails.consecutiveTapCount) ==
2) {
renderEditor?.selectWordsInRange(
dragStartGlobalPosition - editableOffset - scrollableOffset,
updateDetails.globalPosition,
SelectionChangedCause.drag,
);
switch (updateDetails.kind) {
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.touch:
case PointerDeviceKind.unknown:
return editor?.updateMagnifier(updateDetails.globalPosition);
case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad:
case null:
return;
}
}
// Select paragraph-by-paragraph.
if (EditorTextSelectionGestureDetector.getEffectiveConsecutiveTapCount(
updateDetails.consecutiveTapCount) ==
3) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
switch (updateDetails.kind) {
case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad:
return _selectParagraphsInRange(
from: dragStartGlobalPosition -
editableOffset -
scrollableOffset,
to: updateDetails.globalPosition,
cause: SelectionChangedCause.drag,
);
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.touch:
case PointerDeviceKind.unknown:
case null:
// Triple tap to drag is not present on these platforms when using
// non-precise pointer devices at the moment.
break;
}
return;
case TargetPlatform.linux:
return _selectLinesInRange(
from: dragStartGlobalPosition - editableOffset - scrollableOffset,
to: updateDetails.globalPosition,
cause: SelectionChangedCause.drag,
);
case TargetPlatform.windows:
case TargetPlatform.macOS:
return _selectParagraphsInRange(
from: dragStartGlobalPosition - editableOffset - scrollableOffset,
to: updateDetails.globalPosition,
cause: SelectionChangedCause.drag,
);
}
}
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
// With a touch device, nothing should happen, unless there was a double tap, or
// there was a collapsed selection, and the tap/drag position is at the collapsed selection.
// In that case the caret should move with the drag position.
//
// With a mouse device, a drag should select the range from the origin of the drag
// to the current position of the drag.
switch (updateDetails.kind) {
case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad:
renderEditor?.selectPositionAt(
from:
dragStartGlobalPosition - editableOffset - scrollableOffset,
to: updateDetails.globalPosition,
cause: SelectionChangedCause.drag,
);
return;
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.touch:
case PointerDeviceKind.unknown:
assert(_dragBeganOnPreviousSelection != null);
if (renderEditor?.hasFocus == true &&
_dragStartSelection!.isCollapsed &&
_dragBeganOnPreviousSelection!) {
renderEditor?.selectPositionAt(
from: updateDetails.globalPosition,
cause: SelectionChangedCause.drag,
);
return editor?.updateMagnifier(updateDetails.globalPosition);
}
case null:
break;
}
return;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
// With a precise pointer device, such as a mouse, trackpad, or stylus,
// the drag will select the text spanning the origin of the drag to the end of the drag.
// With a touch device, the cursor should move with the drag.
switch (updateDetails.kind) {
case PointerDeviceKind.mouse:
case PointerDeviceKind.trackpad:
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
renderEditor?.selectPositionAt(
from:
dragStartGlobalPosition - editableOffset - scrollableOffset,
to: updateDetails.globalPosition,
cause: SelectionChangedCause.drag,
);
return;
case PointerDeviceKind.touch:
case PointerDeviceKind.unknown:
if (renderEditor?.hasFocus == true) {
renderEditor?.selectPositionAt(
from: updateDetails.globalPosition,
cause: SelectionChangedCause.drag,
);
return editor?.updateMagnifier(updateDetails.globalPosition);
}
case null:
break;
}
return;
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
renderEditor?.selectPositionAt(
from: dragStartGlobalPosition - editableOffset - scrollableOffset,
to: updateDetails.globalPosition,
cause: SelectionChangedCause.drag,
);
}
}
if (_dragStartSelection!.isCollapsed ||
(defaultTargetPlatform != TargetPlatform.iOS &&
defaultTargetPlatform != TargetPlatform.macOS)) {
return _extendSelection(
updateDetails.globalPosition, SelectionChangedCause.drag);
}
// If the drag inverts the selection, Mac and iOS revert to the initial
// selection.
final selection = renderEditor!.selection;
final nextExtent =
renderEditor!.getPositionForOffset(updateDetails.globalPosition);
final isShiftTapDragSelectionForward =
_dragStartSelection!.baseOffset < _dragStartSelection!.extentOffset;
final isInverted = isShiftTapDragSelectionForward
? nextExtent.offset < _dragStartSelection!.baseOffset
: nextExtent.offset > _dragStartSelection!.baseOffset;
if (isInverted && selection.baseOffset == _dragStartSelection!.baseOffset) {
editor?.userUpdateTextEditingValue(
editor!.textEditingValue.copyWith(
selection: TextSelection(
baseOffset: _dragStartSelection!.extentOffset,
extentOffset: nextExtent.offset,
),
),
SelectionChangedCause.drag,
);
} else if (!isInverted &&
nextExtent.offset != _dragStartSelection!.baseOffset &&
selection.baseOffset != _dragStartSelection!.baseOffset) {
editor?.userUpdateTextEditingValue(
editor!.textEditingValue.copyWith(
selection: TextSelection(
baseOffset: _dragStartSelection!.baseOffset,
extentOffset: nextExtent.offset,
),
),
SelectionChangedCause.drag,
);
} else {
_extendSelection(
updateDetails.globalPosition, SelectionChangedCause.drag);
}
} }
/// Handler for [EditorTextSelectionGestureDetector.onDragSelectionEnd]. /// Handler for [EditorTextSelectionGestureDetector.onDragSelectionEnd].
@ -339,7 +968,8 @@ class EditorTextSelectionGestureDetectorBuilder {
/// * [EditorTextSelectionGestureDetector.onDragSelectionEnd], /// * [EditorTextSelectionGestureDetector.onDragSelectionEnd],
/// which triggers this callback. /// which triggers this callback.
@protected @protected
void onDragSelectionEnd(DragEndDetails details) { void onDragSelectionEnd(TapDragEndDetails details) {
// if (editor?.textEditingValue.selection.isCollapsed == false) return;
renderEditor!.handleDragEnd(details); renderEditor!.handleDragEnd(details);
if (isDesktop(supportWeb: true) && if (isDesktop(supportWeb: true) &&
delegate.selectionEnabled && delegate.selectionEnabled &&
@ -347,6 +977,7 @@ class EditorTextSelectionGestureDetectorBuilder {
// added to show selection copy/paste toolbar after drag to select // added to show selection copy/paste toolbar after drag to select
editor!.showToolbar(); editor!.showToolbar();
} }
editor?.hideMagnifier();
} }
/// Returns a [EditorTextSelectionGestureDetector] configured with /// Returns a [EditorTextSelectionGestureDetector] configured with
@ -361,21 +992,26 @@ class EditorTextSelectionGestureDetectorBuilder {
}) { }) {
return EditorTextSelectionGestureDetector( return EditorTextSelectionGestureDetector(
key: key, key: key,
onTapTrackStart: onTapTrackStart,
onTapTrackReset: onTapTrackReset,
onTapDown: onTapDown, onTapDown: onTapDown,
onForcePressStart: delegate.forcePressEnabled ? onForcePressStart : null, onForcePressStart: delegate.forcePressEnabled ? onForcePressStart : null,
onForcePressEnd: delegate.forcePressEnabled ? onForcePressEnd : null, onForcePressEnd: delegate.forcePressEnabled ? onForcePressEnd : null,
onSecondaryTap: onSecondaryTap,
onSecondaryTapDown: onSecondaryTapDown,
onSingleTapUp: onSingleTapUp, onSingleTapUp: onSingleTapUp,
onSingleTapCancel: onSingleTapCancel, onSingleTapCancel: onSingleTapCancel,
onUserTap: onUserTap,
onSingleLongTapStart: onSingleLongTapStart, onSingleLongTapStart: onSingleLongTapStart,
onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate, onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate,
onSingleLongTapEnd: onSingleLongTapEnd, onSingleLongTapEnd: onSingleLongTapEnd,
onDoubleTapDown: onDoubleTapDown, onDoubleTapDown: onDoubleTapDown,
onSecondarySingleTapUp: onSecondarySingleTapUp, onTripleTapDown: onTripleTapDown,
onDragSelectionStart: onDragSelectionStart, onDragSelectionStart: onDragSelectionStart,
onDragSelectionUpdate: onDragSelectionUpdate, onDragSelectionUpdate: onDragSelectionUpdate,
onDragSelectionEnd: onDragSelectionEnd, onDragSelectionEnd: onDragSelectionEnd,
onUserTapAlwaysCalled: onUserTapAlwaysCalled,
behavior: behavior, behavior: behavior,
detectWordBoundary: detectWordBoundary,
child: child, child: child,
); );
} }

@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -187,6 +186,8 @@ class EditorTextSelectionOverlay {
final MagnifierController _magnifierController = MagnifierController(); final MagnifierController _magnifierController = MagnifierController();
bool get magnifierIsVisible => _magnifierController.shown;
final TextMagnifierConfiguration magnifierConfiguration; final TextMagnifierConfiguration magnifierConfiguration;
final ValueNotifier<MagnifierInfo> _magnifierInfo = final ValueNotifier<MagnifierInfo> _magnifierInfo =
@ -790,31 +791,39 @@ class EditorTextSelectionGestureDetector extends StatefulWidget {
/// The [child] parameter must not be null. /// The [child] parameter must not be null.
const EditorTextSelectionGestureDetector({ const EditorTextSelectionGestureDetector({
required this.child, required this.child,
super.key,
this.onTapTrackStart,
this.onTapTrackReset,
this.onTapDown, this.onTapDown,
this.onForcePressStart, this.onForcePressStart,
this.onForcePressEnd, this.onForcePressEnd,
this.onSecondaryTap,
this.onSecondaryTapDown,
this.onSingleTapUp, this.onSingleTapUp,
this.onSingleTapCancel, this.onSingleTapCancel,
this.onSecondaryTapDown, this.onUserTap,
this.onSecondarySingleTapUp,
this.onSecondarySingleTapCancel,
this.onSecondaryDoubleTapDown,
this.onSingleLongTapStart, this.onSingleLongTapStart,
this.onSingleLongTapMoveUpdate, this.onSingleLongTapMoveUpdate,
this.onSingleLongTapEnd, this.onSingleLongTapEnd,
this.onDoubleTapDown, this.onDoubleTapDown,
this.onTripleTapDown,
this.onDragSelectionStart, this.onDragSelectionStart,
this.onDragSelectionUpdate, this.onDragSelectionUpdate,
this.onDragSelectionEnd, this.onDragSelectionEnd,
this.onUserTapAlwaysCalled = false,
this.behavior, this.behavior,
this.detectWordBoundary = true,
super.key,
}); });
/// {@macro flutter.gestures.selectionrecognizers.BaseTapAndDragGestureRecognizer.onTapTrackStart}
final VoidCallback? onTapTrackStart;
/// {@macro flutter.gestures.selectionrecognizers.BaseTapAndDragGestureRecognizer.onTapTrackReset}
final VoidCallback? onTapTrackReset;
/// Called for every tap down including every tap down that's part of a /// 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 /// double click or a long press, except touches that include enough movement
/// to not qualify as taps (e.g. pans and flings). /// to not qualify as taps (e.g. pans and flings).
final GestureTapDownCallback? onTapDown; final GestureTapDragDownCallback? onTapDown;
/// Called when a pointer has tapped down and the force of the pointer has /// Called when a pointer has tapped down and the force of the pointer has
/// just become greater than [ForcePressGestureRecognizer.startPressure]. /// just become greater than [ForcePressGestureRecognizer.startPressure].
@ -824,28 +833,31 @@ class EditorTextSelectionGestureDetector extends StatefulWidget {
/// lifted off the screen. /// lifted off the screen.
final GestureForcePressEndCallback? onForcePressEnd; final GestureForcePressEndCallback? onForcePressEnd;
/// Called for each distinct tap except for every second tap of a double tap. /// Called for a tap event with the secondary mouse button.
final GestureTapCallback? onSecondaryTap;
/// Called for a tap down event with the secondary mouse button.
final GestureTapDownCallback? onSecondaryTapDown;
/// Called for the first tap in a series of taps, consecutive taps do not call
/// this method.
///
/// For example, if the detector was configured with [onTapDown] and /// For example, if the detector was configured with [onTapDown] and
/// [onDoubleTapDown], three quick taps would be recognized as a single tap /// [onDoubleTapDown], three quick taps would be recognized as a single tap
/// down, followed by a double tap down, followed by a single tap down. /// down, followed by a tap up, then a double tap down, followed by a single tap down.
final GestureTapUpCallback? onSingleTapUp; final GestureTapDragUpCallback? onSingleTapUp;
/// Called for each touch that becomes recognized as a gesture that is not a /// 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 /// short tap, such as a long tap or drag. It is called at the moment when
/// another gesture from the touch is recognized. /// another gesture from the touch is recognized.
final GestureTapCancelCallback? onSingleTapCancel; final GestureCancelCallback? 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 /// Called for the first tap in a series of taps when [onUserTapAlwaysCalled] is
final GestureTapDownCallback? onSecondaryDoubleTapDown; /// disabled, which is the default behavior.
///
/// When [onUserTapAlwaysCalled] is enabled, this is called for every tap,
/// including consecutive taps.
final GestureTapCallback? onUserTap;
/// Called for a single long tap that's sustained for longer than /// Called for a single long tap that's sustained for longer than
/// [kLongPressTimeout] but not necessarily lifted. Not called for a /// [kLongPressTimeout] but not necessarily lifted. Not called for a
@ -860,20 +872,25 @@ class EditorTextSelectionGestureDetector extends StatefulWidget {
/// Called after a momentary hold or a short tap that is close in space and /// Called after a momentary hold or a short tap that is close in space and
/// time (within [kDoubleTapTimeout]) to a previous short tap. /// time (within [kDoubleTapTimeout]) to a previous short tap.
final GestureTapDownCallback? onDoubleTapDown; final GestureTapDragDownCallback? onDoubleTapDown;
/// Called after a momentary hold or a short tap that is close in space and
/// time (within [kDoubleTapTimeout]) to a previous double-tap.
final GestureTapDragDownCallback? onTripleTapDown;
/// Called when a mouse starts dragging to select text. /// Called when a mouse starts dragging to select text.
final GestureDragStartCallback? onDragSelectionStart; final GestureTapDragStartCallback? onDragSelectionStart;
/// Called repeatedly as a mouse moves while dragging. /// Called repeatedly as a mouse moves while dragging.
/// final GestureTapDragUpdateCallback? onDragSelectionUpdate;
/// 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. /// Called when a mouse that was previously dragging is released.
final GestureDragEndCallback? onDragSelectionEnd; final GestureTapDragEndCallback? onDragSelectionEnd;
/// Whether [onUserTap] will be called for all taps including consecutive taps.
///
/// Defaults to false, so [onUserTap] is only called for each distinct tap.
final bool onUserTapAlwaysCalled;
/// How this gesture detector should behave during hit testing. /// How this gesture detector should behave during hit testing.
/// ///
@ -883,210 +900,145 @@ class EditorTextSelectionGestureDetector extends StatefulWidget {
/// Child below this widget. /// Child below this widget.
final Widget child; final Widget child;
final bool detectWordBoundary;
@override @override
State<StatefulWidget> createState() => State<StatefulWidget> createState() =>
_EditorTextSelectionGestureDetectorState(); _EditorTextSelectionGestureDetectorState();
static int getEffectiveConsecutiveTapCount(int rawCount) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
// From observation, these platform's reset their tap count to 0 when
// the number of consecutive taps exceeds 3. For example on Debian Linux
// with GTK, when going past a triple click, on the fourth click the
// selection is moved to the precise click position, on the fifth click
// the word at the position is selected, and on the sixth click the
// paragraph at the position is selected.
return rawCount <= 3
? rawCount
: (rawCount % 3 == 0 ? 3 : rawCount % 3);
case TargetPlatform.iOS:
case TargetPlatform.macOS:
// From observation, these platform's either hold their tap count at 3.
// For example on macOS, when going past a triple click, the selection
// should be retained at the paragraph that was first selected on triple
// click.
return math.min(rawCount, 3);
case TargetPlatform.windows:
// From observation, this platform's consecutive tap actions alternate
// between double click and triple click actions. For example, after a
// triple click has selected a paragraph, on the next click the word at
// the clicked position will be selected, and on the next click the
// paragraph at the position is selected.
return rawCount < 2 ? rawCount : 2 + rawCount % 2;
}
}
} }
class _EditorTextSelectionGestureDetectorState class _EditorTextSelectionGestureDetectorState
extends State<EditorTextSelectionGestureDetector> { extends State<EditorTextSelectionGestureDetector> {
// Counts down for a short duration after a previous tap. Null otherwise. // Converts the details.consecutiveTapCount from a TapAndDrag*Details object,
Timer? _doubleTapTimer; // which can grow to be infinitely large, to a value between 1 and 3. The value
Offset? _lastTapOffset; // that the raw count is converted to is based on the default observed behavior
// on the native platforms.
// True if a second tap down of a double tap is detected. Used to discard //
// subsequent tap up / tap hold of the same tap. // This method should be used in all instances when details.consecutiveTapCount
bool _isDoubleTap = false; // would be used.
// _isDoubleTap for mouse right click void _handleTapTrackStart() {
bool _isSecondaryDoubleTap = false; widget.onTapTrackStart?.call();
}
@override void _handleTapTrackReset() {
void dispose() { widget.onTapTrackReset?.call();
_doubleTapTimer?.cancel();
_dragUpdateThrottleTimer?.cancel();
super.dispose();
} }
// The down handler is force-run on success of a single tap and optimistically // The down handler is force-run on success of a single tap and optimistically
// run before a long press success. // run before a long press success.
void _handleTapDown(TapDownDetails details) { void _handleTapDown(TapDragDownDetails details) {
widget.onTapDown?.call(details); widget.onTapDown?.call(details);
// This isn't detected as a double tap gesture in the gesture recognizer // 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 // because it's 2 single taps, each of which may do different things depending
// depending on whether it's a single tap, the first tap of a double tap, // on whether it's a single tap, the first tap of a double tap, the second
// the second tap held down, a clean double tap etc. // tap held down, a clean double tap etc.
if (_doubleTapTimer != null && if (EditorTextSelectionGestureDetector.getEffectiveConsecutiveTapCount(
_isWithinDoubleTapTolerance(details.globalPosition)) { details.consecutiveTapCount) ==
// If there was already a previous tap, the second down hold/tap is a 2) {
// double tap down. return widget.onDoubleTapDown?.call(details);
}
widget.onDoubleTapDown?.call(details);
if (EditorTextSelectionGestureDetector.getEffectiveConsecutiveTapCount(
_doubleTapTimer!.cancel(); details.consecutiveTapCount) ==
_doubleTapTimeout(); 3) {
_isDoubleTap = true; return widget.onTripleTapDown?.call(details);
} }
} }
void _handleTapUp(TapUpDetails details) { void _handleTapUp(TapDragUpDetails details) {
if (!_isDoubleTap) { if (EditorTextSelectionGestureDetector.getEffectiveConsecutiveTapCount(
details.consecutiveTapCount) ==
1) {
widget.onSingleTapUp?.call(details); widget.onSingleTapUp?.call(details);
_lastTapOffset = details.globalPosition; widget.onUserTap?.call();
_doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout); } else if (widget.onUserTapAlwaysCalled) {
widget.onUserTap?.call();
} }
_isDoubleTap = false;
} }
void _handleTapCancel() { void _handleTapCancel() {
widget.onSingleTapCancel?.call(); widget.onSingleTapCancel?.call();
} }
// added secondary tap function for mouse right click to show toolbar void _handleDragStart(TapDragStartDetails details) {
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); widget.onDragSelectionStart?.call(details);
} }
void _handleDragUpdate(DragUpdateDetails details) { void _handleDragUpdate(TapDragUpdateDetails details) {
_lastDragUpdateDetails = details; widget.onDragSelectionUpdate?.call(details);
_dragUpdateThrottleTimer ??= Timer(
const Duration(milliseconds: 50),
_handleDragUpdateThrottled,
);
} }
/// Drag updates are being throttled to avoid excessive text layouts in text void _handleDragEnd(TapDragEndDetails details) {
/// 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); widget.onDragSelectionEnd?.call(details);
_dragUpdateThrottleTimer = null;
_lastDragStartDetails = null;
_lastDragUpdateDetails = null;
} }
void _forcePressStarted(ForcePressDetails details) { void _forcePressStarted(ForcePressDetails details) {
_doubleTapTimer?.cancel();
_doubleTapTimer = null;
widget.onForcePressStart?.call(details); widget.onForcePressStart?.call(details);
} }
void _forcePressEnded(ForcePressDetails details) { void _forcePressEnded(ForcePressDetails details) {
if (widget.onForcePressEnd != null) { widget.onForcePressEnd?.call(details);
widget.onForcePressEnd?.call(details);
}
} }
void _handleLongPressStart(LongPressStartDetails details) { void _handleLongPressStart(LongPressStartDetails details) {
if (!_isDoubleTap) { if (widget.onSingleLongTapStart != null) {
widget.onSingleLongTapStart?.call(details); widget.onSingleLongTapStart!(details);
} }
} }
void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
if (!_isDoubleTap) { if (widget.onSingleLongTapMoveUpdate != null) {
widget.onSingleLongTapMoveUpdate?.call(details); widget.onSingleLongTapMoveUpdate!(details);
} }
} }
void _handleLongPressEnd(LongPressEndDetails details) { void _handleLongPressEnd(LongPressEndDetails details) {
if (!_isDoubleTap) { if (widget.onSingleLongTapEnd != null) {
widget.onSingleLongTapEnd?.call(details); widget.onSingleLongTapEnd!(details);
} }
_isDoubleTap = false;
}
void _doubleTapTimeout() {
_doubleTapTimer = null;
_lastTapOffset = null;
}
bool _isWithinDoubleTapTolerance(Offset secondTapOffset) {
if (_lastTapOffset == null) {
return false;
}
return (secondTapOffset - _lastTapOffset!).distance <= kDoubleTapSlop;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final gestures = <Type, GestureRecognizerFactory>{}; final gestures = <Type, GestureRecognizerFactory>{};
// Use _TransparentTapGestureRecognizer so that TextSelectionGestureDetector gestures[TapGestureRecognizer] =
// can receive the same tap events that a selection handle placed visually GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
// on top of it also receives. () => TapGestureRecognizer(debugOwner: this),
gestures[_TransparentTapGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<_TransparentTapGestureRecognizer>(
() => _TransparentTapGestureRecognizer(debugOwner: this),
(instance) { (instance) {
instance instance
..onTapDown = _handleTapDown ..onSecondaryTap = widget.onSecondaryTap
..onTapUp = _handleTapUp ..onSecondaryTapDown = widget.onSecondaryTapDown;
..onTapCancel = _handleTapCancel
..onSecondaryTapDown = _handleSecondaryTapDown
..onSecondaryTapUp = _handleSecondaryTapUp
..onSecondaryTapCancel = _handleSecondaryTapCancel;
}, },
); );
@ -1110,21 +1062,51 @@ class _EditorTextSelectionGestureDetectorState
if (widget.onDragSelectionStart != null || if (widget.onDragSelectionStart != null ||
widget.onDragSelectionUpdate != null || widget.onDragSelectionUpdate != null ||
widget.onDragSelectionEnd != null) { widget.onDragSelectionEnd != null) {
gestures[HorizontalDragGestureRecognizer] = switch (defaultTargetPlatform) {
GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>( case TargetPlatform.android:
() => HorizontalDragGestureRecognizer( case TargetPlatform.fuchsia:
debugOwner: this, case TargetPlatform.iOS:
supportedDevices: <PointerDeviceKind>{PointerDeviceKind.mouse}), gestures[TapAndHorizontalDragGestureRecognizer] =
(instance) { GestureRecognizerFactoryWithHandlers<
// Text selection should start from the position of the first pointer TapAndHorizontalDragGestureRecognizer>(
// down event. () => TapAndHorizontalDragGestureRecognizer(debugOwner: this),
instance (instance) {
..dragStartBehavior = DragStartBehavior.down instance
..onStart = _handleDragStart // Text selection should start from the position of the first pointer
..onUpdate = _handleDragUpdate // down event.
..onEnd = _handleDragEnd; ..dragStartBehavior = DragStartBehavior.down
}, ..onTapTrackStart = _handleTapTrackStart
); ..onTapTrackReset = _handleTapTrackReset
..onTapDown = _handleTapDown
..onDragStart = _handleDragStart
..onDragUpdate = _handleDragUpdate
..onDragEnd = _handleDragEnd
..onTapUp = _handleTapUp
..onCancel = _handleTapCancel;
},
);
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
gestures[TapAndPanGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
() => TapAndPanGestureRecognizer(debugOwner: this),
(instance) {
instance
// Text selection should start from the position of the first pointer
// down event.
..dragStartBehavior = DragStartBehavior.down
..onTapTrackStart = _handleTapTrackStart
..onTapTrackReset = _handleTapTrackReset
..onTapDown = _handleTapDown
..onDragStart = _handleDragStart
..onDragUpdate = _handleDragUpdate
..onDragEnd = _handleDragEnd
..onTapUp = _handleTapUp
..onCancel = _handleTapCancel;
},
);
}
} }
if (widget.onForcePressStart != null || widget.onForcePressEnd != null) { if (widget.onForcePressStart != null || widget.onForcePressEnd != null) {
@ -1148,32 +1130,3 @@ class _EditorTextSelectionGestureDetectorState
); );
} }
} }
// 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);
}
}
}

Loading…
Cancel
Save