From d1713504a0ae567bd37da482e308c1e2cb051791 Mon Sep 17 00:00:00 2001 From: dom Date: Wed, 28 Jan 2026 18:51:58 +0800 Subject: [PATCH] opt gesture Signed-off-by: dom --- .../selectable_text/selectable_region.dart | 2256 +++++++++++++++++ .../selectable_text.dart | 2 +- .../selectable_text/selection_area.dart | 147 ++ .../tap_and_drag.dart | 39 + .../widgets/flutter/selectable_text/text.dart | 42 + .../text_selection.dart | 7 +- .../widgets/flutter/text_intro/text.dart | 22 - .../live_room/superchat/superchat_card.dart | 3 +- lib/pages/pgc_review/child/view.dart | 3 +- lib/pages/video/ai_conclusion/view.dart | 2 +- .../pgc/widgets/intro_detail.dart | 2 +- lib/pages/video/introduction/ugc/view.dart | 5 +- .../ugc/widgets/selectable_text.dart | 21 - 13 files changed, 2499 insertions(+), 52 deletions(-) create mode 100644 lib/common/widgets/flutter/selectable_text/selectable_region.dart rename lib/common/widgets/flutter/{text_intro => selectable_text}/selectable_text.dart (99%) create mode 100644 lib/common/widgets/flutter/selectable_text/selection_area.dart rename lib/common/widgets/flutter/{text_intro => selectable_text}/tap_and_drag.dart (96%) create mode 100644 lib/common/widgets/flutter/selectable_text/text.dart rename lib/common/widgets/flutter/{text_intro => selectable_text}/text_selection.dart (98%) delete mode 100644 lib/common/widgets/flutter/text_intro/text.dart delete mode 100644 lib/pages/video/introduction/ugc/widgets/selectable_text.dart diff --git a/lib/common/widgets/flutter/selectable_text/selectable_region.dart b/lib/common/widgets/flutter/selectable_text/selectable_region.dart new file mode 100644 index 000000000..9598cab6a --- /dev/null +++ b/lib/common/widgets/flutter/selectable_text/selectable_region.dart @@ -0,0 +1,2256 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:math'; + +import 'package:PiliPlus/common/widgets/flutter/selectable_text/tap_and_drag.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' + hide + BaseTapAndDragGestureRecognizer, + TapAndHorizontalDragGestureRecognizer, + TapAndPanGestureRecognizer; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:vector_math/vector_math_64.dart'; + +// Examples can assume: +// late GlobalKey key; + +const Set _kLongPressSelectionDevices = { + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, +}; + +/// A widget that introduces an area for user selections. +/// +/// Flutter widgets are not selectable by default. Wrapping a widget subtree +/// with a [SelectableRegion] widget enables selection within that subtree (for +/// example, [Text] widgets automatically look for selectable regions to enable +/// selection). The wrapped subtree can be selected by users using mouse or +/// touch gestures, e.g. users can select widgets by holding the mouse +/// left-click and dragging across widgets, or they can use long press gestures +/// to select words on touch devices. +/// +/// A [SelectableRegion] widget requires configuration; in particular specific +/// [selectionControls] must be provided. +/// +/// The [SelectionArea] widget from the [material] library configures a +/// [SelectableRegion] in a platform-specific manner (e.g. using a Material +/// toolbar on Android, a Cupertino toolbar on iOS), and it may therefore be +/// simpler to use that widget rather than using [SelectableRegion] directly. +/// +/// ## An overview of the selection system. +/// +/// Every [Selectable] under the [SelectableRegion] can be selected. They form a +/// selection tree structure to handle the selection. +/// +/// The [SelectableRegion] is a wrapper over [SelectionContainer]. It listens to +/// user gestures and sends corresponding [SelectionEvent]s to the +/// [SelectionContainer] it creates. +/// +/// A [SelectionContainer] is a single [Selectable] that handles +/// [SelectionEvent]s on behalf of child [Selectable]s in the subtree. It +/// creates a [SelectionRegistrarScope] with its [SelectionContainer.delegate] +/// to collect child [Selectable]s and sends the [SelectionEvent]s it receives +/// from the parent [SelectionRegistrar] to the appropriate child [Selectable]s. +/// It creates an abstraction for the parent [SelectionRegistrar] as if it is +/// interacting with a single [Selectable]. +/// +/// The [SelectionContainer] created by [SelectableRegion] is the root node of a +/// selection tree. Each non-leaf node in the tree is a [SelectionContainer], +/// and the leaf node is a leaf widget whose render object implements +/// [Selectable]. They are connected through [SelectionRegistrarScope]s created +/// by [SelectionContainer]s. +/// +/// Both [SelectionContainer]s and the leaf [Selectable]s need to register +/// themselves to the [SelectionRegistrar] from the +/// [SelectionContainer.maybeOf] if they want to participate in the +/// selection. +/// +/// An example selection tree will look like: +/// +/// {@tool snippet} +/// +/// ```dart +/// MaterialApp( +/// home: SelectableRegion( +/// selectionControls: materialTextSelectionControls, +/// child: Scaffold( +/// appBar: AppBar(title: const Text('Flutter Code Sample')), +/// body: ListView( +/// children: const [ +/// Text('Item 0', style: TextStyle(fontSize: 50.0)), +/// Text('Item 1', style: TextStyle(fontSize: 50.0)), +/// ], +/// ), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// +/// SelectionContainer +/// (SelectableRegion) +/// / \ +/// / \ +/// / \ +/// Selectable \ +/// ("Flutter Code Sample") \ +/// \ +/// SelectionContainer +/// (ListView) +/// / \ +/// / \ +/// / \ +/// Selectable Selectable +/// ("Item 0") ("Item 1") +/// +/// +/// ## Making a widget selectable +/// +/// Some leaf widgets, such as [Text], have all of the selection logic wired up +/// automatically and can be selected as long as they are under a +/// [SelectableRegion]. +/// +/// To make a custom selectable widget, its render object needs to mix in +/// [Selectable] and implement the required APIs to handle [SelectionEvent]s +/// as well as paint appropriate selection highlights. +/// +/// The render object also needs to register itself to a [SelectionRegistrar]. +/// For the most cases, one can use [SelectionRegistrant] to auto-register +/// itself with the register returned from [SelectionContainer.maybeOf] as +/// seen in the example below. +/// +/// {@tool dartpad} +/// This sample demonstrates how to create an adapter widget that makes any +/// child widget selectable. +/// +/// ** See code in examples/api/lib/material/selectable_region/selectable_region.0.dart ** +/// {@end-tool} +/// +/// ## Complex layout +/// +/// By default, the screen order is used as the selection order. If a group of +/// [Selectable]s needs to select differently, consider wrapping them with a +/// [SelectionContainer] to customize its selection behavior. +/// +/// {@tool dartpad} +/// This sample demonstrates how to create a [SelectionContainer] that only +/// allows selecting everything or nothing with no partial selection. +/// +/// ** See code in examples/api/lib/material/selection_container/selection_container.0.dart ** +/// {@end-tool} +/// +/// In the case where a group of widgets should be excluded from selection under +/// a [SelectableRegion], consider wrapping that group of widgets using +/// [SelectionContainer.disabled]. +/// +/// {@tool dartpad} +/// This sample demonstrates how to disable selection for a Text in a Column. +/// +/// ** See code in examples/api/lib/material/selection_container/selection_container_disabled.0.dart ** +/// {@end-tool} +/// +/// To create a separate selection system from its parent selection area, +/// wrap part of the subtree with another [SelectableRegion]. The selection of the +/// child selection area can not extend past its subtree, and the selection of +/// the parent selection area can not extend inside the child selection area. +/// +/// ## Selection status +/// +/// A [SelectableRegion]s [SelectableRegionSelectionStatus] is used to indicate whether +/// the [SelectableRegion] is actively changing the selection, or has finalized it. For +/// example, during a mouse click + drag, the [SelectableRegionSelectionStatus] will be +/// set to [SelectableRegionSelectionStatus.changing], and when the mouse click is released +/// the status will be set to [SelectableRegionSelectionStatus.finalized]. +/// +/// The default value of [SelectableRegion]s selection status +/// is [SelectableRegionSelectionStatus.finalized]. +/// +/// To access the [SelectableRegionSelectionStatus] of a parent [SelectableRegion] +/// use [SelectableRegionSelectionStatusScope.maybeOf] and retrieve the value from +/// the [ValueListenable]. +/// +/// One can also listen for changes to the [SelectableRegionSelectionStatus] by +/// adding a listener to the [ValueListenable] retrieved from [SelectableRegionSelectionStatusScope.maybeOf] +/// through [ValueListenable.addListener]. In Stateful widgets this is typically +/// done in [State.didChangeDependencies]. Remove the listener when no longer +/// needed, typically in your Stateful widgets [State.dispose] method through +/// [ValueListenable.removeListener]. +/// +/// ## Tests +/// +/// In a test, a region can be selected either by faking drag events (e.g. using +/// [WidgetTester.dragFrom]) or by sending intents to a widget inside the region +/// that has been given a [GlobalKey], e.g.: +/// +/// ```dart +/// Actions.invoke(key.currentContext!, const SelectAllTextIntent(SelectionChangedCause.keyboard)); +/// ``` +/// +/// See also: +/// +/// * [SelectionArea], which creates a [SelectableRegion] with +/// platform-adaptive selection controls. +/// * [SelectableText], which enables selection on a single run of text. +/// * [SelectionHandler], which contains APIs to handle selection events from the +/// [SelectableRegion]. +/// * [Selectable], which provides API to participate in the selection system. +/// * [SelectionRegistrar], which [Selectable] needs to subscribe to receive +/// selection events. +/// * [SelectionContainer], which collects selectable widgets in the subtree +/// and provides api to dispatch selection event to the collected widget. +/// * [SelectionListener], which enables accessing the [SelectionDetails] of +/// the selectable subtree it wraps. +class SelectableRegion extends StatefulWidget { + /// Create a new [SelectableRegion] widget. + /// + /// The [selectionControls] are used for building the selection handles and + /// toolbar for mobile devices. + const SelectableRegion({ + super.key, + this.contextMenuBuilder, + this.focusNode, + this.magnifierConfiguration = TextMagnifierConfiguration.disabled, + this.onSelectionChanged, + required this.selectionControls, + required this.child, + }); + + /// The configuration for the magnifier used with selections in this region. + /// + /// By default, [SelectableRegion]'s [TextMagnifierConfiguration] is disabled. + /// For a version of [SelectableRegion] that adapts automatically to the + /// current platform, consider [SelectionArea]. + /// + /// {@macro flutter.widgets.magnifier.intro} + final TextMagnifierConfiguration magnifierConfiguration; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// The child widget this selection area applies to. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + final SelectableRegionContextMenuBuilder? contextMenuBuilder; + + /// The delegate to build the selection handles and toolbar for mobile + /// devices. + /// + /// The [emptyTextSelectionControls] global variable provides a default + /// [TextSelectionControls] implementation with no controls. + final TextSelectionControls selectionControls; + + /// Called when the selected content changes. + final ValueChanged? onSelectionChanged; + + /// Returns the [ContextMenuButtonItem]s representing the buttons in this + /// platform's default selection menu. + /// + /// For example, [SelectableRegion] uses this to generate the default buttons + /// for its context menu. + /// + /// See also: + /// + /// * [SelectableRegionState.contextMenuButtonItems], which gives the + /// [ContextMenuButtonItem]s for a specific SelectableRegion. + /// * [EditableText.getEditableButtonItems], which performs a similar role but + /// for content that is both selectable and editable. + /// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can + /// take a list of [ContextMenuButtonItem]s with + /// [AdaptiveTextSelectionToolbar.buttonItems]. + /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button + /// Widgets for the current platform given [ContextMenuButtonItem]s. + static List getSelectableButtonItems({ + required final SelectionGeometry selectionGeometry, + required final VoidCallback onCopy, + required final VoidCallback onSelectAll, + required final VoidCallback? onShare, + }) { + final bool canCopy = + selectionGeometry.status == SelectionStatus.uncollapsed; + final bool canSelectAll = selectionGeometry.hasContent; + final bool platformCanShare = switch (defaultTargetPlatform) { + TargetPlatform.android => + selectionGeometry.status == SelectionStatus.uncollapsed, + TargetPlatform.macOS || + TargetPlatform.fuchsia || + TargetPlatform.linux || + TargetPlatform.windows => false, + // TODO(bleroux): the share button should be shown on iOS but the share + // functionality requires some changes on the engine side because, on iPad, + // it needs an anchor for the popup. + // See: https://github.com/flutter/flutter/issues/141775. + TargetPlatform.iOS => false, + }; + final bool canShare = onShare != null && platformCanShare; + + // On Android, the share button is before the select all button. + final bool showShareBeforeSelectAll = + defaultTargetPlatform == TargetPlatform.android; + + // Determine which buttons will appear so that the order and total number is + // known. A button's position in the menu can slightly affect its + // appearance. + return [ + if (canCopy) + ContextMenuButtonItem( + onPressed: onCopy, + type: ContextMenuButtonType.copy, + ), + if (canShare && showShareBeforeSelectAll) + ContextMenuButtonItem( + onPressed: onShare, + type: ContextMenuButtonType.share, + ), + if (canSelectAll) + ContextMenuButtonItem( + onPressed: onSelectAll, + type: ContextMenuButtonType.selectAll, + ), + if (canShare && !showShareBeforeSelectAll) + ContextMenuButtonItem( + onPressed: onShare, + type: ContextMenuButtonType.share, + ), + ]; + } + + @override + State createState() => SelectableRegionState(); +} + +/// State for a [SelectableRegion]. +class SelectableRegionState extends State + with TextSelectionDelegate + implements SelectionRegistrar { + late final Map> _actions = >{ + SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), + CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)), + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable( + _GranularlyExtendSelectionAction< + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent + >( + this, + granularity: TextGranularity.word, + ), + ), + ExpandSelectionToDocumentBoundaryIntent: _makeOverridable( + _GranularlyExtendSelectionAction( + this, + granularity: TextGranularity.document, + ), + ), + ExpandSelectionToLineBreakIntent: _makeOverridable( + _GranularlyExtendSelectionAction( + this, + granularity: TextGranularity.line, + ), + ), + ExtendSelectionByCharacterIntent: _makeOverridable( + _GranularlyExtendCaretSelectionAction( + this, + granularity: TextGranularity.character, + ), + ), + ExtendSelectionToNextWordBoundaryIntent: _makeOverridable( + _GranularlyExtendCaretSelectionAction< + ExtendSelectionToNextWordBoundaryIntent + >( + this, + granularity: TextGranularity.word, + ), + ), + ExtendSelectionToLineBreakIntent: _makeOverridable( + _GranularlyExtendCaretSelectionAction( + this, + granularity: TextGranularity.line, + ), + ), + ExtendSelectionVerticallyToAdjacentLineIntent: _makeOverridable( + _DirectionallyExtendCaretSelectionAction< + ExtendSelectionVerticallyToAdjacentLineIntent + >(this), + ), + ExtendSelectionToDocumentBoundaryIntent: _makeOverridable( + _GranularlyExtendCaretSelectionAction< + ExtendSelectionToDocumentBoundaryIntent + >( + this, + granularity: TextGranularity.document, + ), + ), + }; + + final Map _gestureRecognizers = + {}; + SelectionOverlay? _selectionOverlay; + final LayerLink _startHandleLayerLink = LayerLink(); + final LayerLink _endHandleLayerLink = LayerLink(); + final LayerLink _toolbarLayerLink = LayerLink(); + final StaticSelectionContainerDelegate _selectionDelegate = + StaticSelectionContainerDelegate(); + // there should only ever be one selectable, which is the SelectionContainer. + Selectable? _selectable; + + bool get _hasSelectionOverlayGeometry => + _selectionDelegate.value.startSelectionPoint != null || + _selectionDelegate.value.endSelectionPoint != null; + + Orientation? _lastOrientation; + SelectedContent? _lastSelectedContent; + + /// The [SelectionOverlay] that is currently visible on the screen. + /// + /// Can be null if there is no visible [SelectionOverlay]. + @visibleForTesting + SelectionOverlay? get selectionOverlay => _selectionOverlay; + + /// The text processing service used to retrieve the native text processing actions. + final ProcessTextService _processTextService = DefaultProcessTextService(); + + /// The list of native text processing actions provided by the engine. + final List _processTextActions = []; + + // The focus node to use if the widget didn't supply one. + FocusNode? _localFocusNode; + FocusNode get _focusNode => + widget.focusNode ?? + (_localFocusNode ??= FocusNode(debugLabel: 'SelectableRegion')); + + /// Notifies its listeners when the selection state in this [SelectableRegion] changes. + final _SelectableRegionSelectionStatusNotifier _selectionStatusNotifier = + _SelectableRegionSelectionStatusNotifier._(); + + @protected + @override + void initState() { + super.initState(); + _focusNode.addListener(_handleFocusChanged); + _initMouseGestureRecognizer(); + _initTouchGestureRecognizer(); + // Right clicks. + _gestureRecognizers[TapGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this), + (TapGestureRecognizer instance) { + instance.onSecondaryTapDown = _handleRightClickDown; + }, + ); + _initProcessTextActions(); + } + + /// Query the engine to initialize the list of text processing actions to show + /// in the text selection toolbar. + Future _initProcessTextActions() async { + _processTextActions + ..clear() + ..addAll(await _processTextService.queryTextActions()); + } + + @protected + @override + void didChangeDependencies() { + super.didChangeDependencies(); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + break; + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + return; + } + + // Hide the text selection toolbar on mobile when orientation changes. + final Orientation orientation = MediaQuery.orientationOf(context); + if (_lastOrientation == null) { + _lastOrientation = orientation; + return; + } + if (orientation != _lastOrientation) { + _lastOrientation = orientation; + hideToolbar(defaultTargetPlatform == TargetPlatform.android); + } + } + + @protected + @override + void didUpdateWidget(SelectableRegion oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.focusNode != oldWidget.focusNode) { + if (oldWidget.focusNode == null && widget.focusNode != null) { + _localFocusNode?.removeListener(_handleFocusChanged); + _localFocusNode?.dispose(); + _localFocusNode = null; + } else if (widget.focusNode == null && oldWidget.focusNode != null) { + oldWidget.focusNode!.removeListener(_handleFocusChanged); + } + _focusNode.addListener(_handleFocusChanged); + if (_focusNode.hasFocus != oldWidget.focusNode?.hasFocus) { + _handleFocusChanged(); + } + } + } + + Action _makeOverridable(Action defaultAction) { + return Action.overridable( + context: context, + defaultAction: defaultAction, + ); + } + + void _handleFocusChanged() { + if (!_focusNode.hasFocus) { + if (kIsWeb) { + PlatformSelectableRegionContextMenu.detach(_selectionDelegate); + } + if (SchedulerBinding.instance.lifecycleState == + AppLifecycleState.resumed) { + // We should only clear the selection when this SelectableRegion loses + // focus while the application is currently running. It is possible + // that the application is not currently running, for example on desktop + // platforms, clicking on a different window switches the focus to + // the new window causing the Flutter application to go inactive. In this + // case we want to retain the selection so it remains when we return to + // the Flutter application. + clearSelection(); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + _finalizeSelectableRegionStatus(); + } + } + if (kIsWeb) { + PlatformSelectableRegionContextMenu.attach(_selectionDelegate); + } + } + + void _updateSelectionStatus() { + final SelectionGeometry geometry = _selectionDelegate.value; + final TextSelection selection = switch (geometry.status) { + SelectionStatus.uncollapsed || SelectionStatus.collapsed => + const TextSelection(baseOffset: 0, extentOffset: 1), + SelectionStatus.none => const TextSelection.collapsed(offset: 1), + }; + textEditingValue = TextEditingValue(text: '__', selection: selection); + if (_hasSelectionOverlayGeometry) { + _updateSelectionOverlay(); + } else { + _selectionOverlay?.dispose(); + _selectionOverlay = null; + } + } + + // gestures. + + /// Whether the Shift key was pressed when the most recent [PointerDownEvent] + /// was tracked by the [BaseTapAndDragGestureRecognizer]. + bool _isShiftPressed = false; + + // The position of the most recent secondary tap down event on this + // SelectableRegion. + Offset? _lastSecondaryTapDownPosition; + + // The device kind for the pointer of the most recent tap down event on this + // SelectableRegion. + PointerDeviceKind? _lastPointerDeviceKind; + + static bool _isPrecisePointerDevice(PointerDeviceKind pointerDeviceKind) { + switch (pointerDeviceKind) { + case PointerDeviceKind.mouse: + return true; + case PointerDeviceKind.trackpad: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + return false; + } + } + + void _finalizeSelectableRegionStatus() { + if (_selectionStatusNotifier.value != + SelectableRegionSelectionStatus.changing) { + // Don't finalize the selection again if it is not currently changing. + return; + } + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; + } + + // Converts the details.consecutiveTapCount from a TapAndDrag*Details object, + // which can grow to be infinitely large, to a value between 1 and the supported + // max consecutive tap count. The value that the raw count is converted to is + // based on the default observed behavior on the native platforms. + // + // This method should be used in all instances when details.consecutiveTapCount + // would be used. + int _getEffectiveConsecutiveTapCount(int rawCount) { + int maxConsecutiveTap = 3; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + if (_lastPointerDeviceKind != null && + _lastPointerDeviceKind != PointerDeviceKind.mouse) { + // When the pointer device kind is not precise like a mouse, native + // Android resets the tap count at 2. For example, this is so the + // selection can collapse on the third tap. + maxConsecutiveTap = 2; + } + // From observation, these platforms reset their tap count to 0 when + // the number of consecutive taps exceeds the max consecutive tap supported. + // For example on native Android, 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 <= maxConsecutiveTap + ? rawCount + : (rawCount % maxConsecutiveTap == 0 + ? maxConsecutiveTap + : rawCount % maxConsecutiveTap); + case TargetPlatform.linux: + // From observation, these platforms reset their tap count to 0 when + // the number of consecutive taps exceeds the max consecutive tap supported. + // 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 <= maxConsecutiveTap + ? rawCount + : (rawCount % maxConsecutiveTap == 0 + ? maxConsecutiveTap + : rawCount % maxConsecutiveTap); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.windows: + // From observation, these platforms hold their tap count at the max + // consecutive tap supported. 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 min(rawCount, maxConsecutiveTap); + } + } + + void _initMouseGestureRecognizer() { + _gestureRecognizers[TapAndPanGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => TapAndPanGestureRecognizer( + debugOwner: this, + supportedDevices: {PointerDeviceKind.mouse}, + ), + (TapAndPanGestureRecognizer instance) { + instance + ..onTapTrackStart = _onTapTrackStart + ..onTapTrackReset = _onTapTrackReset + ..onTapDown = _startNewMouseSelectionGesture + ..onTapUp = _handleMouseTapUp + ..onDragStart = _handleMouseDragStart + ..onDragUpdate = _handleMouseDragUpdate + ..onDragEnd = _handleMouseDragEnd + ..onCancel = clearSelection + ..dragStartBehavior = DragStartBehavior.down; + }, + ); + } + + void _onTapTrackStart() { + _isShiftPressed = HardwareKeyboard.instance.logicalKeysPressed.intersection( + { + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + }, + ).isNotEmpty; + } + + void _onTapTrackReset() { + _isShiftPressed = false; + } + + void _initTouchGestureRecognizer() { + // A [TapAndHorizontalDragGestureRecognizer] is used on non-precise pointer devices + // like PointerDeviceKind.touch so [SelectableRegion] gestures do not conflict with + // ancestor Scrollable gestures in common scenarios like a vertically scrolling list view. + _gestureRecognizers[TapAndHorizontalDragGestureRecognizer] = + GestureRecognizerFactoryWithHandlers< + TapAndHorizontalDragGestureRecognizer + >( + () => TapAndHorizontalDragGestureRecognizer( + debugOwner: this, + supportedDevices: PointerDeviceKind.values.where(( + PointerDeviceKind device, + ) { + return device != PointerDeviceKind.mouse; + }).toSet(), + ), + (TapAndHorizontalDragGestureRecognizer instance) { + instance + // iOS does not provide a device specific touch slop + // unlike Android (~8.0), so the touch slop for a [Scrollable] + // always default to kTouchSlop which is 18.0. When + // [SelectableRegion] is the child of a horizontal + // scrollable that means the [SelectableRegion] will + // always win the gesture arena when competing with + // the ancestor scrollable because they both have + // the same touch slop threshold and the child receives + // the [PointerEvent] first. To avoid this conflict + // and ensure a smooth scrolling experience, on + // iOS the [TapAndHorizontalDragGestureRecognizer] + // will wait for all other gestures to lose before + // declaring victory. + ..eagerVictoryOnDrag = defaultTargetPlatform != TargetPlatform.iOS + ..onTapDown = _startNewMouseSelectionGesture + ..onTapUp = _handleMouseTapUp + ..onDragStart = _handleMouseDragStart + ..onDragUpdate = _handleMouseDragUpdate + ..onDragEnd = _handleMouseDragEnd + ..onCancel = clearSelection + ..dragStartBehavior = DragStartBehavior.down; + }, + ); + _gestureRecognizers[LongPressGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer( + debugOwner: this, + supportedDevices: _kLongPressSelectionDevices, + ), + (LongPressGestureRecognizer instance) { + instance + ..onLongPressStart = _handleTouchLongPressStart + ..onLongPressMoveUpdate = _handleTouchLongPressMoveUpdate + ..onLongPressEnd = _handleTouchLongPressEnd; + }, + ); + } + + Offset? _doubleTapOffset; + void _startNewMouseSelectionGesture(TapDragDownDetails details) { + _lastPointerDeviceKind = details.kind; + switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) { + case 1: + _focusNode.requestFocus(); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + // On mobile platforms the selection is set on tap up for the first + // tap. + break; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + hideToolbar(); + // It is impossible to extend the selection when the shift key is + // pressed and the start of the selection has not been initialized. + // In this case we fallback on collapsing the selection to first + // initialize the selection. + final bool isShiftPressedValid = + _isShiftPressed && + _selectionDelegate.value.startSelectionPoint != null; + if (isShiftPressedValid) { + _selectEndTo(offset: details.globalPosition); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + break; + } + clearSelection(); + _collapseSelectionAt(offset: details.globalPosition); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + } + case 2: + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + if (kIsWeb && + details.kind != null && + !_isPrecisePointerDevice(details.kind!)) { + // Double tap on iOS web triggers when a drag begins after the double tap. + _doubleTapOffset = details.globalPosition; + break; + } + _selectWordAt(offset: details.globalPosition); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + if (details.kind != null && + !_isPrecisePointerDevice(details.kind!)) { + _showHandles(); + } + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + _selectWordAt(offset: details.globalPosition); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + } + case 3: + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + if (details.kind != null && + _isPrecisePointerDevice(details.kind!)) { + // Triple tap on static text is only supported on mobile + // platforms using a precise pointer device. + _selectParagraphAt(offset: details.globalPosition); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + } + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + _selectParagraphAt(offset: details.globalPosition); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + } + } + _updateSelectedContentIfNeeded(); + } + + void _handleMouseDragStart(TapDragStartDetails details) { + switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) { + case 1: + if (details.kind != null && !_isPrecisePointerDevice(details.kind!)) { + // Drag to select is only enabled with a precise pointer device. + return; + } + _selectStartTo(offset: details.globalPosition); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + } + _updateSelectedContentIfNeeded(); + } + + void _handleMouseDragUpdate(TapDragUpdateDetails details) { + switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) { + case 1: + if (details.kind != null && !_isPrecisePointerDevice(details.kind!)) { + // Drag to select is only enabled with a precise pointer device. + return; + } + _selectEndTo(offset: details.globalPosition, continuous: true); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + case 2: + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + // Double tap + drag is only supported on Android when using a precise + // pointer device or when not on the web. + if (!kIsWeb || + details.kind != null && + _isPrecisePointerDevice(details.kind!)) { + _selectEndTo( + offset: details.globalPosition, + continuous: true, + textGranularity: TextGranularity.word, + ); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + } + case TargetPlatform.iOS: + if (kIsWeb && + details.kind != null && + !_isPrecisePointerDevice(details.kind!) && + _doubleTapOffset != null) { + // On iOS web a double tap does not select the word at the position, + // until the drag has begun. + _selectWordAt(offset: _doubleTapOffset!); + _doubleTapOffset = null; + } + _selectEndTo( + offset: details.globalPosition, + continuous: true, + textGranularity: TextGranularity.word, + ); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + if (details.kind != null && + !_isPrecisePointerDevice(details.kind!)) { + _showHandles(); + } + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + _selectEndTo( + offset: details.globalPosition, + continuous: true, + textGranularity: TextGranularity.word, + ); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + } + case 3: + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + // Triple tap + drag is only supported on mobile devices when using + // a precise pointer device. + if (details.kind != null && + _isPrecisePointerDevice(details.kind!)) { + _selectEndTo( + offset: details.globalPosition, + continuous: true, + textGranularity: TextGranularity.paragraph, + ); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + } + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + _selectEndTo( + offset: details.globalPosition, + continuous: true, + textGranularity: TextGranularity.paragraph, + ); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + } + } + _updateSelectedContentIfNeeded(); + } + + void _handleMouseDragEnd(TapDragEndDetails details) { + assert(_lastPointerDeviceKind != null); + final bool isPointerPrecise = _isPrecisePointerDevice( + _lastPointerDeviceKind!, + ); + // On mobile platforms like android, fuchsia, and iOS, a drag gesture will + // only show the selection overlay when the drag has finished and the pointer + // device kind is not precise, for example at the end of a double tap + drag + // to select on native iOS. + final bool shouldShowSelectionOverlayOnMobile = !isPointerPrecise; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + if (shouldShowSelectionOverlayOnMobile) { + _showHandles(); + _showToolbar(); + } + case TargetPlatform.iOS: + if (shouldShowSelectionOverlayOnMobile) { + _showToolbar(); + } + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + // The selection overlay is not shown on desktop platforms after a drag. + break; + } + _finalizeSelection(); + _updateSelectedContentIfNeeded(); + _finalizeSelectableRegionStatus(); + } + + void _handleMouseTapUp(TapDragUpDetails details) { + if (defaultTargetPlatform == TargetPlatform.iOS && + _positionIsOnActiveSelection(globalPosition: details.globalPosition)) { + // On iOS when the tap occurs on the previous selection, instead of + // moving the selection, the context menu will be toggled. + final bool toolbarIsVisible = + _selectionOverlay?.toolbarIsVisible ?? false; + if (toolbarIsVisible) { + hideToolbar(false); + } else { + _showToolbar(); + } + return; + } + switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) { + case 1: + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + hideToolbar(); + _collapseSelectionAt(offset: details.globalPosition); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + // On desktop platforms the selection is set on tap down. + } + case 2: + final bool isPointerPrecise = _isPrecisePointerDevice(details.kind); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + if (!isPointerPrecise) { + // On Android, a double tap will only show the selection overlay after + // the following tap up when the pointer device kind is not precise. + _showHandles(); + _showToolbar(); + } + case TargetPlatform.iOS: + if (!isPointerPrecise) { + if (kIsWeb) { + // Double tap on iOS web only triggers when a drag begins after the double tap. + break; + } + // On iOS, a double tap will only show the selection toolbar after + // the following tap up when the pointer device kind is not precise. + _showToolbar(); + } + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + // The selection overlay is not shown on desktop platforms + // on a double click. + break; + } + } + _finalizeSelectableRegionStatus(); + _updateSelectedContentIfNeeded(); + } + + void _updateSelectedContentIfNeeded() { + if (widget.onSelectionChanged == null) { + return; + } + final SelectedContent? content = _selectable?.getSelectedContent(); + if (_lastSelectedContent?.plainText != content?.plainText) { + _lastSelectedContent = content; + widget.onSelectionChanged!.call(_lastSelectedContent); + } + } + + void _handleTouchLongPressStart(LongPressStartDetails details) { + HapticFeedback.selectionClick(); + _focusNode.requestFocus(); + _selectWordAt(offset: details.globalPosition); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + // Platforms besides Android will show the text selection handles when + // the long press is initiated. Android shows the text selection handles when + // the long press has ended, usually after a pointer up event is received. + if (defaultTargetPlatform != TargetPlatform.android) { + _showHandles(); + } + _updateSelectedContentIfNeeded(); + } + + void _handleTouchLongPressMoveUpdate(LongPressMoveUpdateDetails details) { + _selectEndTo( + offset: details.globalPosition, + textGranularity: TextGranularity.word, + ); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + _updateSelectedContentIfNeeded(); + } + + void _handleTouchLongPressEnd(LongPressEndDetails details) { + _finalizeSelection(); + _updateSelectedContentIfNeeded(); + _finalizeSelectableRegionStatus(); + _showToolbar(); + if (defaultTargetPlatform == TargetPlatform.android) { + _showHandles(); + } + } + + bool _positionIsOnActiveSelection({required Offset globalPosition}) { + for (final Rect selectionRect in _selectionDelegate.value.selectionRects) { + final Matrix4 transform = _selectable!.getTransformTo(null); + final Rect globalRect = MatrixUtils.transformRect( + transform, + selectionRect, + ); + if (globalRect.contains(globalPosition)) { + return true; + } + } + return false; + } + + void _handleRightClickDown(TapDownDetails details) { + final Offset? previousSecondaryTapDownPosition = + _lastSecondaryTapDownPosition; + final bool toolbarIsVisible = _selectionOverlay?.toolbarIsVisible ?? false; + _lastSecondaryTapDownPosition = details.globalPosition; + _focusNode.requestFocus(); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.windows: + // If _lastSecondaryTapDownPosition is within the current selection then + // keep the current selection, if not then collapse it. + final bool lastSecondaryTapDownPositionWasOnActiveSelection = + _positionIsOnActiveSelection( + globalPosition: details.globalPosition, + ); + if (lastSecondaryTapDownPositionWasOnActiveSelection) { + // Restore _lastSecondaryTapDownPosition since it may be cleared if a user + // accesses contextMenuAnchors. + _lastSecondaryTapDownPosition = details.globalPosition; + _showHandles(); + _showToolbar(location: _lastSecondaryTapDownPosition); + _updateSelectedContentIfNeeded(); + return; + } + _collapseSelectionAt(offset: _lastSecondaryTapDownPosition!); + case TargetPlatform.iOS: + _selectWordAt(offset: _lastSecondaryTapDownPosition!); + case TargetPlatform.macOS: + if (previousSecondaryTapDownPosition == _lastSecondaryTapDownPosition && + toolbarIsVisible) { + hideToolbar(); + return; + } + _selectWordAt(offset: _lastSecondaryTapDownPosition!); + case TargetPlatform.linux: + if (toolbarIsVisible) { + hideToolbar(); + return; + } + // If _lastSecondaryTapDownPosition is within the current selection then + // keep the current selection, if not then collapse it. + final bool lastSecondaryTapDownPositionWasOnActiveSelection = + _positionIsOnActiveSelection( + globalPosition: details.globalPosition, + ); + if (!lastSecondaryTapDownPositionWasOnActiveSelection) { + _collapseSelectionAt(offset: _lastSecondaryTapDownPosition!); + } + } + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + _finalizeSelectableRegionStatus(); + // Restore _lastSecondaryTapDownPosition since it may be cleared if a user + // accesses contextMenuAnchors. + _lastSecondaryTapDownPosition = details.globalPosition; + _showHandles(); + _showToolbar(location: _lastSecondaryTapDownPosition); + _updateSelectedContentIfNeeded(); + } + + // Selection update helper methods. + + Offset? _selectionEndPosition; + bool get _userDraggingSelectionEnd => _selectionEndPosition != null; + bool _scheduledSelectionEndEdgeUpdate = false; + + /// Sends end [SelectionEdgeUpdateEvent] to the selectable subtree. + /// + /// If the selectable subtree returns a [SelectionResult.pending], this method + /// continues to send [SelectionEdgeUpdateEvent]s every frame until the result + /// is not pending or users end their gestures. + void _triggerSelectionEndEdgeUpdate({TextGranularity? textGranularity}) { + // This method can be called when the drag is not in progress. This can + // happen if the child scrollable returns SelectionResult.pending, and + // the selection area scheduled a selection update for the next frame, but + // the drag is lifted before the scheduled selection update is run. + if (_scheduledSelectionEndEdgeUpdate || !_userDraggingSelectionEnd) { + return; + } + if (_selectable?.dispatchSelectionEvent( + SelectionEdgeUpdateEvent.forEnd( + globalPosition: _selectionEndPosition!, + granularity: textGranularity, + ), + ) == + SelectionResult.pending) { + _scheduledSelectionEndEdgeUpdate = true; + SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { + if (!_scheduledSelectionEndEdgeUpdate) { + return; + } + _scheduledSelectionEndEdgeUpdate = false; + _triggerSelectionEndEdgeUpdate(textGranularity: textGranularity); + }, debugLabel: 'SelectableRegion.endEdgeUpdate'); + return; + } + } + + void _onAnyDragEnd(DragEndDetails details) { + final bool draggingHandles = + _selectionOverlay != null && + (_selectionOverlay!.isDraggingStartHandle || + _selectionOverlay!.isDraggingEndHandle); + if (widget.selectionControls is! TextSelectionHandleControls) { + if (!draggingHandles) { + _selectionOverlay!.hideMagnifier(); + _selectionOverlay!.showToolbar(); + } + } else { + if (!draggingHandles) { + _selectionOverlay!.hideMagnifier(); + _selectionOverlay!.showToolbar( + context: context, + contextMenuBuilder: (BuildContext context) { + return widget.contextMenuBuilder!(context, this); + }, + ); + } + } + _finalizeSelection(); + _updateSelectedContentIfNeeded(); + _finalizeSelectableRegionStatus(); + } + + void _stopSelectionEndEdgeUpdate() { + _scheduledSelectionEndEdgeUpdate = false; + _selectionEndPosition = null; + } + + Offset? _selectionStartPosition; + bool get _userDraggingSelectionStart => _selectionStartPosition != null; + bool _scheduledSelectionStartEdgeUpdate = false; + + /// Sends start [SelectionEdgeUpdateEvent] to the selectable subtree. + /// + /// If the selectable subtree returns a [SelectionResult.pending], this method + /// continues to send [SelectionEdgeUpdateEvent]s every frame until the result + /// is not pending or users end their gestures. + void _triggerSelectionStartEdgeUpdate({TextGranularity? textGranularity}) { + // This method can be called when the drag is not in progress. This can + // happen if the child scrollable returns SelectionResult.pending, and + // the selection area scheduled a selection update for the next frame, but + // the drag is lifted before the scheduled selection update is run. + if (_scheduledSelectionStartEdgeUpdate || !_userDraggingSelectionStart) { + return; + } + if (_selectable?.dispatchSelectionEvent( + SelectionEdgeUpdateEvent.forStart( + globalPosition: _selectionStartPosition!, + granularity: textGranularity, + ), + ) == + SelectionResult.pending) { + _scheduledSelectionStartEdgeUpdate = true; + SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { + if (!_scheduledSelectionStartEdgeUpdate) { + return; + } + _scheduledSelectionStartEdgeUpdate = false; + _triggerSelectionStartEdgeUpdate(textGranularity: textGranularity); + }, debugLabel: 'SelectableRegion.startEdgeUpdate'); + return; + } + } + + void _stopSelectionStartEdgeUpdate() { + _scheduledSelectionStartEdgeUpdate = false; + _selectionEndPosition = null; + } + + // SelectionOverlay helper methods. + + late Offset _selectionStartHandleDragPosition; + late Offset _selectionEndHandleDragPosition; + + void _handleSelectionStartHandleDragStart(DragStartDetails details) { + assert(_selectionDelegate.value.startSelectionPoint != null); + + final Offset localPosition = + _selectionDelegate.value.startSelectionPoint!.localPosition; + final Matrix4 globalTransform = _selectable!.getTransformTo(null); + _selectionStartHandleDragPosition = MatrixUtils.transformPoint( + globalTransform, + localPosition, + ); + + _selectionOverlay!.showMagnifier( + _buildInfoForMagnifier( + details.globalPosition, + _selectionDelegate.value.startSelectionPoint!, + ), + ); + _updateSelectedContentIfNeeded(); + } + + void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) { + _selectionStartHandleDragPosition = + _selectionStartHandleDragPosition + details.delta; + // The value corresponds to the paint origin of the selection handle. + // Offset it to the center of the line to make it feel more natural. + _selectionStartPosition = + _selectionStartHandleDragPosition - + Offset(0, _selectionDelegate.value.startSelectionPoint!.lineHeight / 2); + _triggerSelectionStartEdgeUpdate(); + + _selectionOverlay!.updateMagnifier( + _buildInfoForMagnifier( + details.globalPosition, + _selectionDelegate.value.startSelectionPoint!, + ), + ); + _updateSelectedContentIfNeeded(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + } + + void _handleSelectionEndHandleDragStart(DragStartDetails details) { + assert(_selectionDelegate.value.endSelectionPoint != null); + final Offset localPosition = + _selectionDelegate.value.endSelectionPoint!.localPosition; + final Matrix4 globalTransform = _selectable!.getTransformTo(null); + _selectionEndHandleDragPosition = MatrixUtils.transformPoint( + globalTransform, + localPosition, + ); + + _selectionOverlay!.showMagnifier( + _buildInfoForMagnifier( + details.globalPosition, + _selectionDelegate.value.endSelectionPoint!, + ), + ); + _updateSelectedContentIfNeeded(); + } + + void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) { + _selectionEndHandleDragPosition = + _selectionEndHandleDragPosition + details.delta; + // The value corresponds to the paint origin of the selection handle. + // Offset it to the center of the line to make it feel more natural. + _selectionEndPosition = + _selectionEndHandleDragPosition - + Offset(0, _selectionDelegate.value.endSelectionPoint!.lineHeight / 2); + _triggerSelectionEndEdgeUpdate(); + + _selectionOverlay!.updateMagnifier( + _buildInfoForMagnifier( + details.globalPosition, + _selectionDelegate.value.endSelectionPoint!, + ), + ); + _updateSelectedContentIfNeeded(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + } + + MagnifierInfo _buildInfoForMagnifier( + Offset globalGesturePosition, + SelectionPoint selectionPoint, + ) { + final Vector3 globalTransform = _selectable! + .getTransformTo(null) + .getTranslation(); + final Offset globalTransformAsOffset = Offset( + globalTransform.x, + globalTransform.y, + ); + final Offset globalSelectionPointPosition = + selectionPoint.localPosition + globalTransformAsOffset; + final Rect caretRect = Rect.fromLTWH( + globalSelectionPointPosition.dx, + globalSelectionPointPosition.dy - selectionPoint.lineHeight, + 0, + selectionPoint.lineHeight, + ); + + return MagnifierInfo( + globalGesturePosition: globalGesturePosition, + caretRect: caretRect, + fieldBounds: globalTransformAsOffset & _selectable!.size, + currentLineBoundaries: globalTransformAsOffset & _selectable!.size, + ); + } + + void _createSelectionOverlay() { + assert(_hasSelectionOverlayGeometry); + if (_selectionOverlay != null) { + return; + } + final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint; + final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint; + _selectionOverlay = SelectionOverlay( + context: context, + debugRequiredFor: widget, + startHandleType: start?.handleType ?? TextSelectionHandleType.collapsed, + lineHeightAtStart: start?.lineHeight ?? end!.lineHeight, + onStartHandleDragStart: _handleSelectionStartHandleDragStart, + onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate, + onStartHandleDragEnd: _onAnyDragEnd, + endHandleType: end?.handleType ?? TextSelectionHandleType.collapsed, + lineHeightAtEnd: end?.lineHeight ?? start!.lineHeight, + onEndHandleDragStart: _handleSelectionEndHandleDragStart, + onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate, + onEndHandleDragEnd: _onAnyDragEnd, + selectionEndpoints: selectionEndpoints, + selectionControls: widget.selectionControls, + selectionDelegate: this, + clipboardStatus: null, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + toolbarLayerLink: _toolbarLayerLink, + magnifierConfiguration: widget.magnifierConfiguration, + ); + } + + void _updateSelectionOverlay() { + if (_selectionOverlay == null) { + return; + } + assert(_hasSelectionOverlayGeometry); + final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint; + final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint; + _selectionOverlay! + ..startHandleType = start?.handleType ?? TextSelectionHandleType.left + ..lineHeightAtStart = start?.lineHeight ?? end!.lineHeight + ..endHandleType = end?.handleType ?? TextSelectionHandleType.right + ..lineHeightAtEnd = end?.lineHeight ?? start!.lineHeight + ..selectionEndpoints = selectionEndpoints; + } + + /// Shows the selection handles. + /// + /// Returns true if the handles are shown, false if the handles can't be + /// shown. + bool _showHandles() { + if (_selectionOverlay != null) { + _selectionOverlay!.showHandles(); + return true; + } + + if (!_hasSelectionOverlayGeometry) { + return false; + } + + _createSelectionOverlay(); + _selectionOverlay!.showHandles(); + return true; + } + + /// Shows the text selection toolbar. + /// + /// If the parameter `location` is set, the toolbar will be shown at the + /// location. Otherwise, the toolbar location will be calculated based on the + /// handles' locations. The `location` is in the coordinates system of the + /// [Overlay]. + /// + /// Returns true if the toolbar is shown, false if the toolbar can't be shown. + bool _showToolbar({Offset? location}) { + if (!_hasSelectionOverlayGeometry && _selectionOverlay == null) { + return false; + } + + // Web is using native dom elements to enable clipboard functionality of the + // context menu: copy, paste, select, cut. It might also provide additional + // functionality depending on the browser (such as translate). Due to this, + // we should not show a Flutter toolbar for the editable text elements + // unless the browser's context menu is explicitly disabled. + if (kIsWeb && BrowserContextMenu.enabled) { + return false; + } + + if (_selectionOverlay == null) { + _createSelectionOverlay(); + } + + _selectionOverlay!.toolbarLocation = location; + if (widget.selectionControls is! TextSelectionHandleControls) { + _selectionOverlay!.showToolbar(); + return true; + } + + _selectionOverlay!.hideToolbar(); + + _selectionOverlay!.showToolbar( + context: context, + contextMenuBuilder: (BuildContext context) { + return widget.contextMenuBuilder!(context, this); + }, + ); + return true; + } + + /// Sets or updates selection end edge to the `offset` location. + /// + /// A selection always contains a select start edge and selection end edge. + /// They can be created by calling both [_selectStartTo] and [_selectEndTo], or + /// use other selection APIs, such as [_selectWordAt] or [selectAll]. + /// + /// This method sets or updates the selection end edge by sending + /// [SelectionEdgeUpdateEvent]s to the child [Selectable]s. + /// + /// If `continuous` is set to true and the update causes scrolling, the + /// method will continue sending the same [SelectionEdgeUpdateEvent]s to the + /// child [Selectable]s every frame until the scrolling finishes or a + /// [_finalizeSelection] is called. + /// + /// The `continuous` argument defaults to false. + /// + /// The `offset` is in global coordinates. + /// + /// Provide the `textGranularity` if the selection should not move by the default + /// [TextGranularity.character]. Only [TextGranularity.character] and + /// [TextGranularity.word] are currently supported. + /// + /// See also: + /// * [_selectStartTo], which sets or updates selection start edge. + /// * [_finalizeSelection], which stops the `continuous` updates. + /// * [clearSelection], which clears the ongoing selection. + /// * [_selectWordAt], which selects a whole word at the location. + /// * [_selectParagraphAt], which selects an entire paragraph at the location. + /// * [_collapseSelectionAt], which collapses the selection at the location. + /// * [selectAll], which selects the entire content. + void _selectEndTo({ + required Offset offset, + bool continuous = false, + TextGranularity? textGranularity, + }) { + if (!continuous) { + _selectable?.dispatchSelectionEvent( + SelectionEdgeUpdateEvent.forEnd( + globalPosition: offset, + granularity: textGranularity, + ), + ); + return; + } + if (_selectionEndPosition != offset) { + _selectionEndPosition = offset; + _triggerSelectionEndEdgeUpdate(textGranularity: textGranularity); + } + } + + /// Sets or updates selection start edge to the `offset` location. + /// + /// A selection always contains a select start edge and selection end edge. + /// They can be created by calling both [_selectStartTo] and [_selectEndTo], or + /// use other selection APIs, such as [_selectWordAt] or [selectAll]. + /// + /// This method sets or updates the selection start edge by sending + /// [SelectionEdgeUpdateEvent]s to the child [Selectable]s. + /// + /// If `continuous` is set to true and the update causes scrolling, the + /// method will continue sending the same [SelectionEdgeUpdateEvent]s to the + /// child [Selectable]s every frame until the scrolling finishes or a + /// [_finalizeSelection] is called. + /// + /// The `continuous` argument defaults to false. + /// + /// The `offset` is in global coordinates. + /// + /// Provide the `textGranularity` if the selection should not move by the default + /// [TextGranularity.character]. Only [TextGranularity.character] and + /// [TextGranularity.word] are currently supported. + /// + /// See also: + /// * [_selectEndTo], which sets or updates selection end edge. + /// * [_finalizeSelection], which stops the `continuous` updates. + /// * [clearSelection], which clears the ongoing selection. + /// * [_selectWordAt], which selects a whole word at the location. + /// * [_selectParagraphAt], which selects an entire paragraph at the location. + /// * [_collapseSelectionAt], which collapses the selection at the location. + /// * [selectAll], which selects the entire content. + void _selectStartTo({ + required Offset offset, + bool continuous = false, + TextGranularity? textGranularity, + }) { + if (!continuous) { + _selectable?.dispatchSelectionEvent( + SelectionEdgeUpdateEvent.forStart( + globalPosition: offset, + granularity: textGranularity, + ), + ); + return; + } + if (_selectionStartPosition != offset) { + _selectionStartPosition = offset; + _triggerSelectionStartEdgeUpdate(textGranularity: textGranularity); + } + } + + /// Collapses the selection at the given `offset` location. + /// + /// The `offset` is in global coordinates. + /// + /// See also: + /// * [_selectStartTo], which sets or updates selection start edge. + /// * [_selectEndTo], which sets or updates selection end edge. + /// * [_finalizeSelection], which stops the `continuous` updates. + /// * [clearSelection], which clears the ongoing selection. + /// * [_selectWordAt], which selects a whole word at the location. + /// * [_selectParagraphAt], which selects an entire paragraph at the location. + /// * [selectAll], which selects the entire content. + void _collapseSelectionAt({required Offset offset}) { + // There may be other selection ongoing. + _finalizeSelection(); + _selectStartTo(offset: offset); + _selectEndTo(offset: offset); + } + + /// Selects a whole word at the `offset` location. + /// + /// The `offset` is in global coordinates. + /// + /// If the whole word is already in the current selection, selection won't + /// change. One call [clearSelection] first if the selection needs to be + /// updated even if the word is already covered by the current selection. + /// + /// One can also use [_selectEndTo] or [_selectStartTo] to adjust the selection + /// edges after calling this method. + /// + /// See also: + /// * [_selectStartTo], which sets or updates selection start edge. + /// * [_selectEndTo], which sets or updates selection end edge. + /// * [_finalizeSelection], which stops the `continuous` updates. + /// * [clearSelection], which clears the ongoing selection. + /// * [_collapseSelectionAt], which collapses the selection at the location. + /// * [_selectParagraphAt], which selects an entire paragraph at the location. + /// * [selectAll], which selects the entire content. + void _selectWordAt({required Offset offset}) { + // There may be other selection ongoing. + _finalizeSelection(); + _selectable?.dispatchSelectionEvent( + SelectWordSelectionEvent(globalPosition: offset), + ); + } + + /// Selects the entire paragraph at the `offset` location. + /// + /// The `offset` is in global coordinates. + /// + /// If the paragraph is already in the current selection, selection won't + /// change. One call [clearSelection] first if the selection needs to be + /// updated even if the paragraph is already covered by the current selection. + /// + /// One can also use [_selectEndTo] or [_selectStartTo] to adjust the selection + /// edges after calling this method. + /// + /// See also: + /// * [_selectStartTo], which sets or updates selection start edge. + /// * [_selectEndTo], which sets or updates selection end edge. + /// * [_finalizeSelection], which stops the `continuous` updates. + /// * [clearSelection], which clear the ongoing selection. + /// * [_selectWordAt], which selects a whole word at the location. + /// * [selectAll], which selects the entire content. + void _selectParagraphAt({required Offset offset}) { + // There may be other selection ongoing. + _finalizeSelection(); + _selectable?.dispatchSelectionEvent( + SelectParagraphSelectionEvent(globalPosition: offset), + ); + } + + /// Stops any ongoing selection updates. + /// + /// This method is different from [clearSelection] that it does not remove + /// the current selection. It only stops the continuous updates. + /// + /// A continuous update can happen as result of calling [_selectStartTo] or + /// [_selectEndTo] with `continuous` sets to true which causes a [Selectable] + /// to scroll. Calling this method will stop the update as well as the + /// scrolling. + void _finalizeSelection() { + _stopSelectionEndEdgeUpdate(); + _stopSelectionStartEdgeUpdate(); + } + + /// Removes the ongoing selection for this [SelectableRegion]. + void clearSelection() { + _finalizeSelection(); + _directionalHorizontalBaseline = null; + _adjustingSelectionEnd = null; + _selectable?.dispatchSelectionEvent(const ClearSelectionEvent()); + _updateSelectedContentIfNeeded(); + } + + Future _copy() async { + final SelectedContent? data = _selectable?.getSelectedContent(); + if (data == null) { + return; + } + await Clipboard.setData(ClipboardData(text: data.plainText)); + } + + Future _share() async { + final SelectedContent? data = _selectable?.getSelectedContent(); + if (data == null) { + return; + } + await SystemChannels.platform.invokeMethod('Share.invoke', data.plainText); + } + + /// {@macro flutter.widgets.EditableText.getAnchors} + /// + /// See also: + /// + /// * [contextMenuButtonItems], which provides the [ContextMenuButtonItem]s + /// for the default context menu buttons. + TextSelectionToolbarAnchors get contextMenuAnchors { + if (_lastSecondaryTapDownPosition != null) { + final TextSelectionToolbarAnchors anchors = TextSelectionToolbarAnchors( + primaryAnchor: _lastSecondaryTapDownPosition!, + ); + // Clear the state of _lastSecondaryTapDownPosition after use since a user may + // access contextMenuAnchors and receive invalid anchors for their context menu. + _lastSecondaryTapDownPosition = null; + return anchors; + } + final RenderBox renderBox = context.findRenderObject()! as RenderBox; + return TextSelectionToolbarAnchors.fromSelection( + renderBox: renderBox, + startGlyphHeight: startGlyphHeight, + endGlyphHeight: endGlyphHeight, + selectionEndpoints: selectionEndpoints, + ); + } + + bool? _adjustingSelectionEnd; + bool _determineIsAdjustingSelectionEnd(bool forward) { + if (_adjustingSelectionEnd != null) { + return _adjustingSelectionEnd!; + } + final bool isReversed; + final SelectionPoint start = _selectionDelegate.value.startSelectionPoint!; + final SelectionPoint end = _selectionDelegate.value.endSelectionPoint!; + if (start.localPosition.dy > end.localPosition.dy) { + isReversed = true; + } else if (start.localPosition.dy < end.localPosition.dy) { + isReversed = false; + } else { + isReversed = start.localPosition.dx > end.localPosition.dx; + } + // Always move the selection edge that increases the selection range. + return _adjustingSelectionEnd = forward != isReversed; + } + + void _granularlyExtendSelection(TextGranularity granularity, bool forward) { + _directionalHorizontalBaseline = null; + if (!_selectionDelegate.value.hasSelection) { + return; + } + _selectable?.dispatchSelectionEvent( + GranularlyExtendSelectionEvent( + forward: forward, + isEnd: _determineIsAdjustingSelectionEnd(forward), + granularity: granularity, + ), + ); + _updateSelectedContentIfNeeded(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + _finalizeSelectableRegionStatus(); + } + + double? _directionalHorizontalBaseline; + + void _directionallyExtendSelection(bool forward) { + if (!_selectionDelegate.value.hasSelection) { + return; + } + final bool adjustingSelectionExtend = _determineIsAdjustingSelectionEnd( + forward, + ); + final SelectionPoint baseLinePoint = adjustingSelectionExtend + ? _selectionDelegate.value.endSelectionPoint! + : _selectionDelegate.value.startSelectionPoint!; + _directionalHorizontalBaseline ??= baseLinePoint.localPosition.dx; + final Offset globalSelectionPointOffset = MatrixUtils.transformPoint( + context.findRenderObject()!.getTransformTo(null), + Offset(_directionalHorizontalBaseline!, 0), + ); + _selectable?.dispatchSelectionEvent( + DirectionallyExtendSelectionEvent( + isEnd: _adjustingSelectionEnd!, + direction: forward + ? SelectionExtendDirection.nextLine + : SelectionExtendDirection.previousLine, + dx: globalSelectionPointOffset.dx, + ), + ); + _updateSelectedContentIfNeeded(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + _finalizeSelectableRegionStatus(); + } + + // [TextSelectionDelegate] overrides. + + /// Returns the [ContextMenuButtonItem]s representing the buttons in this + /// platform's default selection menu. + /// + /// See also: + /// + /// * [SelectableRegion.getSelectableButtonItems], which performs a similar role, + /// but for any selectable text, not just specifically SelectableRegion. + /// * [EditableTextState.contextMenuButtonItems], which performs a similar role + /// but for content that is not just selectable but also editable. + /// * [contextMenuAnchors], which provides the anchor points for the default + /// context menu. + /// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can + /// take a list of [ContextMenuButtonItem]s with + /// [AdaptiveTextSelectionToolbar.buttonItems]. + /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the + /// button Widgets for the current platform given [ContextMenuButtonItem]s. + List get contextMenuButtonItems { + return SelectableRegion.getSelectableButtonItems( + selectionGeometry: _selectionDelegate.value, + onCopy: () { + _copy(); + + // On Android copy should clear the selection. + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + clearSelection(); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + _finalizeSelectableRegionStatus(); + case TargetPlatform.iOS: + hideToolbar(false); + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + hideToolbar(); + } + }, + onSelectAll: () { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + selectAll(SelectionChangedCause.toolbar); + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + selectAll(); + hideToolbar(); + } + }, + onShare: () { + _share(); + + // On Android, share should clear the selection. + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + clearSelection(); + _selectionStatusNotifier.value = + SelectableRegionSelectionStatus.changing; + _finalizeSelectableRegionStatus(); + case TargetPlatform.iOS: + hideToolbar(false); + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + hideToolbar(); + } + }, + )..addAll(_textProcessingActionButtonItems); + } + + List get _textProcessingActionButtonItems { + final List buttonItems = []; + final SelectedContent? data = _selectable?.getSelectedContent(); + if (data == null) { + return buttonItems; + } + + for (final ProcessTextAction action in _processTextActions) { + buttonItems.add( + ContextMenuButtonItem( + label: action.label, + onPressed: () async { + final String selectedText = data.plainText; + if (selectedText.isNotEmpty) { + await _processTextService.processTextAction( + action.id, + selectedText, + true, + ); + hideToolbar(); + } + }, + ), + ); + } + return buttonItems; + } + + /// The line height at the start of the current selection. + double get startGlyphHeight { + return _selectionDelegate.value.startSelectionPoint!.lineHeight; + } + + /// The line height at the end of the current selection. + double get endGlyphHeight { + return _selectionDelegate.value.endSelectionPoint!.lineHeight; + } + + /// Returns the local coordinates of the endpoints of the current selection. + List get selectionEndpoints { + final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint; + final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint; + late List points; + final Offset startLocalPosition = + start?.localPosition ?? end!.localPosition; + final Offset endLocalPosition = end?.localPosition ?? start!.localPosition; + if (startLocalPosition.dy > endLocalPosition.dy) { + points = [ + TextSelectionPoint(endLocalPosition, TextDirection.ltr), + TextSelectionPoint(startLocalPosition, TextDirection.ltr), + ]; + } else { + points = [ + TextSelectionPoint(startLocalPosition, TextDirection.ltr), + TextSelectionPoint(endLocalPosition, TextDirection.ltr), + ]; + } + return points; + } + + // [TextSelectionDelegate] overrides. + // TODO(justinmc): After deprecations have been removed, remove + // TextSelectionDelegate from this class. + // https://github.com/flutter/flutter/issues/111213 + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + bool get cutEnabled => false; + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + bool get pasteEnabled => false; + + @override + void hideToolbar([bool hideHandles = true]) { + _selectionOverlay?.hideToolbar(); + if (hideHandles) { + _selectionOverlay?.hideHandles(); + } + } + + @override + void selectAll([SelectionChangedCause? cause]) { + clearSelection(); + _selectable?.dispatchSelectionEvent(const SelectAllSelectionEvent()); + if (cause == SelectionChangedCause.toolbar) { + _showToolbar(); + _showHandles(); + } + _updateSelectedContentIfNeeded(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + _finalizeSelectableRegionStatus(); + } + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + void copySelection(SelectionChangedCause cause) { + _copy(); + clearSelection(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + _finalizeSelectableRegionStatus(); + } + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + TextEditingValue textEditingValue = const TextEditingValue(text: '_'); + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + void bringIntoView(TextPosition position) { + /* SelectableRegion must be in view at this point. */ + } + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + void cutSelection(SelectionChangedCause cause) { + assert(false); + } + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + void userUpdateTextEditingValue( + TextEditingValue value, + SelectionChangedCause cause, + ) { + /* SelectableRegion maintains its own state */ + } + + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + @override + Future pasteText(SelectionChangedCause cause) async { + assert(false); + } + + // [SelectionRegistrar] override. + + @override + void add(Selectable selectable) { + assert(_selectable == null); + _selectable = selectable; + _selectable!.addListener(_updateSelectionStatus); + _selectable!.pushHandleLayers(_startHandleLayerLink, _endHandleLayerLink); + } + + @override + void remove(Selectable selectable) { + assert(_selectable == selectable); + _selectable!.removeListener(_updateSelectionStatus); + _selectable!.pushHandleLayers(null, null); + _selectable = null; + } + + @protected + @override + void dispose() { + _selectable?.removeListener(_updateSelectionStatus); + _selectable?.pushHandleLayers(null, null); + _selectionDelegate.dispose(); + _selectionStatusNotifier.dispose(); + // In case dispose was triggered before gesture end, remove the magnifier + // so it doesn't remain stuck in the overlay forever. + _selectionOverlay?.hideMagnifier(); + _selectionOverlay?.dispose(); + _selectionOverlay = null; + widget.focusNode?.removeListener(_handleFocusChanged); + _localFocusNode?.removeListener(_handleFocusChanged); + _localFocusNode?.dispose(); + super.dispose(); + } + + @protected + @override + Widget build(BuildContext context) { + assert(debugCheckHasOverlay(context)); + Widget result = SelectableRegionSelectionStatusScope._( + selectionStatusNotifier: _selectionStatusNotifier, + child: SelectionContainer( + registrar: this, + delegate: _selectionDelegate, + child: widget.child, + ), + ); + if (kIsWeb) { + result = PlatformSelectableRegionContextMenu(child: result); + } + return CompositedTransformTarget( + link: _toolbarLayerLink, + child: RawGestureDetector( + gestures: _gestureRecognizers, + behavior: HitTestBehavior.translucent, + excludeFromSemantics: true, + child: Actions( + actions: _actions, + child: Focus.withExternalFocusNode( + includeSemantics: false, + focusNode: _focusNode, + child: result, + ), + ), + ), + ); + } +} + +/// An action that does not override any [Action.overridable] in the subtree. +/// +/// If this action is invoked by an [Action.overridable], it will immediately +/// invoke the [Action.overridable] and do nothing else. Otherwise, it will call +/// [invokeAction]. +abstract class _NonOverrideAction extends ContextAction { + Object? invokeAction(T intent, [BuildContext? context]); + + @override + Object? invoke(T intent, [BuildContext? context]) { + if (callingAction != null) { + return callingAction!.invoke(intent); + } + return invokeAction(intent, context); + } +} + +class _SelectAllAction extends _NonOverrideAction { + _SelectAllAction(this.state); + + final SelectableRegionState state; + + @override + void invokeAction(SelectAllTextIntent intent, [BuildContext? context]) { + state.selectAll(SelectionChangedCause.keyboard); + } +} + +class _CopySelectionAction extends _NonOverrideAction { + _CopySelectionAction(this.state); + + final SelectableRegionState state; + + @override + void invokeAction(CopySelectionTextIntent intent, [BuildContext? context]) { + state._copy(); + } +} + +class _GranularlyExtendSelectionAction + extends _NonOverrideAction { + _GranularlyExtendSelectionAction(this.state, {required this.granularity}); + + final SelectableRegionState state; + final TextGranularity granularity; + + @override + void invokeAction(T intent, [BuildContext? context]) { + state._granularlyExtendSelection(granularity, intent.forward); + } +} + +class _GranularlyExtendCaretSelectionAction< + T extends DirectionalCaretMovementIntent +> + extends _NonOverrideAction { + _GranularlyExtendCaretSelectionAction( + this.state, { + required this.granularity, + }); + + final SelectableRegionState state; + final TextGranularity granularity; + + @override + void invokeAction(T intent, [BuildContext? context]) { + if (intent.collapseSelection) { + // Selectable region never collapses selection. + return; + } + state._granularlyExtendSelection(granularity, intent.forward); + } +} + +class _DirectionallyExtendCaretSelectionAction< + T extends DirectionalCaretMovementIntent +> + extends _NonOverrideAction { + _DirectionallyExtendCaretSelectionAction(this.state); + + final SelectableRegionState state; + + @override + void invokeAction(T intent, [BuildContext? context]) { + if (intent.collapseSelection) { + // Selectable region never collapses selection. + return; + } + state._directionallyExtendSelection(intent.forward); + } +} + +/// Signature for a widget builder that builds a context menu for the given +/// [SelectableRegionState]. +/// +/// See also: +/// +/// * [EditableTextContextMenuBuilder], which performs the same role for +/// [EditableText]. +typedef SelectableRegionContextMenuBuilder = + Widget Function( + BuildContext context, + SelectableRegionState selectableRegionState, + ); + +/// Notifies its listeners when the [SelectableRegion] that created this object +/// is changing or finalizes its selection. +/// +/// To access the [_SelectableRegionSelectionStatusNotifier] from the nearest [SelectableRegion] +/// ancestor, use [SelectableRegionSelectionStatusScope.maybeOf]. +final class _SelectableRegionSelectionStatusNotifier extends ChangeNotifier + implements ValueListenable { + _SelectableRegionSelectionStatusNotifier._(); + + SelectableRegionSelectionStatus _selectableRegionSelectionStatus = + SelectableRegionSelectionStatus.finalized; + + /// The current value of the [SelectableRegionSelectionStatus] of the [SelectableRegion] + /// that owns this object. + /// + /// Defaults to [SelectableRegionSelectionStatus.finalized]. + @override + SelectableRegionSelectionStatus get value => _selectableRegionSelectionStatus; + + /// Sets the [SelectableRegionSelectionStatus] for the [SelectableRegion] that + /// owns this object. + /// + /// Listeners are notified even if the value did not change. + @protected + set value(SelectableRegionSelectionStatus newStatus) { + assert( + newStatus == SelectableRegionSelectionStatus.finalized && + value == SelectableRegionSelectionStatus.changing || + newStatus == SelectableRegionSelectionStatus.changing, + 'Attempting to finalize the selection when it is already finalized.', + ); + _selectableRegionSelectionStatus = newStatus; + notifyListeners(); + } +} + +/// Notifies its listeners when the selection under a [SelectableRegion] or +/// [SelectionArea] is being changed or finalized. +/// +/// Use [SelectableRegionSelectionStatusScope.maybeOf], to access the [ValueListenable] of type +/// [SelectableRegionSelectionStatus] under a [SelectableRegion]. Its listeners +/// will be called even when the value of the [SelectableRegionSelectionStatus] +/// does not change. +final class SelectableRegionSelectionStatusScope extends InheritedWidget { + const SelectableRegionSelectionStatusScope._({ + required this.selectionStatusNotifier, + required super.child, + }); + + /// Tracks updates to the [SelectableRegionSelectionStatus] of the owning + /// [SelectableRegion]. + /// + /// Listeners will be called even when the value of the [SelectableRegionSelectionStatus] + /// does not change. The selection under the [SelectableRegion] still may have changed. + final ValueListenable + selectionStatusNotifier; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [SelectableRegion] or [SelectionArea] widget, then null is + /// returned. + /// + /// Calling this method will create a dependency on the closest + /// [SelectableRegionSelectionStatusScope] in the [context], if there is one. + static ValueListenable? maybeOf( + BuildContext context, + ) { + return context + .dependOnInheritedWidgetOfExactType< + SelectableRegionSelectionStatusScope + >() + ?.selectionStatusNotifier; + } + + @override + bool updateShouldNotify(SelectableRegionSelectionStatusScope oldWidget) { + return selectionStatusNotifier != oldWidget.selectionStatusNotifier; + } +} diff --git a/lib/common/widgets/flutter/text_intro/selectable_text.dart b/lib/common/widgets/flutter/selectable_text/selectable_text.dart similarity index 99% rename from lib/common/widgets/flutter/text_intro/selectable_text.dart rename to lib/common/widgets/flutter/selectable_text/selectable_text.dart index f075c86e7..c73e49f34 100644 --- a/lib/common/widgets/flutter/text_intro/selectable_text.dart +++ b/lib/common/widgets/flutter/selectable_text/selectable_text.dart @@ -4,7 +4,7 @@ import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; -import 'package:PiliPlus/common/widgets/flutter/text_intro/text_selection.dart'; +import 'package:PiliPlus/common/widgets/flutter/selectable_text/text_selection.dart'; import 'package:flutter/cupertino.dart' hide TextSelectionGestureDetectorBuilder; import 'package:flutter/gestures.dart'; diff --git a/lib/common/widgets/flutter/selectable_text/selection_area.dart b/lib/common/widgets/flutter/selectable_text/selection_area.dart new file mode 100644 index 000000000..6e4c0c6ba --- /dev/null +++ b/lib/common/widgets/flutter/selectable_text/selection_area.dart @@ -0,0 +1,147 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:PiliPlus/common/widgets/flutter/selectable_text/selectable_region.dart'; +import 'package:flutter/cupertino.dart' + hide + SelectableRegion, + SelectableRegionState, + SelectableRegionContextMenuBuilder; +import 'package:flutter/material.dart' + hide + SelectableRegion, + SelectableRegionState, + SelectableRegionContextMenuBuilder; +import 'package:flutter/rendering.dart'; + +/// A widget that introduces an area for user selections with adaptive selection +/// controls. +/// +/// This widget creates a [SelectableRegion] with platform-adaptive selection +/// controls. +/// +/// Flutter widgets are not selectable by default. To enable selection for +/// a specific screen, consider wrapping the body of the [Route] with a +/// [SelectionArea]. +/// +/// The [SelectionArea] widget must have a [Localizations] ancestor that +/// contains a [MaterialLocalizations] delegate; using the [MaterialApp] widget +/// ensures that such an ancestor is present. +/// +/// {@tool dartpad} +/// This example shows how to make a screen selectable. +/// +/// ** See code in examples/api/lib/material/selection_area/selection_area.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SelectableRegion], which provides an overview of the selection system. +/// * [SelectableText], which enables selection on a single run of text. +/// * [SelectionListener], which enables accessing the [SelectionDetails] of +/// the selectable subtree it wraps. +class SelectionArea extends StatefulWidget { + /// Creates a [SelectionArea]. + /// + /// If [selectionControls] is null, a platform specific one is used. + const SelectionArea({ + super.key, + this.focusNode, + this.selectionControls, + this.contextMenuBuilder = _defaultContextMenuBuilder, + this.magnifierConfiguration, + this.onSelectionChanged, + required this.child, + }); + + /// The configuration for the magnifier in the selection region. + /// + /// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier] + /// on Android, and builds nothing on all other platforms. To suppress the + /// magnifier, consider passing [TextMagnifierConfiguration.disabled]. + /// + /// {@macro flutter.widgets.magnifier.intro} + final TextMagnifierConfiguration? magnifierConfiguration; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// The delegate to build the selection handles and toolbar. + /// + /// If it is null, the platform specific selection control is used. + final TextSelectionControls? selectionControls; + + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + /// + /// If not provided, will build a default menu based on the ambient + /// [ThemeData.platform]. + /// + /// {@tool dartpad} + /// This example shows how to build a custom context menu for any selected + /// content in a SelectionArea. + /// + /// ** See code in examples/api/lib/material/context_menu/selectable_region_toolbar_builder.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar], which is built by default. + final SelectableRegionContextMenuBuilder? contextMenuBuilder; + + /// Called when the selected content changes. + final ValueChanged? onSelectionChanged; + + /// The child widget this selection area applies to. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + static Widget _defaultContextMenuBuilder( + BuildContext context, + SelectableRegionState selectableRegionState, + ) => AdaptiveTextSelectionToolbar.buttonItems( + buttonItems: selectableRegionState.contextMenuButtonItems, + anchors: selectableRegionState.contextMenuAnchors, + ); + + @override + State createState() => SelectionAreaState(); +} + +/// State for a [SelectionArea]. +class SelectionAreaState extends State { + final GlobalKey _selectableRegionKey = + GlobalKey(); + + /// The [State] of the [SelectableRegion] for which this [SelectionArea] wraps. + SelectableRegionState get selectableRegion => + _selectableRegionKey.currentState!; + + @protected + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterialLocalizations(context)); + final TextSelectionControls controls = + widget.selectionControls ?? + switch (Theme.of(context).platform) { + TargetPlatform.android || + TargetPlatform.fuchsia => materialTextSelectionHandleControls, + TargetPlatform.linux || + TargetPlatform.windows => desktopTextSelectionHandleControls, + TargetPlatform.iOS => cupertinoTextSelectionHandleControls, + TargetPlatform.macOS => cupertinoDesktopTextSelectionHandleControls, + }; + return SelectableRegion( + key: _selectableRegionKey, + selectionControls: controls, + focusNode: widget.focusNode, + contextMenuBuilder: widget.contextMenuBuilder, + magnifierConfiguration: + widget.magnifierConfiguration ?? + TextMagnifier.adaptiveMagnifierConfiguration, + onSelectionChanged: widget.onSelectionChanged, + child: widget.child, + ); + } +} diff --git a/lib/common/widgets/flutter/text_intro/tap_and_drag.dart b/lib/common/widgets/flutter/selectable_text/tap_and_drag.dart similarity index 96% rename from lib/common/widgets/flutter/text_intro/tap_and_drag.dart rename to lib/common/widgets/flutter/selectable_text/tap_and_drag.dart index c9bdd7a04..6712f94dd 100644 --- a/lib/common/widgets/flutter/text_intro/tap_and_drag.dart +++ b/lib/common/widgets/flutter/selectable_text/tap_and_drag.dart @@ -1083,3 +1083,42 @@ class TapAndHorizontalDragGestureRecognizer @override String get debugDescription => 'tap and horizontal drag'; } + +/// {@template flutter.gestures.selectionrecognizers.TapAndPanGestureRecognizer} +/// Recognizes taps along with both horizontal and vertical movement. +/// +/// This recognizer will accept a drag on any axis, regardless if it has won the +/// arena for the primary pointer being tracked. +/// +/// See also: +/// +/// * [BaseTapAndDragGestureRecognizer], for the class that provides the main +/// implementation details of this recognizer. +/// * [TapAndHorizontalDragGestureRecognizer], for a similar recognizer that +/// only accepts horizontal drags before it has won the arena for the primary +/// pointer being tracked. +/// * [PanGestureRecognizer], for a similar recognizer that only recognizes +/// movement. +/// {@endtemplate} +class TapAndPanGestureRecognizer extends BaseTapAndDragGestureRecognizer { + /// Create a gesture recognizer for interactions on a plane. + TapAndPanGestureRecognizer({super.debugOwner, super.supportedDevices}); + + @override + bool _hasSufficientGlobalDistanceToAccept( + PointerDeviceKind pointerDeviceKind, + ) { + return true; + // return _globalDistanceMoved.abs() > + // computePanSlop(pointerDeviceKind, gestureSettings); + } + + // @override + // Offset _getDeltaForDetails(Offset delta) => delta; + + // @override + // double? _getPrimaryValueFromOffset(Offset value) => null; + + @override + String get debugDescription => 'tap and pan'; +} diff --git a/lib/common/widgets/flutter/selectable_text/text.dart b/lib/common/widgets/flutter/selectable_text/text.dart new file mode 100644 index 000000000..24be9f726 --- /dev/null +++ b/lib/common/widgets/flutter/selectable_text/text.dart @@ -0,0 +1,42 @@ +import 'package:PiliPlus/common/widgets/flutter/selectable_text/selectable_text.dart'; +import 'package:PiliPlus/common/widgets/flutter/selectable_text/selection_area.dart'; +import 'package:PiliPlus/utils/platform_utils.dart'; +import 'package:flutter/material.dart' hide SelectableText, SelectionArea; + +Widget selectableText( + String text, { + TextStyle? style, +}) { + if (PlatformUtils.isDesktop) { + return SelectionArea( + child: Text( + style: style, + text, + ), + ); + } + return SelectableText( + style: style, + text, + scrollPhysics: const NeverScrollableScrollPhysics(), + ); +} + +Widget selectableRichText( + TextSpan textSpan, { + TextStyle? style, +}) { + if (PlatformUtils.isDesktop) { + return SelectionArea( + child: Text.rich( + style: style, + textSpan, + ), + ); + } + return SelectableText.rich( + style: style, + textSpan, + scrollPhysics: const NeverScrollableScrollPhysics(), + ); +} diff --git a/lib/common/widgets/flutter/text_intro/text_selection.dart b/lib/common/widgets/flutter/selectable_text/text_selection.dart similarity index 98% rename from lib/common/widgets/flutter/text_intro/text_selection.dart rename to lib/common/widgets/flutter/selectable_text/text_selection.dart index bca55cfe9..58e187852 100644 --- a/lib/common/widgets/flutter/text_intro/text_selection.dart +++ b/lib/common/widgets/flutter/selectable_text/text_selection.dart @@ -4,10 +4,13 @@ import 'dart:math' as math; -import 'package:PiliPlus/common/widgets/flutter/text_intro/tap_and_drag.dart'; +import 'package:PiliPlus/common/widgets/flutter/selectable_text/tap_and_drag.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart' - hide BaseTapAndDragGestureRecognizer, TapAndHorizontalDragGestureRecognizer; + hide + BaseTapAndDragGestureRecognizer, + TapAndHorizontalDragGestureRecognizer, + TapAndPanGestureRecognizer; import 'package:flutter/material.dart'; class CustomTextSelectionGestureDetectorBuilder diff --git a/lib/common/widgets/flutter/text_intro/text.dart b/lib/common/widgets/flutter/text_intro/text.dart deleted file mode 100644 index fedca01f1..000000000 --- a/lib/common/widgets/flutter/text_intro/text.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:PiliPlus/common/widgets/flutter/text_intro/selectable_text.dart'; -import 'package:PiliPlus/utils/platform_utils.dart'; -import 'package:flutter/material.dart' hide SelectableText; - -Widget selectableRichText( - TextSpan textSpan, { - TextStyle? style, -}) { - if (PlatformUtils.isDesktop) { - return SelectionArea( - child: Text.rich( - style: style, - textSpan, - ), - ); - } - return SelectableText.rich( - style: style, - textSpan, - scrollPhysics: const NeverScrollableScrollPhysics(), - ); -} diff --git a/lib/pages/live_room/superchat/superchat_card.dart b/lib/pages/live_room/superchat/superchat_card.dart index 94f6c9ad9..6821112a4 100644 --- a/lib/pages/live_room/superchat/superchat_card.dart +++ b/lib/pages/live_room/superchat/superchat_card.dart @@ -1,12 +1,13 @@ import 'dart:async'; +import 'package:PiliPlus/common/widgets/flutter/selectable_text/selection_area.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/models/common/image_type.dart'; import 'package:PiliPlus/models_new/live/live_superchat/item.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/platform_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide SelectionArea; import 'package:get/get.dart'; class SuperChatCard extends StatefulWidget { diff --git a/lib/pages/pgc_review/child/view.dart b/lib/pages/pgc_review/child/view.dart index 0c97c3e0f..a3e47ca98 100644 --- a/lib/pages/pgc_review/child/view.dart +++ b/lib/pages/pgc_review/child/view.dart @@ -3,6 +3,7 @@ import 'package:PiliPlus/common/widgets/custom_icon.dart'; import 'package:PiliPlus/common/widgets/custom_sliver_persistent_header_delegate.dart'; import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; import 'package:PiliPlus/common/widgets/flutter/refresh_indicator.dart'; +import 'package:PiliPlus/common/widgets/flutter/selectable_text/selectable_text.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; import 'package:PiliPlus/http/loading_state.dart'; @@ -17,7 +18,7 @@ import 'package:PiliPlus/utils/extension/theme_ext.dart'; import 'package:PiliPlus/utils/num_utils.dart'; import 'package:PiliPlus/utils/platform_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide SelectableText; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; diff --git a/lib/pages/video/ai_conclusion/view.dart b/lib/pages/video/ai_conclusion/view.dart index 3ed1fddc4..f3ae11c13 100644 --- a/lib/pages/video/ai_conclusion/view.dart +++ b/lib/pages/video/ai_conclusion/view.dart @@ -1,8 +1,8 @@ +import 'package:PiliPlus/common/widgets/flutter/selectable_text/text.dart'; import 'package:PiliPlus/common/widgets/gesture/tap_gesture_recognizer.dart'; import 'package:PiliPlus/models_new/video/video_ai_conclusion/model_result.dart'; import 'package:PiliPlus/pages/common/slide/common_slide_page.dart'; import 'package:PiliPlus/pages/video/controller.dart'; -import 'package:PiliPlus/pages/video/introduction/ugc/widgets/selectable_text.dart'; import 'package:PiliPlus/utils/duration_utils.dart'; import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; import 'package:flutter/material.dart'; diff --git a/lib/pages/video/introduction/pgc/widgets/intro_detail.dart b/lib/pages/video/introduction/pgc/widgets/intro_detail.dart index d04547e35..4fe2b1189 100644 --- a/lib/pages/video/introduction/pgc/widgets/intro_detail.dart +++ b/lib/pages/video/introduction/pgc/widgets/intro_detail.dart @@ -1,4 +1,5 @@ import 'package:PiliPlus/common/widgets/flutter/page/tabs.dart'; +import 'package:PiliPlus/common/widgets/flutter/selectable_text/text.dart'; import 'package:PiliPlus/common/widgets/keep_alive_wrapper.dart'; import 'package:PiliPlus/common/widgets/scroll_physics.dart'; import 'package:PiliPlus/common/widgets/stat/stat.dart'; @@ -8,7 +9,6 @@ import 'package:PiliPlus/models_new/video/video_tag/data.dart'; import 'package:PiliPlus/pages/common/slide/common_slide_page.dart'; import 'package:PiliPlus/pages/pgc_review/view.dart'; import 'package:PiliPlus/pages/search/widgets/search_text.dart'; -import 'package:PiliPlus/pages/video/introduction/ugc/widgets/selectable_text.dart'; import 'package:PiliPlus/utils/extension/scroll_controller_ext.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/material.dart' hide TabBarView; diff --git a/lib/pages/video/introduction/ugc/view.dart b/lib/pages/video/introduction/ugc/view.dart index e2db0013e..38dec5e64 100644 --- a/lib/pages/video/introduction/ugc/view.dart +++ b/lib/pages/video/introduction/ugc/view.dart @@ -1,6 +1,7 @@ import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; -import 'package:PiliPlus/common/widgets/flutter/text_intro/text.dart'; +import 'package:PiliPlus/common/widgets/flutter/selectable_text/selection_area.dart'; +import 'package:PiliPlus/common/widgets/flutter/selectable_text/text.dart'; import 'package:PiliPlus/common/widgets/gesture/tap_gesture_recognizer.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/pendant_avatar.dart'; @@ -36,7 +37,7 @@ import 'package:PiliPlus/utils/platform_utils.dart'; import 'package:PiliPlus/utils/request_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:expandable/expandable.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide SelectionArea; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; diff --git a/lib/pages/video/introduction/ugc/widgets/selectable_text.dart b/lib/pages/video/introduction/ugc/widgets/selectable_text.dart deleted file mode 100644 index 7c8467980..000000000 --- a/lib/pages/video/introduction/ugc/widgets/selectable_text.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:PiliPlus/utils/platform_utils.dart'; -import 'package:flutter/material.dart'; - -Widget selectableText( - String text, { - TextStyle? style, -}) { - if (PlatformUtils.isDesktop) { - return SelectionArea( - child: Text( - style: style, - text, - ), - ); - } - return SelectableText( - style: style, - text, - scrollPhysics: const NeverScrollableScrollPhysics(), - ); -}