1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
2 | // Use of this source code is governed by a BSD-style license that can be |
3 | // found in the LICENSE file. |
4 | |
5 | /// @docImport 'package:flutter/material.dart'; |
6 | /// @docImport 'package:flutter_test/flutter_test.dart'; |
7 | /// |
8 | /// @docImport 'editable_text.dart'; |
9 | /// @docImport 'text.dart'; |
10 | library; |
11 | |
12 | import 'dart:async'; |
13 | import 'dart:math'; |
14 | |
15 | import 'package:flutter/foundation.dart'; |
16 | import 'package:flutter/gestures.dart'; |
17 | import 'package:flutter/rendering.dart'; |
18 | import 'package:flutter/scheduler.dart'; |
19 | import 'package:flutter/services.dart'; |
20 | import 'package:vector_math/vector_math_64.dart' ; |
21 | |
22 | import 'actions.dart'; |
23 | import 'basic.dart'; |
24 | import 'context_menu_button_item.dart'; |
25 | import 'debug.dart'; |
26 | import 'focus_manager.dart'; |
27 | import 'focus_scope.dart'; |
28 | import 'framework.dart'; |
29 | import 'gesture_detector.dart'; |
30 | import 'magnifier.dart'; |
31 | import 'media_query.dart'; |
32 | import 'overlay.dart'; |
33 | import 'platform_selectable_region_context_menu.dart'; |
34 | import 'selection_container.dart'; |
35 | import 'text_editing_intents.dart'; |
36 | import 'text_selection.dart'; |
37 | import 'text_selection_toolbar_anchors.dart'; |
38 | |
39 | // Examples can assume: |
40 | // FocusNode _focusNode = FocusNode(); |
41 | // late GlobalKey key; |
42 | |
43 | const Set<PointerDeviceKind> _kLongPressSelectionDevices = <PointerDeviceKind>{ |
44 | PointerDeviceKind.touch, |
45 | PointerDeviceKind.stylus, |
46 | PointerDeviceKind.invertedStylus, |
47 | }; |
48 | |
49 | // In practice some selectables like widgetspan shift several pixels. So when |
50 | // the vertical position diff is within the threshold, compare the horizontal |
51 | // position to make the compareScreenOrder function more robust. |
52 | const double _kSelectableVerticalComparingThreshold = 3.0; |
53 | |
54 | /// A widget that introduces an area for user selections. |
55 | /// |
56 | /// Flutter widgets are not selectable by default. Wrapping a widget subtree |
57 | /// with a [SelectableRegion] widget enables selection within that subtree (for |
58 | /// example, [Text] widgets automatically look for selectable regions to enable |
59 | /// selection). The wrapped subtree can be selected by users using mouse or |
60 | /// touch gestures, e.g. users can select widgets by holding the mouse |
61 | /// left-click and dragging across widgets, or they can use long press gestures |
62 | /// to select words on touch devices. |
63 | /// |
64 | /// A [SelectableRegion] widget requires configuration; in particular specific |
65 | /// [selectionControls] must be provided. |
66 | /// |
67 | /// The [SelectionArea] widget from the [material] library configures a |
68 | /// [SelectableRegion] in a platform-specific manner (e.g. using a Material |
69 | /// toolbar on Android, a Cupertino toolbar on iOS), and it may therefore be |
70 | /// simpler to use that widget rather than using [SelectableRegion] directly. |
71 | /// |
72 | /// ## An overview of the selection system. |
73 | /// |
74 | /// Every [Selectable] under the [SelectableRegion] can be selected. They form a |
75 | /// selection tree structure to handle the selection. |
76 | /// |
77 | /// The [SelectableRegion] is a wrapper over [SelectionContainer]. It listens to |
78 | /// user gestures and sends corresponding [SelectionEvent]s to the |
79 | /// [SelectionContainer] it creates. |
80 | /// |
81 | /// A [SelectionContainer] is a single [Selectable] that handles |
82 | /// [SelectionEvent]s on behalf of child [Selectable]s in the subtree. It |
83 | /// creates a [SelectionRegistrarScope] with its [SelectionContainer.delegate] |
84 | /// to collect child [Selectable]s and sends the [SelectionEvent]s it receives |
85 | /// from the parent [SelectionRegistrar] to the appropriate child [Selectable]s. |
86 | /// It creates an abstraction for the parent [SelectionRegistrar] as if it is |
87 | /// interacting with a single [Selectable]. |
88 | /// |
89 | /// The [SelectionContainer] created by [SelectableRegion] is the root node of a |
90 | /// selection tree. Each non-leaf node in the tree is a [SelectionContainer], |
91 | /// and the leaf node is a leaf widget whose render object implements |
92 | /// [Selectable]. They are connected through [SelectionRegistrarScope]s created |
93 | /// by [SelectionContainer]s. |
94 | /// |
95 | /// Both [SelectionContainer]s and the leaf [Selectable]s need to register |
96 | /// themselves to the [SelectionRegistrar] from the |
97 | /// [SelectionContainer.maybeOf] if they want to participate in the |
98 | /// selection. |
99 | /// |
100 | /// An example selection tree will look like: |
101 | /// |
102 | /// {@tool snippet} |
103 | /// |
104 | /// ```dart |
105 | /// MaterialApp( |
106 | /// home: SelectableRegion( |
107 | /// selectionControls: materialTextSelectionControls, |
108 | /// focusNode: _focusNode, // initialized to FocusNode() |
109 | /// child: Scaffold( |
110 | /// appBar: AppBar(title: const Text('Flutter Code Sample')), |
111 | /// body: ListView( |
112 | /// children: const <Widget>[ |
113 | /// Text('Item 0', style: TextStyle(fontSize: 50.0)), |
114 | /// Text('Item 1', style: TextStyle(fontSize: 50.0)), |
115 | /// ], |
116 | /// ), |
117 | /// ), |
118 | /// ), |
119 | /// ) |
120 | /// ``` |
121 | /// {@end-tool} |
122 | /// |
123 | /// |
124 | /// SelectionContainer |
125 | /// (SelectableRegion) |
126 | /// / \ |
127 | /// / \ |
128 | /// / \ |
129 | /// Selectable \ |
130 | /// ("Flutter Code Sample") \ |
131 | /// \ |
132 | /// SelectionContainer |
133 | /// (ListView) |
134 | /// / \ |
135 | /// / \ |
136 | /// / \ |
137 | /// Selectable Selectable |
138 | /// ("Item 0") ("Item 1") |
139 | /// |
140 | /// |
141 | /// ## Making a widget selectable |
142 | /// |
143 | /// Some leaf widgets, such as [Text], have all of the selection logic wired up |
144 | /// automatically and can be selected as long as they are under a |
145 | /// [SelectableRegion]. |
146 | /// |
147 | /// To make a custom selectable widget, its render object needs to mix in |
148 | /// [Selectable] and implement the required APIs to handle [SelectionEvent]s |
149 | /// as well as paint appropriate selection highlights. |
150 | /// |
151 | /// The render object also needs to register itself to a [SelectionRegistrar]. |
152 | /// For the most cases, one can use [SelectionRegistrant] to auto-register |
153 | /// itself with the register returned from [SelectionContainer.maybeOf] as |
154 | /// seen in the example below. |
155 | /// |
156 | /// {@tool dartpad} |
157 | /// This sample demonstrates how to create an adapter widget that makes any |
158 | /// child widget selectable. |
159 | /// |
160 | /// ** See code in examples/api/lib/material/selectable_region/selectable_region.0.dart ** |
161 | /// {@end-tool} |
162 | /// |
163 | /// ## Complex layout |
164 | /// |
165 | /// By default, the screen order is used as the selection order. If a group of |
166 | /// [Selectable]s needs to select differently, consider wrapping them with a |
167 | /// [SelectionContainer] to customize its selection behavior. |
168 | /// |
169 | /// {@tool dartpad} |
170 | /// This sample demonstrates how to create a [SelectionContainer] that only |
171 | /// allows selecting everything or nothing with no partial selection. |
172 | /// |
173 | /// ** See code in examples/api/lib/material/selection_container/selection_container.0.dart ** |
174 | /// {@end-tool} |
175 | /// |
176 | /// In the case where a group of widgets should be excluded from selection under |
177 | /// a [SelectableRegion], consider wrapping that group of widgets using |
178 | /// [SelectionContainer.disabled]. |
179 | /// |
180 | /// {@tool dartpad} |
181 | /// This sample demonstrates how to disable selection for a Text in a Column. |
182 | /// |
183 | /// ** See code in examples/api/lib/material/selection_container/selection_container_disabled.0.dart ** |
184 | /// {@end-tool} |
185 | /// |
186 | /// To create a separate selection system from its parent selection area, |
187 | /// wrap part of the subtree with another [SelectableRegion]. The selection of the |
188 | /// child selection area can not extend past its subtree, and the selection of |
189 | /// the parent selection area can not extend inside the child selection area. |
190 | /// |
191 | /// ## Tests |
192 | /// |
193 | /// In a test, a region can be selected either by faking drag events (e.g. using |
194 | /// [WidgetTester.dragFrom]) or by sending intents to a widget inside the region |
195 | /// that has been given a [GlobalKey], e.g.: |
196 | /// |
197 | /// ```dart |
198 | /// Actions.invoke(key.currentContext!, const SelectAllTextIntent(SelectionChangedCause.keyboard)); |
199 | /// ``` |
200 | /// |
201 | /// See also: |
202 | /// |
203 | /// * [SelectionArea], which creates a [SelectableRegion] with |
204 | /// platform-adaptive selection controls. |
205 | /// * [SelectableText], which enables selection on a single run of text. |
206 | /// * [SelectionHandler], which contains APIs to handle selection events from the |
207 | /// [SelectableRegion]. |
208 | /// * [Selectable], which provides API to participate in the selection system. |
209 | /// * [SelectionRegistrar], which [Selectable] needs to subscribe to receive |
210 | /// selection events. |
211 | /// * [SelectionContainer], which collects selectable widgets in the subtree |
212 | /// and provides api to dispatch selection event to the collected widget. |
213 | class SelectableRegion extends StatefulWidget { |
214 | /// Create a new [SelectableRegion] widget. |
215 | /// |
216 | /// The [selectionControls] are used for building the selection handles and |
217 | /// toolbar for mobile devices. |
218 | const SelectableRegion({ |
219 | super.key, |
220 | this.contextMenuBuilder, |
221 | required this.focusNode, |
222 | required this.selectionControls, |
223 | required this.child, |
224 | this.magnifierConfiguration = TextMagnifierConfiguration.disabled, |
225 | this.onSelectionChanged, |
226 | }); |
227 | |
228 | /// The configuration for the magnifier used with selections in this region. |
229 | /// |
230 | /// By default, [SelectableRegion]'s [TextMagnifierConfiguration] is disabled. |
231 | /// For a version of [SelectableRegion] that adapts automatically to the |
232 | /// current platform, consider [SelectionArea]. |
233 | /// |
234 | /// {@macro flutter.widgets.magnifier.intro} |
235 | final TextMagnifierConfiguration magnifierConfiguration; |
236 | |
237 | /// {@macro flutter.widgets.Focus.focusNode} |
238 | final FocusNode focusNode; |
239 | |
240 | /// The child widget this selection area applies to. |
241 | /// |
242 | /// {@macro flutter.widgets.ProxyWidget.child} |
243 | final Widget child; |
244 | |
245 | /// {@macro flutter.widgets.EditableText.contextMenuBuilder} |
246 | final SelectableRegionContextMenuBuilder? contextMenuBuilder; |
247 | |
248 | /// The delegate to build the selection handles and toolbar for mobile |
249 | /// devices. |
250 | /// |
251 | /// The [emptyTextSelectionControls] global variable provides a default |
252 | /// [TextSelectionControls] implementation with no controls. |
253 | final TextSelectionControls selectionControls; |
254 | |
255 | /// Called when the selected content changes. |
256 | final ValueChanged<SelectedContent?>? onSelectionChanged; |
257 | |
258 | /// Returns the [ContextMenuButtonItem]s representing the buttons in this |
259 | /// platform's default selection menu. |
260 | /// |
261 | /// For example, [SelectableRegion] uses this to generate the default buttons |
262 | /// for its context menu. |
263 | /// |
264 | /// See also: |
265 | /// |
266 | /// * [SelectableRegionState.contextMenuButtonItems], which gives the |
267 | /// [ContextMenuButtonItem]s for a specific SelectableRegion. |
268 | /// * [EditableText.getEditableButtonItems], which performs a similar role but |
269 | /// for content that is both selectable and editable. |
270 | /// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can |
271 | /// take a list of [ContextMenuButtonItem]s with |
272 | /// [AdaptiveTextSelectionToolbar.buttonItems]. |
273 | /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button |
274 | /// Widgets for the current platform given [ContextMenuButtonItem]s. |
275 | static List<ContextMenuButtonItem> getSelectableButtonItems({ |
276 | required final SelectionGeometry selectionGeometry, |
277 | required final VoidCallback onCopy, |
278 | required final VoidCallback onSelectAll, |
279 | required final VoidCallback? onShare, |
280 | }) { |
281 | final bool canCopy = selectionGeometry.status == SelectionStatus.uncollapsed; |
282 | final bool canSelectAll = selectionGeometry.hasContent; |
283 | final bool platformCanShare = switch (defaultTargetPlatform) { |
284 | TargetPlatform.android |
285 | => selectionGeometry.status == SelectionStatus.uncollapsed, |
286 | TargetPlatform.macOS |
287 | || TargetPlatform.fuchsia |
288 | || TargetPlatform.linux |
289 | || TargetPlatform.windows |
290 | => false, |
291 | // TODO(bleroux): the share button should be shown on iOS but the share |
292 | // functionality requires some changes on the engine side because, on iPad, |
293 | // it needs an anchor for the popup. |
294 | // See: https://github.com/flutter/flutter/issues/141775. |
295 | TargetPlatform.iOS |
296 | => false, |
297 | }; |
298 | final bool canShare = onShare != null && platformCanShare; |
299 | |
300 | // On Android, the share button is before the select all button. |
301 | final bool showShareBeforeSelectAll = defaultTargetPlatform == TargetPlatform.android; |
302 | |
303 | // Determine which buttons will appear so that the order and total number is |
304 | // known. A button's position in the menu can slightly affect its |
305 | // appearance. |
306 | return <ContextMenuButtonItem>[ |
307 | if (canCopy) |
308 | ContextMenuButtonItem( |
309 | onPressed: onCopy, |
310 | type: ContextMenuButtonType.copy, |
311 | ), |
312 | if (canShare && showShareBeforeSelectAll) |
313 | ContextMenuButtonItem( |
314 | onPressed: onShare, |
315 | type: ContextMenuButtonType.share, |
316 | ), |
317 | if (canSelectAll) |
318 | ContextMenuButtonItem( |
319 | onPressed: onSelectAll, |
320 | type: ContextMenuButtonType.selectAll, |
321 | ), |
322 | if (canShare && !showShareBeforeSelectAll) |
323 | ContextMenuButtonItem( |
324 | onPressed: onShare, |
325 | type: ContextMenuButtonType.share, |
326 | ), |
327 | ]; |
328 | } |
329 | |
330 | @override |
331 | State<StatefulWidget> createState() => SelectableRegionState(); |
332 | } |
333 | |
334 | /// State for a [SelectableRegion]. |
335 | class SelectableRegionState extends State<SelectableRegion> with TextSelectionDelegate implements SelectionRegistrar { |
336 | late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{ |
337 | SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), |
338 | CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)), |
339 | ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable(_GranularlyExtendSelectionAction<ExtendSelectionToNextWordBoundaryOrCaretLocationIntent>(this, granularity: TextGranularity.word)), |
340 | ExpandSelectionToDocumentBoundaryIntent: _makeOverridable(_GranularlyExtendSelectionAction<ExpandSelectionToDocumentBoundaryIntent>(this, granularity: TextGranularity.document)), |
341 | ExpandSelectionToLineBreakIntent: _makeOverridable(_GranularlyExtendSelectionAction<ExpandSelectionToLineBreakIntent>(this, granularity: TextGranularity.line)), |
342 | ExtendSelectionByCharacterIntent: _makeOverridable(_GranularlyExtendCaretSelectionAction<ExtendSelectionByCharacterIntent>(this, granularity: TextGranularity.character)), |
343 | ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(_GranularlyExtendCaretSelectionAction<ExtendSelectionToNextWordBoundaryIntent>(this, granularity: TextGranularity.word)), |
344 | ExtendSelectionToLineBreakIntent: _makeOverridable(_GranularlyExtendCaretSelectionAction<ExtendSelectionToLineBreakIntent>(this, granularity: TextGranularity.line)), |
345 | ExtendSelectionVerticallyToAdjacentLineIntent: _makeOverridable(_DirectionallyExtendCaretSelectionAction<ExtendSelectionVerticallyToAdjacentLineIntent>(this)), |
346 | ExtendSelectionToDocumentBoundaryIntent: _makeOverridable(_GranularlyExtendCaretSelectionAction<ExtendSelectionToDocumentBoundaryIntent>(this, granularity: TextGranularity.document)), |
347 | }; |
348 | |
349 | final Map<Type, GestureRecognizerFactory> _gestureRecognizers = <Type, GestureRecognizerFactory>{}; |
350 | SelectionOverlay? _selectionOverlay; |
351 | final LayerLink _startHandleLayerLink = LayerLink(); |
352 | final LayerLink _endHandleLayerLink = LayerLink(); |
353 | final LayerLink _toolbarLayerLink = LayerLink(); |
354 | final _SelectableRegionContainerDelegate _selectionDelegate = _SelectableRegionContainerDelegate(); |
355 | // there should only ever be one selectable, which is the SelectionContainer. |
356 | Selectable? _selectable; |
357 | |
358 | bool get _hasSelectionOverlayGeometry => _selectionDelegate.value.startSelectionPoint != null |
359 | || _selectionDelegate.value.endSelectionPoint != null; |
360 | |
361 | Orientation? _lastOrientation; |
362 | SelectedContent? _lastSelectedContent; |
363 | |
364 | /// The [SelectionOverlay] that is currently visible on the screen. |
365 | /// |
366 | /// Can be null if there is no visible [SelectionOverlay]. |
367 | @visibleForTesting |
368 | SelectionOverlay? get selectionOverlay => _selectionOverlay; |
369 | |
370 | /// The text processing service used to retrieve the native text processing actions. |
371 | final ProcessTextService _processTextService = DefaultProcessTextService(); |
372 | |
373 | /// The list of native text processing actions provided by the engine. |
374 | final List<ProcessTextAction> _processTextActions = <ProcessTextAction>[]; |
375 | |
376 | @override |
377 | void initState() { |
378 | super.initState(); |
379 | widget.focusNode.addListener(_handleFocusChanged); |
380 | _initMouseGestureRecognizer(); |
381 | _initTouchGestureRecognizer(); |
382 | // Right clicks. |
383 | _gestureRecognizers[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>( |
384 | () => TapGestureRecognizer(debugOwner: this), |
385 | (TapGestureRecognizer instance) { |
386 | instance.onSecondaryTapDown = _handleRightClickDown; |
387 | }, |
388 | ); |
389 | _initProcessTextActions(); |
390 | } |
391 | |
392 | /// Query the engine to initialize the list of text processing actions to show |
393 | /// in the text selection toolbar. |
394 | Future<void> _initProcessTextActions() async { |
395 | _processTextActions.clear(); |
396 | _processTextActions.addAll(await _processTextService.queryTextActions()); |
397 | } |
398 | |
399 | @override |
400 | void didChangeDependencies() { |
401 | super.didChangeDependencies(); |
402 | switch (defaultTargetPlatform) { |
403 | case TargetPlatform.android: |
404 | case TargetPlatform.iOS: |
405 | break; |
406 | case TargetPlatform.fuchsia: |
407 | case TargetPlatform.linux: |
408 | case TargetPlatform.macOS: |
409 | case TargetPlatform.windows: |
410 | return; |
411 | } |
412 | |
413 | // Hide the text selection toolbar on mobile when orientation changes. |
414 | final Orientation orientation = MediaQuery.orientationOf(context); |
415 | if (_lastOrientation == null) { |
416 | _lastOrientation = orientation; |
417 | return; |
418 | } |
419 | if (orientation != _lastOrientation) { |
420 | _lastOrientation = orientation; |
421 | hideToolbar(defaultTargetPlatform == TargetPlatform.android); |
422 | } |
423 | } |
424 | |
425 | @override |
426 | void didUpdateWidget(SelectableRegion oldWidget) { |
427 | super.didUpdateWidget(oldWidget); |
428 | if (widget.focusNode != oldWidget.focusNode) { |
429 | oldWidget.focusNode.removeListener(_handleFocusChanged); |
430 | widget.focusNode.addListener(_handleFocusChanged); |
431 | if (widget.focusNode.hasFocus != oldWidget.focusNode.hasFocus) { |
432 | _handleFocusChanged(); |
433 | } |
434 | } |
435 | } |
436 | |
437 | Action<T> _makeOverridable<T extends Intent>(Action<T> defaultAction) { |
438 | return Action<T>.overridable(context: context, defaultAction: defaultAction); |
439 | } |
440 | |
441 | void _handleFocusChanged() { |
442 | if (!widget.focusNode.hasFocus) { |
443 | if (kIsWeb) { |
444 | PlatformSelectableRegionContextMenu.detach(_selectionDelegate); |
445 | } |
446 | if (SchedulerBinding.instance.lifecycleState == AppLifecycleState.resumed) { |
447 | // We should only clear the selection when this SelectableRegion loses |
448 | // focus while the application is currently running. It is possible |
449 | // that the application is not currently running, for example on desktop |
450 | // platforms, clicking on a different window switches the focus to |
451 | // the new window causing the Flutter application to go inactive. In this |
452 | // case we want to retain the selection so it remains when we return to |
453 | // the Flutter application. |
454 | clearSelection(); |
455 | } |
456 | } |
457 | if (kIsWeb) { |
458 | PlatformSelectableRegionContextMenu.attach(_selectionDelegate); |
459 | } |
460 | } |
461 | |
462 | void _updateSelectionStatus() { |
463 | final SelectionGeometry geometry = _selectionDelegate.value; |
464 | final TextSelection selection = switch (geometry.status) { |
465 | SelectionStatus.uncollapsed || SelectionStatus.collapsed => const TextSelection(baseOffset: 0, extentOffset: 1), |
466 | SelectionStatus.none => const TextSelection.collapsed(offset: 1), |
467 | }; |
468 | textEditingValue = TextEditingValue(text: '__' , selection: selection); |
469 | if (_hasSelectionOverlayGeometry) { |
470 | _updateSelectionOverlay(); |
471 | } else { |
472 | _selectionOverlay?.dispose(); |
473 | _selectionOverlay = null; |
474 | } |
475 | } |
476 | |
477 | // gestures. |
478 | |
479 | /// Whether the Shift key was pressed when the most recent [PointerDownEvent] |
480 | /// was tracked by the [BaseTapAndDragGestureRecognizer]. |
481 | bool _isShiftPressed = false; |
482 | |
483 | // The position of the most recent secondary tap down event on this |
484 | // SelectableRegion. |
485 | Offset? _lastSecondaryTapDownPosition; |
486 | |
487 | // The device kind for the pointer of the most recent tap down event on this |
488 | // SelectableRegion. |
489 | PointerDeviceKind? _lastPointerDeviceKind; |
490 | |
491 | static bool _isPrecisePointerDevice(PointerDeviceKind pointerDeviceKind) { |
492 | switch (pointerDeviceKind) { |
493 | case PointerDeviceKind.mouse: |
494 | return true; |
495 | case PointerDeviceKind.trackpad: |
496 | case PointerDeviceKind.stylus: |
497 | case PointerDeviceKind.invertedStylus: |
498 | case PointerDeviceKind.touch: |
499 | case PointerDeviceKind.unknown: |
500 | return false; |
501 | } |
502 | } |
503 | |
504 | // Converts the details.consecutiveTapCount from a TapAndDrag*Details object, |
505 | // which can grow to be infinitely large, to a value between 1 and the supported |
506 | // max consecutive tap count. The value that the raw count is converted to is |
507 | // based on the default observed behavior on the native platforms. |
508 | // |
509 | // This method should be used in all instances when details.consecutiveTapCount |
510 | // would be used. |
511 | int _getEffectiveConsecutiveTapCount(int rawCount) { |
512 | int maxConsecutiveTap = 3; |
513 | switch (defaultTargetPlatform) { |
514 | case TargetPlatform.android: |
515 | case TargetPlatform.fuchsia: |
516 | if (_lastPointerDeviceKind != null && _lastPointerDeviceKind != PointerDeviceKind.mouse) { |
517 | // When the pointer device kind is not precise like a mouse, native |
518 | // Android resets the tap count at 2. For example, this is so the |
519 | // selection can collapse on the third tap. |
520 | maxConsecutiveTap = 2; |
521 | } |
522 | // From observation, these platforms reset their tap count to 0 when |
523 | // the number of consecutive taps exceeds the max consecutive tap supported. |
524 | // For example on native Android, when going past a triple click, |
525 | // on the fourth click the selection is moved to the precise click |
526 | // position, on the fifth click the word at the position is selected, and |
527 | // on the sixth click the paragraph at the position is selected. |
528 | return rawCount <= maxConsecutiveTap ? rawCount : (rawCount % maxConsecutiveTap == 0 ? maxConsecutiveTap : rawCount % maxConsecutiveTap); |
529 | case TargetPlatform.linux: |
530 | // From observation, these platforms reset their tap count to 0 when |
531 | // the number of consecutive taps exceeds the max consecutive tap supported. |
532 | // For example on Debian Linux with GTK, when going past a triple click, |
533 | // on the fourth click the selection is moved to the precise click |
534 | // position, on the fifth click the word at the position is selected, and |
535 | // on the sixth click the paragraph at the position is selected. |
536 | return rawCount <= maxConsecutiveTap ? rawCount : (rawCount % maxConsecutiveTap == 0 ? maxConsecutiveTap : rawCount % maxConsecutiveTap); |
537 | case TargetPlatform.iOS: |
538 | case TargetPlatform.macOS: |
539 | case TargetPlatform.windows: |
540 | // From observation, these platforms hold their tap count at the max |
541 | // consecutive tap supported. For example on macOS, when going past a triple |
542 | // click, the selection should be retained at the paragraph that was first |
543 | // selected on triple click. |
544 | return min(rawCount, maxConsecutiveTap); |
545 | } |
546 | } |
547 | |
548 | void _initMouseGestureRecognizer() { |
549 | _gestureRecognizers[TapAndPanGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>( |
550 | () => TapAndPanGestureRecognizer( |
551 | debugOwner:this, |
552 | supportedDevices: <PointerDeviceKind>{ PointerDeviceKind.mouse }, |
553 | ), |
554 | (TapAndPanGestureRecognizer instance) { |
555 | instance |
556 | ..onTapTrackStart = _onTapTrackStart |
557 | ..onTapTrackReset = _onTapTrackReset |
558 | ..onTapDown = _startNewMouseSelectionGesture |
559 | ..onTapUp = _handleMouseTapUp |
560 | ..onDragStart = _handleMouseDragStart |
561 | ..onDragUpdate = _handleMouseDragUpdate |
562 | ..onDragEnd = _handleMouseDragEnd |
563 | ..onCancel = clearSelection |
564 | ..dragStartBehavior = DragStartBehavior.down; |
565 | }, |
566 | ); |
567 | } |
568 | |
569 | void _onTapTrackStart() { |
570 | _isShiftPressed = HardwareKeyboard.instance.logicalKeysPressed |
571 | .intersection(<LogicalKeyboardKey>{LogicalKeyboardKey.shiftLeft, LogicalKeyboardKey.shiftRight}) |
572 | .isNotEmpty; |
573 | } |
574 | |
575 | void _onTapTrackReset() { |
576 | _isShiftPressed = false; |
577 | } |
578 | |
579 | void _initTouchGestureRecognizer() { |
580 | // A [TapAndHorizontalDragGestureRecognizer] is used on non-precise pointer devices |
581 | // like PointerDeviceKind.touch so [SelectableRegion] gestures do not conflict with |
582 | // ancestor Scrollable gestures in common scenarios like a vertically scrolling list view. |
583 | _gestureRecognizers[TapAndHorizontalDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapAndHorizontalDragGestureRecognizer>( |
584 | () => TapAndHorizontalDragGestureRecognizer( |
585 | debugOwner:this, |
586 | supportedDevices: PointerDeviceKind.values.where((PointerDeviceKind device) { |
587 | return device != PointerDeviceKind.mouse; |
588 | }).toSet(), |
589 | ), |
590 | (TapAndHorizontalDragGestureRecognizer instance) { |
591 | instance |
592 | // iOS does not provide a device specific touch slop |
593 | // unlike Android (~8.0), so the touch slop for a [Scrollable] |
594 | // always default to kTouchSlop which is 18.0. When |
595 | // [SelectableRegion] is the child of a horizontal |
596 | // scrollable that means the [SelectableRegion] will |
597 | // always win the gesture arena when competing with |
598 | // the ancestor scrollable because they both have |
599 | // the same touch slop threshold and the child receives |
600 | // the [PointerEvent] first. To avoid this conflict |
601 | // and ensure a smooth scrolling experience, on |
602 | // iOS the [TapAndHorizontalDragGestureRecognizer] |
603 | // will wait for all other gestures to lose before |
604 | // declaring victory. |
605 | ..eagerVictoryOnDrag = defaultTargetPlatform != TargetPlatform.iOS |
606 | ..onTapDown = _startNewMouseSelectionGesture |
607 | ..onTapUp = _handleMouseTapUp |
608 | ..onDragStart = _handleMouseDragStart |
609 | ..onDragUpdate = _handleMouseDragUpdate |
610 | ..onDragEnd = _handleMouseDragEnd |
611 | ..onCancel = clearSelection |
612 | ..dragStartBehavior = DragStartBehavior.down; |
613 | }, |
614 | ); |
615 | _gestureRecognizers[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>( |
616 | () => LongPressGestureRecognizer(debugOwner: this, supportedDevices: _kLongPressSelectionDevices), |
617 | (LongPressGestureRecognizer instance) { |
618 | instance |
619 | ..onLongPressStart = _handleTouchLongPressStart |
620 | ..onLongPressMoveUpdate = _handleTouchLongPressMoveUpdate |
621 | ..onLongPressEnd = _handleTouchLongPressEnd; |
622 | }, |
623 | ); |
624 | } |
625 | |
626 | Offset? _doubleTapOffset; |
627 | void _startNewMouseSelectionGesture(TapDragDownDetails details) { |
628 | _lastPointerDeviceKind = details.kind; |
629 | switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) { |
630 | case 1: |
631 | widget.focusNode.requestFocus(); |
632 | switch (defaultTargetPlatform) { |
633 | case TargetPlatform.android: |
634 | case TargetPlatform.fuchsia: |
635 | case TargetPlatform.iOS: |
636 | // On mobile platforms the selection is set on tap up for the first |
637 | // tap. |
638 | break; |
639 | case TargetPlatform.macOS: |
640 | case TargetPlatform.linux: |
641 | case TargetPlatform.windows: |
642 | hideToolbar(); |
643 | // It is impossible to extend the selection when the shift key is |
644 | // pressed and the start of the selection has not been initialized. |
645 | // In this case we fallback on collapsing the selection to first |
646 | // initialize the selection. |
647 | final bool isShiftPressedValid = _isShiftPressed && _selectionDelegate.value.startSelectionPoint != null; |
648 | if (isShiftPressedValid) { |
649 | _selectEndTo(offset: details.globalPosition); |
650 | return; |
651 | } |
652 | _collapseSelectionAt(offset: details.globalPosition); |
653 | } |
654 | case 2: |
655 | switch (defaultTargetPlatform) { |
656 | case TargetPlatform.iOS: |
657 | if (kIsWeb && details.kind != null && !_isPrecisePointerDevice(details.kind!)) { |
658 | // Double tap on iOS web triggers when a drag begins after the double tap. |
659 | _doubleTapOffset = details.globalPosition; |
660 | break; |
661 | } |
662 | _selectWordAt(offset: details.globalPosition); |
663 | if (details.kind != null && !_isPrecisePointerDevice(details.kind!)) { |
664 | _showHandles(); |
665 | } |
666 | case TargetPlatform.android: |
667 | case TargetPlatform.fuchsia: |
668 | case TargetPlatform.macOS: |
669 | case TargetPlatform.linux: |
670 | case TargetPlatform.windows: |
671 | _selectWordAt(offset: details.globalPosition); |
672 | } |
673 | case 3: |
674 | switch (defaultTargetPlatform) { |
675 | case TargetPlatform.android: |
676 | case TargetPlatform.fuchsia: |
677 | case TargetPlatform.iOS: |
678 | if (details.kind != null && _isPrecisePointerDevice(details.kind!)) { |
679 | // Triple tap on static text is only supported on mobile |
680 | // platforms using a precise pointer device. |
681 | _selectParagraphAt(offset: details.globalPosition); |
682 | } |
683 | case TargetPlatform.macOS: |
684 | case TargetPlatform.linux: |
685 | case TargetPlatform.windows: |
686 | _selectParagraphAt(offset: details.globalPosition); |
687 | } |
688 | } |
689 | _updateSelectedContentIfNeeded(); |
690 | } |
691 | |
692 | void _handleMouseDragStart(TapDragStartDetails details) { |
693 | switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) { |
694 | case 1: |
695 | if (details.kind != null && !_isPrecisePointerDevice(details.kind!)) { |
696 | // Drag to select is only enabled with a precise pointer device. |
697 | return; |
698 | } |
699 | _selectStartTo(offset: details.globalPosition); |
700 | } |
701 | _updateSelectedContentIfNeeded(); |
702 | } |
703 | |
704 | void _handleMouseDragUpdate(TapDragUpdateDetails details) { |
705 | switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) { |
706 | case 1: |
707 | if (details.kind != null && !_isPrecisePointerDevice(details.kind!)) { |
708 | // Drag to select is only enabled with a precise pointer device. |
709 | return; |
710 | } |
711 | _selectEndTo(offset: details.globalPosition, continuous: true); |
712 | case 2: |
713 | switch (defaultTargetPlatform) { |
714 | case TargetPlatform.android: |
715 | case TargetPlatform.fuchsia: |
716 | // Double tap + drag is only supported on Android when using a precise |
717 | // pointer device or when not on the web. |
718 | if (!kIsWeb || details.kind != null && _isPrecisePointerDevice(details.kind!)) { |
719 | _selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.word); |
720 | } |
721 | case TargetPlatform.iOS: |
722 | if (kIsWeb && details.kind != null && !_isPrecisePointerDevice(details.kind!) && _doubleTapOffset != null) { |
723 | // On iOS web a double tap does not select the word at the position, |
724 | // until the drag has begun. |
725 | _selectWordAt(offset: _doubleTapOffset!); |
726 | _doubleTapOffset = null; |
727 | } |
728 | _selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.word); |
729 | if (details.kind != null && !_isPrecisePointerDevice(details.kind!)) { |
730 | _showHandles(); |
731 | } |
732 | case TargetPlatform.macOS: |
733 | case TargetPlatform.linux: |
734 | case TargetPlatform.windows: |
735 | _selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.word); |
736 | } |
737 | case 3: |
738 | switch (defaultTargetPlatform) { |
739 | case TargetPlatform.android: |
740 | case TargetPlatform.fuchsia: |
741 | case TargetPlatform.iOS: |
742 | // Triple tap + drag is only supported on mobile devices when using |
743 | // a precise pointer device. |
744 | if (details.kind != null && _isPrecisePointerDevice(details.kind!)) { |
745 | _selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.paragraph); |
746 | } |
747 | case TargetPlatform.macOS: |
748 | case TargetPlatform.linux: |
749 | case TargetPlatform.windows: |
750 | _selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.paragraph); |
751 | } |
752 | } |
753 | _updateSelectedContentIfNeeded(); |
754 | } |
755 | |
756 | void _handleMouseDragEnd(TapDragEndDetails details) { |
757 | final bool isPointerPrecise = _lastPointerDeviceKind != null && _lastPointerDeviceKind == PointerDeviceKind.mouse; |
758 | switch (defaultTargetPlatform) { |
759 | case TargetPlatform.android: |
760 | case TargetPlatform.fuchsia: |
761 | if (!isPointerPrecise) { |
762 | // On Android, a drag gesture will only show the selection overlay when |
763 | // the drag has finished and the pointer device kind is not precise. |
764 | _showHandles(); |
765 | _showToolbar(); |
766 | } |
767 | case TargetPlatform.iOS: |
768 | if (!isPointerPrecise) { |
769 | // On iOS, a drag gesture will only show the selection toolbar when |
770 | // the drag has finished and the pointer device kind is not precise. |
771 | _showToolbar(); |
772 | } |
773 | case TargetPlatform.macOS: |
774 | case TargetPlatform.linux: |
775 | case TargetPlatform.windows: |
776 | // The selection overlay is not shown on desktop platforms after a drag. |
777 | break; |
778 | } |
779 | _finalizeSelection(); |
780 | _updateSelectedContentIfNeeded(); |
781 | } |
782 | |
783 | void _handleMouseTapUp(TapDragUpDetails details) { |
784 | if (defaultTargetPlatform == TargetPlatform.iOS && _positionIsOnActiveSelection(globalPosition: details.globalPosition)) { |
785 | // On iOS when the tap occurs on the previous selection, instead of |
786 | // moving the selection, the context menu will be toggled. |
787 | final bool toolbarIsVisible = _selectionOverlay?.toolbarIsVisible ?? false; |
788 | if (toolbarIsVisible) { |
789 | hideToolbar(false); |
790 | } else { |
791 | _showToolbar(); |
792 | } |
793 | return; |
794 | } |
795 | switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) { |
796 | case 1: |
797 | switch (defaultTargetPlatform) { |
798 | case TargetPlatform.android: |
799 | case TargetPlatform.fuchsia: |
800 | case TargetPlatform.iOS: |
801 | hideToolbar(); |
802 | _collapseSelectionAt(offset: details.globalPosition); |
803 | case TargetPlatform.macOS: |
804 | case TargetPlatform.linux: |
805 | case TargetPlatform.windows: |
806 | // On desktop platforms the selection is set on tap down. |
807 | break; |
808 | } |
809 | case 2: |
810 | final bool isPointerPrecise = _isPrecisePointerDevice(details.kind); |
811 | switch (defaultTargetPlatform) { |
812 | case TargetPlatform.android: |
813 | case TargetPlatform.fuchsia: |
814 | if (!isPointerPrecise) { |
815 | // On Android, a double tap will only show the selection overlay after |
816 | // the following tap up when the pointer device kind is not precise. |
817 | _showHandles(); |
818 | _showToolbar(); |
819 | } |
820 | case TargetPlatform.iOS: |
821 | if (!isPointerPrecise) { |
822 | // On iOS, a double tap will only show the selection toolbar after |
823 | // the following tap up when the pointer device kind is not precise. |
824 | _showToolbar(); |
825 | } |
826 | case TargetPlatform.macOS: |
827 | case TargetPlatform.linux: |
828 | case TargetPlatform.windows: |
829 | // The selection overlay is not shown on desktop platforms |
830 | // on a double click. |
831 | break; |
832 | } |
833 | } |
834 | _updateSelectedContentIfNeeded(); |
835 | } |
836 | |
837 | void _updateSelectedContentIfNeeded() { |
838 | if (_lastSelectedContent?.plainText != _selectable?.getSelectedContent()?.plainText) { |
839 | _lastSelectedContent = _selectable?.getSelectedContent(); |
840 | widget.onSelectionChanged?.call(_lastSelectedContent); |
841 | } |
842 | } |
843 | |
844 | void _handleTouchLongPressStart(LongPressStartDetails details) { |
845 | HapticFeedback.selectionClick(); |
846 | widget.focusNode.requestFocus(); |
847 | _selectWordAt(offset: details.globalPosition); |
848 | // Platforms besides Android will show the text selection handles when |
849 | // the long press is initiated. Android shows the text selection handles when |
850 | // the long press has ended, usually after a pointer up event is received. |
851 | if (defaultTargetPlatform != TargetPlatform.android) { |
852 | _showHandles(); |
853 | } |
854 | _updateSelectedContentIfNeeded(); |
855 | } |
856 | |
857 | void _handleTouchLongPressMoveUpdate(LongPressMoveUpdateDetails details) { |
858 | _selectEndTo(offset: details.globalPosition, textGranularity: TextGranularity.word); |
859 | _updateSelectedContentIfNeeded(); |
860 | } |
861 | |
862 | void _handleTouchLongPressEnd(LongPressEndDetails details) { |
863 | _finalizeSelection(); |
864 | _updateSelectedContentIfNeeded(); |
865 | _showToolbar(); |
866 | if (defaultTargetPlatform == TargetPlatform.android) { |
867 | _showHandles(); |
868 | } |
869 | } |
870 | |
871 | bool _positionIsOnActiveSelection({required Offset globalPosition}) { |
872 | for (final Rect selectionRect in _selectionDelegate.value.selectionRects) { |
873 | final Matrix4 transform = _selectable!.getTransformTo(null); |
874 | final Rect globalRect = MatrixUtils.transformRect(transform, selectionRect); |
875 | if (globalRect.contains(globalPosition)) { |
876 | return true; |
877 | } |
878 | } |
879 | return false; |
880 | } |
881 | |
882 | void _handleRightClickDown(TapDownDetails details) { |
883 | final Offset? previousSecondaryTapDownPosition = _lastSecondaryTapDownPosition; |
884 | final bool toolbarIsVisible = _selectionOverlay?.toolbarIsVisible ?? false; |
885 | _lastSecondaryTapDownPosition = details.globalPosition; |
886 | widget.focusNode.requestFocus(); |
887 | switch (defaultTargetPlatform) { |
888 | case TargetPlatform.android: |
889 | case TargetPlatform.fuchsia: |
890 | case TargetPlatform.windows: |
891 | // If _lastSecondaryTapDownPosition is within the current selection then |
892 | // keep the current selection, if not then collapse it. |
893 | final bool lastSecondaryTapDownPositionWasOnActiveSelection = _positionIsOnActiveSelection(globalPosition: details.globalPosition); |
894 | if (!lastSecondaryTapDownPositionWasOnActiveSelection) { |
895 | _collapseSelectionAt(offset: _lastSecondaryTapDownPosition!); |
896 | } |
897 | _showHandles(); |
898 | _showToolbar(location: _lastSecondaryTapDownPosition); |
899 | case TargetPlatform.iOS: |
900 | _selectWordAt(offset: _lastSecondaryTapDownPosition!); |
901 | _showHandles(); |
902 | _showToolbar(location: _lastSecondaryTapDownPosition); |
903 | case TargetPlatform.macOS: |
904 | if (previousSecondaryTapDownPosition == _lastSecondaryTapDownPosition && toolbarIsVisible) { |
905 | hideToolbar(); |
906 | return; |
907 | } |
908 | _selectWordAt(offset: _lastSecondaryTapDownPosition!); |
909 | _showHandles(); |
910 | _showToolbar(location: _lastSecondaryTapDownPosition); |
911 | case TargetPlatform.linux: |
912 | if (toolbarIsVisible) { |
913 | hideToolbar(); |
914 | return; |
915 | } |
916 | // If _lastSecondaryTapDownPosition is within the current selection then |
917 | // keep the current selection, if not then collapse it. |
918 | final bool lastSecondaryTapDownPositionWasOnActiveSelection = _positionIsOnActiveSelection(globalPosition: details.globalPosition); |
919 | if (!lastSecondaryTapDownPositionWasOnActiveSelection) { |
920 | _collapseSelectionAt(offset: _lastSecondaryTapDownPosition!); |
921 | } |
922 | _showHandles(); |
923 | _showToolbar(location: _lastSecondaryTapDownPosition); |
924 | } |
925 | _updateSelectedContentIfNeeded(); |
926 | } |
927 | |
928 | // Selection update helper methods. |
929 | |
930 | Offset? _selectionEndPosition; |
931 | bool get _userDraggingSelectionEnd => _selectionEndPosition != null; |
932 | bool _scheduledSelectionEndEdgeUpdate = false; |
933 | |
934 | /// Sends end [SelectionEdgeUpdateEvent] to the selectable subtree. |
935 | /// |
936 | /// If the selectable subtree returns a [SelectionResult.pending], this method |
937 | /// continues to send [SelectionEdgeUpdateEvent]s every frame until the result |
938 | /// is not pending or users end their gestures. |
939 | void _triggerSelectionEndEdgeUpdate({TextGranularity? textGranularity}) { |
940 | // This method can be called when the drag is not in progress. This can |
941 | // happen if the child scrollable returns SelectionResult.pending, and |
942 | // the selection area scheduled a selection update for the next frame, but |
943 | // the drag is lifted before the scheduled selection update is run. |
944 | if (_scheduledSelectionEndEdgeUpdate || !_userDraggingSelectionEnd) { |
945 | return; |
946 | } |
947 | if (_selectable?.dispatchSelectionEvent( |
948 | SelectionEdgeUpdateEvent.forEnd(globalPosition: _selectionEndPosition!, granularity: textGranularity)) == SelectionResult.pending) { |
949 | _scheduledSelectionEndEdgeUpdate = true; |
950 | SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { |
951 | if (!_scheduledSelectionEndEdgeUpdate) { |
952 | return; |
953 | } |
954 | _scheduledSelectionEndEdgeUpdate = false; |
955 | _triggerSelectionEndEdgeUpdate(textGranularity: textGranularity); |
956 | }, debugLabel: 'SelectableRegion.endEdgeUpdate' ); |
957 | return; |
958 | } |
959 | } |
960 | |
961 | void _onAnyDragEnd(DragEndDetails details) { |
962 | if (widget.selectionControls is! TextSelectionHandleControls) { |
963 | _selectionOverlay!.hideMagnifier(); |
964 | _selectionOverlay!.showToolbar(); |
965 | } else { |
966 | _selectionOverlay!.hideMagnifier(); |
967 | _selectionOverlay!.showToolbar( |
968 | context: context, |
969 | contextMenuBuilder: (BuildContext context) { |
970 | return widget.contextMenuBuilder!(context, this); |
971 | }, |
972 | ); |
973 | } |
974 | _stopSelectionStartEdgeUpdate(); |
975 | _stopSelectionEndEdgeUpdate(); |
976 | _updateSelectedContentIfNeeded(); |
977 | } |
978 | |
979 | void _stopSelectionEndEdgeUpdate() { |
980 | _scheduledSelectionEndEdgeUpdate = false; |
981 | _selectionEndPosition = null; |
982 | } |
983 | |
984 | Offset? _selectionStartPosition; |
985 | bool get _userDraggingSelectionStart => _selectionStartPosition != null; |
986 | bool _scheduledSelectionStartEdgeUpdate = false; |
987 | |
988 | /// Sends start [SelectionEdgeUpdateEvent] to the selectable subtree. |
989 | /// |
990 | /// If the selectable subtree returns a [SelectionResult.pending], this method |
991 | /// continues to send [SelectionEdgeUpdateEvent]s every frame until the result |
992 | /// is not pending or users end their gestures. |
993 | void _triggerSelectionStartEdgeUpdate({TextGranularity? textGranularity}) { |
994 | // This method can be called when the drag is not in progress. This can |
995 | // happen if the child scrollable returns SelectionResult.pending, and |
996 | // the selection area scheduled a selection update for the next frame, but |
997 | // the drag is lifted before the scheduled selection update is run. |
998 | if (_scheduledSelectionStartEdgeUpdate || !_userDraggingSelectionStart) { |
999 | return; |
1000 | } |
1001 | if (_selectable?.dispatchSelectionEvent( |
1002 | SelectionEdgeUpdateEvent.forStart(globalPosition: _selectionStartPosition!, granularity: textGranularity)) == SelectionResult.pending) { |
1003 | _scheduledSelectionStartEdgeUpdate = true; |
1004 | SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { |
1005 | if (!_scheduledSelectionStartEdgeUpdate) { |
1006 | return; |
1007 | } |
1008 | _scheduledSelectionStartEdgeUpdate = false; |
1009 | _triggerSelectionStartEdgeUpdate(textGranularity: textGranularity); |
1010 | }, debugLabel: 'SelectableRegion.startEdgeUpdate' ); |
1011 | return; |
1012 | } |
1013 | } |
1014 | |
1015 | void _stopSelectionStartEdgeUpdate() { |
1016 | _scheduledSelectionStartEdgeUpdate = false; |
1017 | _selectionEndPosition = null; |
1018 | } |
1019 | |
1020 | // SelectionOverlay helper methods. |
1021 | |
1022 | late Offset _selectionStartHandleDragPosition; |
1023 | late Offset _selectionEndHandleDragPosition; |
1024 | |
1025 | void _handleSelectionStartHandleDragStart(DragStartDetails details) { |
1026 | assert(_selectionDelegate.value.startSelectionPoint != null); |
1027 | |
1028 | final Offset localPosition = _selectionDelegate.value.startSelectionPoint!.localPosition; |
1029 | final Matrix4 globalTransform = _selectable!.getTransformTo(null); |
1030 | _selectionStartHandleDragPosition = MatrixUtils.transformPoint(globalTransform, localPosition); |
1031 | |
1032 | _selectionOverlay!.showMagnifier(_buildInfoForMagnifier( |
1033 | details.globalPosition, |
1034 | _selectionDelegate.value.startSelectionPoint!, |
1035 | )); |
1036 | _updateSelectedContentIfNeeded(); |
1037 | } |
1038 | |
1039 | void _handleSelectionStartHandleDragUpdate(DragUpdateDetails details) { |
1040 | _selectionStartHandleDragPosition = _selectionStartHandleDragPosition + details.delta; |
1041 | // The value corresponds to the paint origin of the selection handle. |
1042 | // Offset it to the center of the line to make it feel more natural. |
1043 | _selectionStartPosition = _selectionStartHandleDragPosition - Offset(0, _selectionDelegate.value.startSelectionPoint!.lineHeight / 2); |
1044 | _triggerSelectionStartEdgeUpdate(); |
1045 | |
1046 | _selectionOverlay!.updateMagnifier(_buildInfoForMagnifier( |
1047 | details.globalPosition, |
1048 | _selectionDelegate.value.startSelectionPoint!, |
1049 | )); |
1050 | _updateSelectedContentIfNeeded(); |
1051 | } |
1052 | |
1053 | void _handleSelectionEndHandleDragStart(DragStartDetails details) { |
1054 | assert(_selectionDelegate.value.endSelectionPoint != null); |
1055 | final Offset localPosition = _selectionDelegate.value.endSelectionPoint!.localPosition; |
1056 | final Matrix4 globalTransform = _selectable!.getTransformTo(null); |
1057 | _selectionEndHandleDragPosition = MatrixUtils.transformPoint(globalTransform, localPosition); |
1058 | |
1059 | _selectionOverlay!.showMagnifier(_buildInfoForMagnifier( |
1060 | details.globalPosition, |
1061 | _selectionDelegate.value.endSelectionPoint!, |
1062 | )); |
1063 | _updateSelectedContentIfNeeded(); |
1064 | } |
1065 | |
1066 | void _handleSelectionEndHandleDragUpdate(DragUpdateDetails details) { |
1067 | _selectionEndHandleDragPosition = _selectionEndHandleDragPosition + details.delta; |
1068 | // The value corresponds to the paint origin of the selection handle. |
1069 | // Offset it to the center of the line to make it feel more natural. |
1070 | _selectionEndPosition = _selectionEndHandleDragPosition - Offset(0, _selectionDelegate.value.endSelectionPoint!.lineHeight / 2); |
1071 | _triggerSelectionEndEdgeUpdate(); |
1072 | |
1073 | _selectionOverlay!.updateMagnifier(_buildInfoForMagnifier( |
1074 | details.globalPosition, |
1075 | _selectionDelegate.value.endSelectionPoint!, |
1076 | )); |
1077 | _updateSelectedContentIfNeeded(); |
1078 | } |
1079 | |
1080 | MagnifierInfo _buildInfoForMagnifier(Offset globalGesturePosition, SelectionPoint selectionPoint) { |
1081 | final Vector3 globalTransform = _selectable!.getTransformTo(null).getTranslation(); |
1082 | final Offset globalTransformAsOffset = Offset(globalTransform.x, globalTransform.y); |
1083 | final Offset globalSelectionPointPosition = selectionPoint.localPosition + globalTransformAsOffset; |
1084 | final Rect caretRect = Rect.fromLTWH( |
1085 | globalSelectionPointPosition.dx, |
1086 | globalSelectionPointPosition.dy - selectionPoint.lineHeight, |
1087 | 0, |
1088 | selectionPoint.lineHeight |
1089 | ); |
1090 | |
1091 | return MagnifierInfo( |
1092 | globalGesturePosition: globalGesturePosition, |
1093 | caretRect: caretRect, |
1094 | fieldBounds: globalTransformAsOffset & _selectable!.size, |
1095 | currentLineBoundaries: globalTransformAsOffset & _selectable!.size, |
1096 | ); |
1097 | } |
1098 | |
1099 | void _createSelectionOverlay() { |
1100 | assert(_hasSelectionOverlayGeometry); |
1101 | if (_selectionOverlay != null) { |
1102 | return; |
1103 | } |
1104 | final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint; |
1105 | final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint; |
1106 | _selectionOverlay = SelectionOverlay( |
1107 | context: context, |
1108 | debugRequiredFor: widget, |
1109 | startHandleType: start?.handleType ?? TextSelectionHandleType.left, |
1110 | lineHeightAtStart: start?.lineHeight ?? end!.lineHeight, |
1111 | onStartHandleDragStart: _handleSelectionStartHandleDragStart, |
1112 | onStartHandleDragUpdate: _handleSelectionStartHandleDragUpdate, |
1113 | onStartHandleDragEnd: _onAnyDragEnd, |
1114 | endHandleType: end?.handleType ?? TextSelectionHandleType.right, |
1115 | lineHeightAtEnd: end?.lineHeight ?? start!.lineHeight, |
1116 | onEndHandleDragStart: _handleSelectionEndHandleDragStart, |
1117 | onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate, |
1118 | onEndHandleDragEnd: _onAnyDragEnd, |
1119 | selectionEndpoints: selectionEndpoints, |
1120 | selectionControls: widget.selectionControls, |
1121 | selectionDelegate: this, |
1122 | clipboardStatus: null, |
1123 | startHandleLayerLink: _startHandleLayerLink, |
1124 | endHandleLayerLink: _endHandleLayerLink, |
1125 | toolbarLayerLink: _toolbarLayerLink, |
1126 | magnifierConfiguration: widget.magnifierConfiguration |
1127 | ); |
1128 | } |
1129 | |
1130 | void _updateSelectionOverlay() { |
1131 | if (_selectionOverlay == null) { |
1132 | return; |
1133 | } |
1134 | assert(_hasSelectionOverlayGeometry); |
1135 | final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint; |
1136 | final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint; |
1137 | _selectionOverlay! |
1138 | ..startHandleType = start?.handleType ?? TextSelectionHandleType.left |
1139 | ..lineHeightAtStart = start?.lineHeight ?? end!.lineHeight |
1140 | ..endHandleType = end?.handleType ?? TextSelectionHandleType.right |
1141 | ..lineHeightAtEnd = end?.lineHeight ?? start!.lineHeight |
1142 | ..selectionEndpoints = selectionEndpoints; |
1143 | } |
1144 | |
1145 | /// Shows the selection handles. |
1146 | /// |
1147 | /// Returns true if the handles are shown, false if the handles can't be |
1148 | /// shown. |
1149 | bool _showHandles() { |
1150 | if (_selectionOverlay != null) { |
1151 | _selectionOverlay!.showHandles(); |
1152 | return true; |
1153 | } |
1154 | |
1155 | if (!_hasSelectionOverlayGeometry) { |
1156 | return false; |
1157 | } |
1158 | |
1159 | _createSelectionOverlay(); |
1160 | _selectionOverlay!.showHandles(); |
1161 | return true; |
1162 | } |
1163 | |
1164 | /// Shows the text selection toolbar. |
1165 | /// |
1166 | /// If the parameter `location` is set, the toolbar will be shown at the |
1167 | /// location. Otherwise, the toolbar location will be calculated based on the |
1168 | /// handles' locations. The `location` is in the coordinates system of the |
1169 | /// [Overlay]. |
1170 | /// |
1171 | /// Returns true if the toolbar is shown, false if the toolbar can't be shown. |
1172 | bool _showToolbar({Offset? location}) { |
1173 | if (!_hasSelectionOverlayGeometry && _selectionOverlay == null) { |
1174 | return false; |
1175 | } |
1176 | |
1177 | // Web is using native dom elements to enable clipboard functionality of the |
1178 | // context menu: copy, paste, select, cut. It might also provide additional |
1179 | // functionality depending on the browser (such as translate). Due to this, |
1180 | // we should not show a Flutter toolbar for the editable text elements |
1181 | // unless the browser's context menu is explicitly disabled. |
1182 | if (kIsWeb && BrowserContextMenu.enabled) { |
1183 | return false; |
1184 | } |
1185 | |
1186 | if (_selectionOverlay == null) { |
1187 | _createSelectionOverlay(); |
1188 | } |
1189 | |
1190 | _selectionOverlay!.toolbarLocation = location; |
1191 | if (widget.selectionControls is! TextSelectionHandleControls) { |
1192 | _selectionOverlay!.showToolbar(); |
1193 | return true; |
1194 | } |
1195 | |
1196 | _selectionOverlay!.hideToolbar(); |
1197 | |
1198 | _selectionOverlay!.showToolbar( |
1199 | context: context, |
1200 | contextMenuBuilder: (BuildContext context) { |
1201 | return widget.contextMenuBuilder!(context, this); |
1202 | }, |
1203 | ); |
1204 | return true; |
1205 | } |
1206 | |
1207 | /// Sets or updates selection end edge to the `offset` location. |
1208 | /// |
1209 | /// A selection always contains a select start edge and selection end edge. |
1210 | /// They can be created by calling both [_selectStartTo] and [_selectEndTo], or |
1211 | /// use other selection APIs, such as [_selectWordAt] or [selectAll]. |
1212 | /// |
1213 | /// This method sets or updates the selection end edge by sending |
1214 | /// [SelectionEdgeUpdateEvent]s to the child [Selectable]s. |
1215 | /// |
1216 | /// If `continuous` is set to true and the update causes scrolling, the |
1217 | /// method will continue sending the same [SelectionEdgeUpdateEvent]s to the |
1218 | /// child [Selectable]s every frame until the scrolling finishes or a |
1219 | /// [_finalizeSelection] is called. |
1220 | /// |
1221 | /// The `continuous` argument defaults to false. |
1222 | /// |
1223 | /// The `offset` is in global coordinates. |
1224 | /// |
1225 | /// Provide the `textGranularity` if the selection should not move by the default |
1226 | /// [TextGranularity.character]. Only [TextGranularity.character] and |
1227 | /// [TextGranularity.word] are currently supported. |
1228 | /// |
1229 | /// See also: |
1230 | /// * [_selectStartTo], which sets or updates selection start edge. |
1231 | /// * [_finalizeSelection], which stops the `continuous` updates. |
1232 | /// * [clearSelection], which clears the ongoing selection. |
1233 | /// * [_selectWordAt], which selects a whole word at the location. |
1234 | /// * [_selectParagraphAt], which selects an entire paragraph at the location. |
1235 | /// * [_collapseSelectionAt], which collapses the selection at the location. |
1236 | /// * [selectAll], which selects the entire content. |
1237 | void _selectEndTo({required Offset offset, bool continuous = false, TextGranularity? textGranularity}) { |
1238 | if (!continuous) { |
1239 | _selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forEnd(globalPosition: offset, granularity: textGranularity)); |
1240 | return; |
1241 | } |
1242 | if (_selectionEndPosition != offset) { |
1243 | _selectionEndPosition = offset; |
1244 | _triggerSelectionEndEdgeUpdate(textGranularity: textGranularity); |
1245 | } |
1246 | } |
1247 | |
1248 | /// Sets or updates selection start edge to the `offset` location. |
1249 | /// |
1250 | /// A selection always contains a select start edge and selection end edge. |
1251 | /// They can be created by calling both [_selectStartTo] and [_selectEndTo], or |
1252 | /// use other selection APIs, such as [_selectWordAt] or [selectAll]. |
1253 | /// |
1254 | /// This method sets or updates the selection start edge by sending |
1255 | /// [SelectionEdgeUpdateEvent]s to the child [Selectable]s. |
1256 | /// |
1257 | /// If `continuous` is set to true and the update causes scrolling, the |
1258 | /// method will continue sending the same [SelectionEdgeUpdateEvent]s to the |
1259 | /// child [Selectable]s every frame until the scrolling finishes or a |
1260 | /// [_finalizeSelection] is called. |
1261 | /// |
1262 | /// The `continuous` argument defaults to false. |
1263 | /// |
1264 | /// The `offset` is in global coordinates. |
1265 | /// |
1266 | /// Provide the `textGranularity` if the selection should not move by the default |
1267 | /// [TextGranularity.character]. Only [TextGranularity.character] and |
1268 | /// [TextGranularity.word] are currently supported. |
1269 | /// |
1270 | /// See also: |
1271 | /// * [_selectEndTo], which sets or updates selection end edge. |
1272 | /// * [_finalizeSelection], which stops the `continuous` updates. |
1273 | /// * [clearSelection], which clears the ongoing selection. |
1274 | /// * [_selectWordAt], which selects a whole word at the location. |
1275 | /// * [_selectParagraphAt], which selects an entire paragraph at the location. |
1276 | /// * [_collapseSelectionAt], which collapses the selection at the location. |
1277 | /// * [selectAll], which selects the entire content. |
1278 | void _selectStartTo({required Offset offset, bool continuous = false, TextGranularity? textGranularity}) { |
1279 | if (!continuous) { |
1280 | _selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forStart(globalPosition: offset, granularity: textGranularity)); |
1281 | return; |
1282 | } |
1283 | if (_selectionStartPosition != offset) { |
1284 | _selectionStartPosition = offset; |
1285 | _triggerSelectionStartEdgeUpdate(textGranularity: textGranularity); |
1286 | } |
1287 | } |
1288 | |
1289 | /// Collapses the selection at the given `offset` location. |
1290 | /// |
1291 | /// The `offset` is in global coordinates. |
1292 | /// |
1293 | /// See also: |
1294 | /// * [_selectStartTo], which sets or updates selection start edge. |
1295 | /// * [_selectEndTo], which sets or updates selection end edge. |
1296 | /// * [_finalizeSelection], which stops the `continuous` updates. |
1297 | /// * [clearSelection], which clears the ongoing selection. |
1298 | /// * [_selectWordAt], which selects a whole word at the location. |
1299 | /// * [_selectParagraphAt], which selects an entire paragraph at the location. |
1300 | /// * [selectAll], which selects the entire content. |
1301 | void _collapseSelectionAt({required Offset offset}) { |
1302 | _selectStartTo(offset: offset); |
1303 | _selectEndTo(offset: offset); |
1304 | } |
1305 | |
1306 | /// Selects a whole word at the `offset` location. |
1307 | /// |
1308 | /// The `offset` is in global coordinates. |
1309 | /// |
1310 | /// If the whole word is already in the current selection, selection won't |
1311 | /// change. One call [clearSelection] first if the selection needs to be |
1312 | /// updated even if the word is already covered by the current selection. |
1313 | /// |
1314 | /// One can also use [_selectEndTo] or [_selectStartTo] to adjust the selection |
1315 | /// edges after calling this method. |
1316 | /// |
1317 | /// See also: |
1318 | /// * [_selectStartTo], which sets or updates selection start edge. |
1319 | /// * [_selectEndTo], which sets or updates selection end edge. |
1320 | /// * [_finalizeSelection], which stops the `continuous` updates. |
1321 | /// * [clearSelection], which clears the ongoing selection. |
1322 | /// * [_collapseSelectionAt], which collapses the selection at the location. |
1323 | /// * [_selectParagraphAt], which selects an entire paragraph at the location. |
1324 | /// * [selectAll], which selects the entire content. |
1325 | void _selectWordAt({required Offset offset}) { |
1326 | // There may be other selection ongoing. |
1327 | _finalizeSelection(); |
1328 | _selectable?.dispatchSelectionEvent(SelectWordSelectionEvent(globalPosition: offset)); |
1329 | } |
1330 | |
1331 | /// Selects the entire paragraph at the `offset` location. |
1332 | /// |
1333 | /// The `offset` is in global coordinates. |
1334 | /// |
1335 | /// If the paragraph is already in the current selection, selection won't |
1336 | /// change. One call [clearSelection] first if the selection needs to be |
1337 | /// updated even if the paragraph is already covered by the current selection. |
1338 | /// |
1339 | /// One can also use [_selectEndTo] or [_selectStartTo] to adjust the selection |
1340 | /// edges after calling this method. |
1341 | /// |
1342 | /// See also: |
1343 | /// * [_selectStartTo], which sets or updates selection start edge. |
1344 | /// * [_selectEndTo], which sets or updates selection end edge. |
1345 | /// * [_finalizeSelection], which stops the `continuous` updates. |
1346 | /// * [clearSelection], which clear the ongoing selection. |
1347 | /// * [_selectWordAt], which selects a whole word at the location. |
1348 | /// * [selectAll], which selects the entire content. |
1349 | void _selectParagraphAt({required Offset offset}) { |
1350 | // There may be other selection ongoing. |
1351 | _finalizeSelection(); |
1352 | _selectable?.dispatchSelectionEvent(SelectParagraphSelectionEvent(globalPosition: offset)); |
1353 | } |
1354 | |
1355 | /// Stops any ongoing selection updates. |
1356 | /// |
1357 | /// This method is different from [clearSelection] that it does not remove |
1358 | /// the current selection. It only stops the continuous updates. |
1359 | /// |
1360 | /// A continuous update can happen as result of calling [_selectStartTo] or |
1361 | /// [_selectEndTo] with `continuous` sets to true which causes a [Selectable] |
1362 | /// to scroll. Calling this method will stop the update as well as the |
1363 | /// scrolling. |
1364 | void _finalizeSelection() { |
1365 | _stopSelectionEndEdgeUpdate(); |
1366 | _stopSelectionStartEdgeUpdate(); |
1367 | } |
1368 | |
1369 | /// Removes the ongoing selection for this [SelectableRegion]. |
1370 | void clearSelection() { |
1371 | _finalizeSelection(); |
1372 | _directionalHorizontalBaseline = null; |
1373 | _adjustingSelectionEnd = null; |
1374 | _selectable?.dispatchSelectionEvent(const ClearSelectionEvent()); |
1375 | _updateSelectedContentIfNeeded(); |
1376 | } |
1377 | |
1378 | Future<void> _copy() async { |
1379 | final SelectedContent? data = _selectable?.getSelectedContent(); |
1380 | if (data == null) { |
1381 | return; |
1382 | } |
1383 | await Clipboard.setData(ClipboardData(text: data.plainText)); |
1384 | } |
1385 | |
1386 | Future<void> _share() async { |
1387 | final SelectedContent? data = _selectable?.getSelectedContent(); |
1388 | if (data == null) { |
1389 | return; |
1390 | } |
1391 | await SystemChannels.platform.invokeMethod('Share.invoke' , data.plainText); |
1392 | } |
1393 | |
1394 | /// {@macro flutter.widgets.EditableText.getAnchors} |
1395 | /// |
1396 | /// See also: |
1397 | /// |
1398 | /// * [contextMenuButtonItems], which provides the [ContextMenuButtonItem]s |
1399 | /// for the default context menu buttons. |
1400 | TextSelectionToolbarAnchors get contextMenuAnchors { |
1401 | if (_lastSecondaryTapDownPosition != null) { |
1402 | return TextSelectionToolbarAnchors( |
1403 | primaryAnchor: _lastSecondaryTapDownPosition!, |
1404 | ); |
1405 | } |
1406 | final RenderBox renderBox = context.findRenderObject()! as RenderBox; |
1407 | return TextSelectionToolbarAnchors.fromSelection( |
1408 | renderBox: renderBox, |
1409 | startGlyphHeight: startGlyphHeight, |
1410 | endGlyphHeight: endGlyphHeight, |
1411 | selectionEndpoints: selectionEndpoints, |
1412 | ); |
1413 | } |
1414 | |
1415 | bool? _adjustingSelectionEnd; |
1416 | bool _determineIsAdjustingSelectionEnd(bool forward) { |
1417 | if (_adjustingSelectionEnd != null) { |
1418 | return _adjustingSelectionEnd!; |
1419 | } |
1420 | final bool isReversed; |
1421 | final SelectionPoint start = _selectionDelegate.value |
1422 | .startSelectionPoint!; |
1423 | final SelectionPoint end = _selectionDelegate.value.endSelectionPoint!; |
1424 | if (start.localPosition.dy > end.localPosition.dy) { |
1425 | isReversed = true; |
1426 | } else if (start.localPosition.dy < end.localPosition.dy) { |
1427 | isReversed = false; |
1428 | } else { |
1429 | isReversed = start.localPosition.dx > end.localPosition.dx; |
1430 | } |
1431 | // Always move the selection edge that increases the selection range. |
1432 | return _adjustingSelectionEnd = forward != isReversed; |
1433 | } |
1434 | |
1435 | void _granularlyExtendSelection(TextGranularity granularity, bool forward) { |
1436 | _directionalHorizontalBaseline = null; |
1437 | if (!_selectionDelegate.value.hasSelection) { |
1438 | return; |
1439 | } |
1440 | _selectable?.dispatchSelectionEvent( |
1441 | GranularlyExtendSelectionEvent( |
1442 | forward: forward, |
1443 | isEnd: _determineIsAdjustingSelectionEnd(forward), |
1444 | granularity: granularity, |
1445 | ), |
1446 | ); |
1447 | _updateSelectedContentIfNeeded(); |
1448 | } |
1449 | |
1450 | double? _directionalHorizontalBaseline; |
1451 | |
1452 | void _directionallyExtendSelection(bool forward) { |
1453 | if (!_selectionDelegate.value.hasSelection) { |
1454 | return; |
1455 | } |
1456 | final bool adjustingSelectionExtend = _determineIsAdjustingSelectionEnd(forward); |
1457 | final SelectionPoint baseLinePoint = adjustingSelectionExtend |
1458 | ? _selectionDelegate.value.endSelectionPoint! |
1459 | : _selectionDelegate.value.startSelectionPoint!; |
1460 | _directionalHorizontalBaseline ??= baseLinePoint.localPosition.dx; |
1461 | final Offset globalSelectionPointOffset = MatrixUtils.transformPoint(context.findRenderObject()!.getTransformTo(null), Offset(_directionalHorizontalBaseline!, 0)); |
1462 | _selectable?.dispatchSelectionEvent( |
1463 | DirectionallyExtendSelectionEvent( |
1464 | isEnd: _adjustingSelectionEnd!, |
1465 | direction: forward ? SelectionExtendDirection.nextLine : SelectionExtendDirection.previousLine, |
1466 | dx: globalSelectionPointOffset.dx, |
1467 | ), |
1468 | ); |
1469 | _updateSelectedContentIfNeeded(); |
1470 | } |
1471 | |
1472 | // [TextSelectionDelegate] overrides. |
1473 | |
1474 | /// Returns the [ContextMenuButtonItem]s representing the buttons in this |
1475 | /// platform's default selection menu. |
1476 | /// |
1477 | /// See also: |
1478 | /// |
1479 | /// * [SelectableRegion.getSelectableButtonItems], which performs a similar role, |
1480 | /// but for any selectable text, not just specifically SelectableRegion. |
1481 | /// * [EditableTextState.contextMenuButtonItems], which performs a similar role |
1482 | /// but for content that is not just selectable but also editable. |
1483 | /// * [contextMenuAnchors], which provides the anchor points for the default |
1484 | /// context menu. |
1485 | /// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can |
1486 | /// take a list of [ContextMenuButtonItem]s with |
1487 | /// [AdaptiveTextSelectionToolbar.buttonItems]. |
1488 | /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the |
1489 | /// button Widgets for the current platform given [ContextMenuButtonItem]s. |
1490 | List<ContextMenuButtonItem> get contextMenuButtonItems { |
1491 | return SelectableRegion.getSelectableButtonItems( |
1492 | selectionGeometry: _selectionDelegate.value, |
1493 | onCopy: () { |
1494 | _copy(); |
1495 | |
1496 | // On Android copy should clear the selection. |
1497 | switch (defaultTargetPlatform) { |
1498 | case TargetPlatform.android: |
1499 | case TargetPlatform.fuchsia: |
1500 | clearSelection(); |
1501 | case TargetPlatform.iOS: |
1502 | hideToolbar(false); |
1503 | case TargetPlatform.linux: |
1504 | case TargetPlatform.macOS: |
1505 | case TargetPlatform.windows: |
1506 | hideToolbar(); |
1507 | } |
1508 | }, |
1509 | onSelectAll: () { |
1510 | switch (defaultTargetPlatform) { |
1511 | case TargetPlatform.android: |
1512 | case TargetPlatform.iOS: |
1513 | case TargetPlatform.fuchsia: |
1514 | selectAll(SelectionChangedCause.toolbar); |
1515 | case TargetPlatform.linux: |
1516 | case TargetPlatform.macOS: |
1517 | case TargetPlatform.windows: |
1518 | selectAll(); |
1519 | hideToolbar(); |
1520 | } |
1521 | }, |
1522 | onShare: () { |
1523 | _share(); |
1524 | |
1525 | // On Android, share should clear the selection. |
1526 | switch (defaultTargetPlatform) { |
1527 | case TargetPlatform.android: |
1528 | case TargetPlatform.fuchsia: |
1529 | clearSelection(); |
1530 | case TargetPlatform.iOS: |
1531 | hideToolbar(false); |
1532 | case TargetPlatform.linux: |
1533 | case TargetPlatform.macOS: |
1534 | case TargetPlatform.windows: |
1535 | hideToolbar(); |
1536 | } |
1537 | }, |
1538 | )..addAll(_textProcessingActionButtonItems); |
1539 | } |
1540 | |
1541 | List<ContextMenuButtonItem> get _textProcessingActionButtonItems { |
1542 | final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[]; |
1543 | final SelectedContent? data = _selectable?.getSelectedContent(); |
1544 | if (data == null) { |
1545 | return buttonItems; |
1546 | } |
1547 | |
1548 | for (final ProcessTextAction action in _processTextActions) { |
1549 | buttonItems.add(ContextMenuButtonItem( |
1550 | label: action.label, |
1551 | onPressed: () async { |
1552 | final String selectedText = data.plainText; |
1553 | if (selectedText.isNotEmpty) { |
1554 | await _processTextService.processTextAction(action.id, selectedText, true); |
1555 | hideToolbar(); |
1556 | } |
1557 | }, |
1558 | )); |
1559 | } |
1560 | return buttonItems; |
1561 | } |
1562 | |
1563 | /// The line height at the start of the current selection. |
1564 | double get startGlyphHeight { |
1565 | return _selectionDelegate.value.startSelectionPoint!.lineHeight; |
1566 | } |
1567 | |
1568 | /// The line height at the end of the current selection. |
1569 | double get endGlyphHeight { |
1570 | return _selectionDelegate.value.endSelectionPoint!.lineHeight; |
1571 | } |
1572 | |
1573 | /// Returns the local coordinates of the endpoints of the current selection. |
1574 | List<TextSelectionPoint> get selectionEndpoints { |
1575 | final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint; |
1576 | final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint; |
1577 | late List<TextSelectionPoint> points; |
1578 | final Offset startLocalPosition = start?.localPosition ?? end!.localPosition; |
1579 | final Offset endLocalPosition = end?.localPosition ?? start!.localPosition; |
1580 | if (startLocalPosition.dy > endLocalPosition.dy) { |
1581 | points = <TextSelectionPoint>[ |
1582 | TextSelectionPoint(endLocalPosition, TextDirection.ltr), |
1583 | TextSelectionPoint(startLocalPosition, TextDirection.ltr), |
1584 | ]; |
1585 | } else { |
1586 | points = <TextSelectionPoint>[ |
1587 | TextSelectionPoint(startLocalPosition, TextDirection.ltr), |
1588 | TextSelectionPoint(endLocalPosition, TextDirection.ltr), |
1589 | ]; |
1590 | } |
1591 | return points; |
1592 | } |
1593 | |
1594 | // [TextSelectionDelegate] overrides. |
1595 | // TODO(justinmc): After deprecations have been removed, remove |
1596 | // TextSelectionDelegate from this class. |
1597 | // https://github.com/flutter/flutter/issues/111213 |
1598 | |
1599 | @Deprecated( |
1600 | 'Use `contextMenuBuilder` instead. ' |
1601 | 'This feature was deprecated after v3.3.0-0.5.pre.' , |
1602 | ) |
1603 | @override |
1604 | bool get cutEnabled => false; |
1605 | |
1606 | @Deprecated( |
1607 | 'Use `contextMenuBuilder` instead. ' |
1608 | 'This feature was deprecated after v3.3.0-0.5.pre.' , |
1609 | ) |
1610 | @override |
1611 | bool get pasteEnabled => false; |
1612 | |
1613 | @override |
1614 | void hideToolbar([bool hideHandles = true]) { |
1615 | _selectionOverlay?.hideToolbar(); |
1616 | if (hideHandles) { |
1617 | _selectionOverlay?.hideHandles(); |
1618 | } |
1619 | } |
1620 | |
1621 | @override |
1622 | void selectAll([SelectionChangedCause? cause]) { |
1623 | clearSelection(); |
1624 | _selectable?.dispatchSelectionEvent(const SelectAllSelectionEvent()); |
1625 | if (cause == SelectionChangedCause.toolbar) { |
1626 | _showToolbar(); |
1627 | _showHandles(); |
1628 | } |
1629 | _updateSelectedContentIfNeeded(); |
1630 | } |
1631 | |
1632 | @Deprecated( |
1633 | 'Use `contextMenuBuilder` instead. ' |
1634 | 'This feature was deprecated after v3.3.0-0.5.pre.' , |
1635 | ) |
1636 | @override |
1637 | void copySelection(SelectionChangedCause cause) { |
1638 | _copy(); |
1639 | clearSelection(); |
1640 | } |
1641 | |
1642 | @Deprecated( |
1643 | 'Use `contextMenuBuilder` instead. ' |
1644 | 'This feature was deprecated after v3.3.0-0.5.pre.' , |
1645 | ) |
1646 | @override |
1647 | TextEditingValue textEditingValue = const TextEditingValue(text: '_' ); |
1648 | |
1649 | @Deprecated( |
1650 | 'Use `contextMenuBuilder` instead. ' |
1651 | 'This feature was deprecated after v3.3.0-0.5.pre.' , |
1652 | ) |
1653 | @override |
1654 | void bringIntoView(TextPosition position) {/* SelectableRegion must be in view at this point. */} |
1655 | |
1656 | @Deprecated( |
1657 | 'Use `contextMenuBuilder` instead. ' |
1658 | 'This feature was deprecated after v3.3.0-0.5.pre.' , |
1659 | ) |
1660 | @override |
1661 | void cutSelection(SelectionChangedCause cause) { |
1662 | assert(false); |
1663 | } |
1664 | |
1665 | @Deprecated( |
1666 | 'Use `contextMenuBuilder` instead. ' |
1667 | 'This feature was deprecated after v3.3.0-0.5.pre.' , |
1668 | ) |
1669 | @override |
1670 | void userUpdateTextEditingValue(TextEditingValue value, SelectionChangedCause cause) {/* SelectableRegion maintains its own state */} |
1671 | |
1672 | @Deprecated( |
1673 | 'Use `contextMenuBuilder` instead. ' |
1674 | 'This feature was deprecated after v3.3.0-0.5.pre.' , |
1675 | ) |
1676 | @override |
1677 | Future<void> pasteText(SelectionChangedCause cause) async { |
1678 | assert(false); |
1679 | } |
1680 | |
1681 | // [SelectionRegistrar] override. |
1682 | |
1683 | @override |
1684 | void add(Selectable selectable) { |
1685 | assert(_selectable == null); |
1686 | _selectable = selectable; |
1687 | _selectable!.addListener(_updateSelectionStatus); |
1688 | _selectable!.pushHandleLayers(_startHandleLayerLink, _endHandleLayerLink); |
1689 | } |
1690 | |
1691 | @override |
1692 | void remove(Selectable selectable) { |
1693 | assert(_selectable == selectable); |
1694 | _selectable!.removeListener(_updateSelectionStatus); |
1695 | _selectable!.pushHandleLayers(null, null); |
1696 | _selectable = null; |
1697 | } |
1698 | |
1699 | @override |
1700 | void dispose() { |
1701 | _selectable?.removeListener(_updateSelectionStatus); |
1702 | _selectable?.pushHandleLayers(null, null); |
1703 | _selectionDelegate.dispose(); |
1704 | // In case dispose was triggered before gesture end, remove the magnifier |
1705 | // so it doesn't remain stuck in the overlay forever. |
1706 | _selectionOverlay?.hideMagnifier(); |
1707 | _selectionOverlay?.dispose(); |
1708 | _selectionOverlay = null; |
1709 | super.dispose(); |
1710 | } |
1711 | |
1712 | @override |
1713 | Widget build(BuildContext context) { |
1714 | assert(debugCheckHasOverlay(context)); |
1715 | Widget result = SelectionContainer( |
1716 | registrar: this, |
1717 | delegate: _selectionDelegate, |
1718 | child: widget.child, |
1719 | ); |
1720 | if (kIsWeb) { |
1721 | result = PlatformSelectableRegionContextMenu( |
1722 | child: result, |
1723 | ); |
1724 | } |
1725 | return CompositedTransformTarget( |
1726 | link: _toolbarLayerLink, |
1727 | child: RawGestureDetector( |
1728 | gestures: _gestureRecognizers, |
1729 | behavior: HitTestBehavior.translucent, |
1730 | excludeFromSemantics: true, |
1731 | child: Actions( |
1732 | actions: _actions, |
1733 | child: Focus( |
1734 | includeSemantics: false, |
1735 | focusNode: widget.focusNode, |
1736 | child: result, |
1737 | ), |
1738 | ), |
1739 | ), |
1740 | ); |
1741 | } |
1742 | } |
1743 | |
1744 | /// An action that does not override any [Action.overridable] in the subtree. |
1745 | /// |
1746 | /// If this action is invoked by an [Action.overridable], it will immediately |
1747 | /// invoke the [Action.overridable] and do nothing else. Otherwise, it will call |
1748 | /// [invokeAction]. |
1749 | abstract class _NonOverrideAction<T extends Intent> extends ContextAction<T> { |
1750 | Object? invokeAction(T intent, [BuildContext? context]); |
1751 | |
1752 | @override |
1753 | Object? invoke(T intent, [BuildContext? context]) { |
1754 | if (callingAction != null) { |
1755 | return callingAction!.invoke(intent); |
1756 | } |
1757 | return invokeAction(intent, context); |
1758 | } |
1759 | } |
1760 | |
1761 | class _SelectAllAction extends _NonOverrideAction<SelectAllTextIntent> { |
1762 | _SelectAllAction(this.state); |
1763 | |
1764 | final SelectableRegionState state; |
1765 | |
1766 | @override |
1767 | void invokeAction(SelectAllTextIntent intent, [BuildContext? context]) { |
1768 | state.selectAll(SelectionChangedCause.keyboard); |
1769 | } |
1770 | } |
1771 | |
1772 | class _CopySelectionAction extends _NonOverrideAction<CopySelectionTextIntent> { |
1773 | _CopySelectionAction(this.state); |
1774 | |
1775 | final SelectableRegionState state; |
1776 | |
1777 | @override |
1778 | void invokeAction(CopySelectionTextIntent intent, [BuildContext? context]) { |
1779 | state._copy(); |
1780 | } |
1781 | } |
1782 | |
1783 | class _GranularlyExtendSelectionAction<T extends DirectionalTextEditingIntent> extends _NonOverrideAction<T> { |
1784 | _GranularlyExtendSelectionAction(this.state, {required this.granularity}); |
1785 | |
1786 | final SelectableRegionState state; |
1787 | final TextGranularity granularity; |
1788 | |
1789 | @override |
1790 | void invokeAction(T intent, [BuildContext? context]) { |
1791 | state._granularlyExtendSelection(granularity, intent.forward); |
1792 | } |
1793 | } |
1794 | |
1795 | class _GranularlyExtendCaretSelectionAction<T extends DirectionalCaretMovementIntent> extends _NonOverrideAction<T> { |
1796 | _GranularlyExtendCaretSelectionAction(this.state, {required this.granularity}); |
1797 | |
1798 | final SelectableRegionState state; |
1799 | final TextGranularity granularity; |
1800 | |
1801 | @override |
1802 | void invokeAction(T intent, [BuildContext? context]) { |
1803 | if (intent.collapseSelection) { |
1804 | // Selectable region never collapses selection. |
1805 | return; |
1806 | } |
1807 | state._granularlyExtendSelection(granularity, intent.forward); |
1808 | } |
1809 | } |
1810 | |
1811 | class _DirectionallyExtendCaretSelectionAction<T extends DirectionalCaretMovementIntent> extends _NonOverrideAction<T> { |
1812 | _DirectionallyExtendCaretSelectionAction(this.state); |
1813 | |
1814 | final SelectableRegionState state; |
1815 | |
1816 | @override |
1817 | void invokeAction(T intent, [BuildContext? context]) { |
1818 | if (intent.collapseSelection) { |
1819 | // Selectable region never collapses selection. |
1820 | return; |
1821 | } |
1822 | state._directionallyExtendSelection(intent.forward); |
1823 | } |
1824 | } |
1825 | |
1826 | class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContainerDelegate { |
1827 | final Set<Selectable> _hasReceivedStartEvent = <Selectable>{}; |
1828 | final Set<Selectable> _hasReceivedEndEvent = <Selectable>{}; |
1829 | |
1830 | Offset? _lastStartEdgeUpdateGlobalPosition; |
1831 | Offset? _lastEndEdgeUpdateGlobalPosition; |
1832 | |
1833 | @override |
1834 | void remove(Selectable selectable) { |
1835 | _hasReceivedStartEvent.remove(selectable); |
1836 | _hasReceivedEndEvent.remove(selectable); |
1837 | super.remove(selectable); |
1838 | } |
1839 | |
1840 | void _updateLastEdgeEventsFromGeometries() { |
1841 | if (currentSelectionStartIndex != -1 && selectables[currentSelectionStartIndex].value.hasSelection) { |
1842 | final Selectable start = selectables[currentSelectionStartIndex]; |
1843 | final Offset localStartEdge = start.value.startSelectionPoint!.localPosition + |
1844 | Offset(0, - start.value.startSelectionPoint!.lineHeight / 2); |
1845 | _lastStartEdgeUpdateGlobalPosition = MatrixUtils.transformPoint(start.getTransformTo(null), localStartEdge); |
1846 | } |
1847 | if (currentSelectionEndIndex != -1 && selectables[currentSelectionEndIndex].value.hasSelection) { |
1848 | final Selectable end = selectables[currentSelectionEndIndex]; |
1849 | final Offset localEndEdge = end.value.endSelectionPoint!.localPosition + |
1850 | Offset(0, -end.value.endSelectionPoint!.lineHeight / 2); |
1851 | _lastEndEdgeUpdateGlobalPosition = MatrixUtils.transformPoint(end.getTransformTo(null), localEndEdge); |
1852 | } |
1853 | } |
1854 | |
1855 | @override |
1856 | SelectionResult handleSelectAll(SelectAllSelectionEvent event) { |
1857 | final SelectionResult result = super.handleSelectAll(event); |
1858 | for (final Selectable selectable in selectables) { |
1859 | _hasReceivedStartEvent.add(selectable); |
1860 | _hasReceivedEndEvent.add(selectable); |
1861 | } |
1862 | // Synthesize last update event so the edge updates continue to work. |
1863 | _updateLastEdgeEventsFromGeometries(); |
1864 | return result; |
1865 | } |
1866 | |
1867 | /// Selects a word in a [Selectable] at the location |
1868 | /// [SelectWordSelectionEvent.globalPosition]. |
1869 | @override |
1870 | SelectionResult handleSelectWord(SelectWordSelectionEvent event) { |
1871 | final SelectionResult result = super.handleSelectWord(event); |
1872 | if (currentSelectionStartIndex != -1) { |
1873 | _hasReceivedStartEvent.add(selectables[currentSelectionStartIndex]); |
1874 | } |
1875 | if (currentSelectionEndIndex != -1) { |
1876 | _hasReceivedEndEvent.add(selectables[currentSelectionEndIndex]); |
1877 | } |
1878 | _updateLastEdgeEventsFromGeometries(); |
1879 | return result; |
1880 | } |
1881 | |
1882 | /// Selects a paragraph in a [Selectable] at the location |
1883 | /// [SelectParagraphSelectionEvent.globalPosition]. |
1884 | @override |
1885 | SelectionResult handleSelectParagraph(SelectParagraphSelectionEvent event) { |
1886 | final SelectionResult result = super.handleSelectParagraph(event); |
1887 | if (currentSelectionStartIndex != -1) { |
1888 | _hasReceivedStartEvent.add(selectables[currentSelectionStartIndex]); |
1889 | } |
1890 | if (currentSelectionEndIndex != -1) { |
1891 | _hasReceivedEndEvent.add(selectables[currentSelectionEndIndex]); |
1892 | } |
1893 | _updateLastEdgeEventsFromGeometries(); |
1894 | return result; |
1895 | } |
1896 | |
1897 | @override |
1898 | SelectionResult handleClearSelection(ClearSelectionEvent event) { |
1899 | final SelectionResult result = super.handleClearSelection(event); |
1900 | _hasReceivedStartEvent.clear(); |
1901 | _hasReceivedEndEvent.clear(); |
1902 | _lastStartEdgeUpdateGlobalPosition = null; |
1903 | _lastEndEdgeUpdateGlobalPosition = null; |
1904 | return result; |
1905 | } |
1906 | |
1907 | @override |
1908 | SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) { |
1909 | if (event.type == SelectionEventType.endEdgeUpdate) { |
1910 | _lastEndEdgeUpdateGlobalPosition = event.globalPosition; |
1911 | } else { |
1912 | _lastStartEdgeUpdateGlobalPosition = event.globalPosition; |
1913 | } |
1914 | return super.handleSelectionEdgeUpdate(event); |
1915 | } |
1916 | |
1917 | @override |
1918 | void dispose() { |
1919 | _hasReceivedStartEvent.clear(); |
1920 | _hasReceivedEndEvent.clear(); |
1921 | super.dispose(); |
1922 | } |
1923 | |
1924 | @override |
1925 | SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) { |
1926 | switch (event.type) { |
1927 | case SelectionEventType.startEdgeUpdate: |
1928 | _hasReceivedStartEvent.add(selectable); |
1929 | ensureChildUpdated(selectable); |
1930 | case SelectionEventType.endEdgeUpdate: |
1931 | _hasReceivedEndEvent.add(selectable); |
1932 | ensureChildUpdated(selectable); |
1933 | case SelectionEventType.clear: |
1934 | _hasReceivedStartEvent.remove(selectable); |
1935 | _hasReceivedEndEvent.remove(selectable); |
1936 | case SelectionEventType.selectAll: |
1937 | case SelectionEventType.selectWord: |
1938 | case SelectionEventType.selectParagraph: |
1939 | break; |
1940 | case SelectionEventType.granularlyExtendSelection: |
1941 | case SelectionEventType.directionallyExtendSelection: |
1942 | _hasReceivedStartEvent.add(selectable); |
1943 | _hasReceivedEndEvent.add(selectable); |
1944 | ensureChildUpdated(selectable); |
1945 | } |
1946 | return super.dispatchSelectionEventToChild(selectable, event); |
1947 | } |
1948 | |
1949 | @override |
1950 | void ensureChildUpdated(Selectable selectable) { |
1951 | if (_lastEndEdgeUpdateGlobalPosition != null && _hasReceivedEndEvent.add(selectable)) { |
1952 | final SelectionEdgeUpdateEvent synthesizedEvent = SelectionEdgeUpdateEvent.forEnd( |
1953 | globalPosition: _lastEndEdgeUpdateGlobalPosition!, |
1954 | ); |
1955 | if (currentSelectionEndIndex == -1) { |
1956 | handleSelectionEdgeUpdate(synthesizedEvent); |
1957 | } |
1958 | selectable.dispatchSelectionEvent(synthesizedEvent); |
1959 | } |
1960 | if (_lastStartEdgeUpdateGlobalPosition != null && _hasReceivedStartEvent.add(selectable)) { |
1961 | final SelectionEdgeUpdateEvent synthesizedEvent = SelectionEdgeUpdateEvent.forStart( |
1962 | globalPosition: _lastStartEdgeUpdateGlobalPosition!, |
1963 | ); |
1964 | if (currentSelectionStartIndex == -1) { |
1965 | handleSelectionEdgeUpdate(synthesizedEvent); |
1966 | } |
1967 | selectable.dispatchSelectionEvent(synthesizedEvent); |
1968 | } |
1969 | } |
1970 | |
1971 | @override |
1972 | void didChangeSelectables() { |
1973 | if (_lastEndEdgeUpdateGlobalPosition != null) { |
1974 | handleSelectionEdgeUpdate( |
1975 | SelectionEdgeUpdateEvent.forEnd( |
1976 | globalPosition: _lastEndEdgeUpdateGlobalPosition!, |
1977 | ), |
1978 | ); |
1979 | } |
1980 | if (_lastStartEdgeUpdateGlobalPosition != null) { |
1981 | handleSelectionEdgeUpdate( |
1982 | SelectionEdgeUpdateEvent.forStart( |
1983 | globalPosition: _lastStartEdgeUpdateGlobalPosition!, |
1984 | ), |
1985 | ); |
1986 | } |
1987 | final Set<Selectable> selectableSet = selectables.toSet(); |
1988 | _hasReceivedEndEvent.removeWhere((Selectable selectable) => !selectableSet.contains(selectable)); |
1989 | _hasReceivedStartEvent.removeWhere((Selectable selectable) => !selectableSet.contains(selectable)); |
1990 | super.didChangeSelectables(); |
1991 | } |
1992 | } |
1993 | |
1994 | /// A delegate that handles events and updates for multiple [Selectable] |
1995 | /// children. |
1996 | /// |
1997 | /// Updates are optimized by tracking which [Selectable]s reside on the edges of |
1998 | /// a selection. Subclasses should implement [ensureChildUpdated] to describe |
1999 | /// how a [Selectable] should behave when added to a selection. |
2000 | abstract class MultiSelectableSelectionContainerDelegate extends SelectionContainerDelegate with ChangeNotifier { |
2001 | /// Creates an instance of [MultiSelectableSelectionContainerDelegate]. |
2002 | MultiSelectableSelectionContainerDelegate() { |
2003 | if (kFlutterMemoryAllocationsEnabled) { |
2004 | ChangeNotifier.maybeDispatchObjectCreation(this); |
2005 | } |
2006 | } |
2007 | |
2008 | /// Gets the list of [Selectable]s this delegate is managing. |
2009 | List<Selectable> selectables = <Selectable>[]; |
2010 | |
2011 | /// The number of additional pixels added to the selection handle drawable |
2012 | /// area. |
2013 | /// |
2014 | /// Selection handles that are outside of the drawable area will be hidden. |
2015 | /// That logic prevents handles that get scrolled off the viewport from being |
2016 | /// drawn on the screen. |
2017 | /// |
2018 | /// The drawable area = current rectangle of [SelectionContainer] + |
2019 | /// _kSelectionHandleDrawableAreaPadding on each side. |
2020 | /// |
2021 | /// This was an eyeballed value to create smooth user experiences. |
2022 | static const double _kSelectionHandleDrawableAreaPadding = 5.0; |
2023 | |
2024 | /// The current [Selectable] that contains the selection end edge. |
2025 | @protected |
2026 | int currentSelectionEndIndex = -1; |
2027 | |
2028 | /// The current [Selectable] that contains the selection start edge. |
2029 | @protected |
2030 | int currentSelectionStartIndex = -1; |
2031 | |
2032 | LayerLink? _startHandleLayer; |
2033 | Selectable? _startHandleLayerOwner; |
2034 | LayerLink? _endHandleLayer; |
2035 | Selectable? _endHandleLayerOwner; |
2036 | |
2037 | bool _isHandlingSelectionEvent = false; |
2038 | bool _scheduledSelectableUpdate = false; |
2039 | bool _selectionInProgress = false; |
2040 | Set<Selectable> _additions = <Selectable>{}; |
2041 | |
2042 | bool _extendSelectionInProgress = false; |
2043 | |
2044 | @override |
2045 | void add(Selectable selectable) { |
2046 | assert(!selectables.contains(selectable)); |
2047 | _additions.add(selectable); |
2048 | _scheduleSelectableUpdate(); |
2049 | } |
2050 | |
2051 | @override |
2052 | void remove(Selectable selectable) { |
2053 | if (_additions.remove(selectable)) { |
2054 | // The same selectable was added in the same frame and is not yet |
2055 | // incorporated into the selectables. |
2056 | // |
2057 | // Removing such selectable doesn't require selection geometry update. |
2058 | return; |
2059 | } |
2060 | _removeSelectable(selectable); |
2061 | _scheduleSelectableUpdate(); |
2062 | } |
2063 | |
2064 | /// Notifies this delegate that layout of the container has changed. |
2065 | void layoutDidChange() { |
2066 | _updateSelectionGeometry(); |
2067 | } |
2068 | |
2069 | void _scheduleSelectableUpdate() { |
2070 | if (!_scheduledSelectableUpdate) { |
2071 | _scheduledSelectableUpdate = true; |
2072 | void runScheduledTask([Duration? duration]) { |
2073 | if (!_scheduledSelectableUpdate) { |
2074 | return; |
2075 | } |
2076 | _scheduledSelectableUpdate = false; |
2077 | _updateSelectables(); |
2078 | } |
2079 | |
2080 | if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.postFrameCallbacks) { |
2081 | // A new task can be scheduled as a result of running the scheduled task |
2082 | // from another MultiSelectableSelectionContainerDelegate. This can |
2083 | // happen if nesting two SelectionContainers. The selectable can be |
2084 | // safely updated in the same frame in this case. |
2085 | scheduleMicrotask(runScheduledTask); |
2086 | } else { |
2087 | SchedulerBinding.instance.addPostFrameCallback( |
2088 | runScheduledTask, |
2089 | debugLabel: 'SelectionContainer.runScheduledTask' , |
2090 | ); |
2091 | } |
2092 | } |
2093 | } |
2094 | |
2095 | void _updateSelectables() { |
2096 | // Remove offScreen selectable. |
2097 | if (_additions.isNotEmpty) { |
2098 | _flushAdditions(); |
2099 | } |
2100 | didChangeSelectables(); |
2101 | } |
2102 | |
2103 | void _flushAdditions() { |
2104 | final List<Selectable> mergingSelectables = _additions.toList()..sort(compareOrder); |
2105 | final List<Selectable> existingSelectables = selectables; |
2106 | selectables = <Selectable>[]; |
2107 | int mergingIndex = 0; |
2108 | int existingIndex = 0; |
2109 | int selectionStartIndex = currentSelectionStartIndex; |
2110 | int selectionEndIndex = currentSelectionEndIndex; |
2111 | // Merge two sorted lists. |
2112 | while (mergingIndex < mergingSelectables.length || existingIndex < existingSelectables.length) { |
2113 | if (mergingIndex >= mergingSelectables.length || |
2114 | (existingIndex < existingSelectables.length && |
2115 | compareOrder(existingSelectables[existingIndex], mergingSelectables[mergingIndex]) < 0)) { |
2116 | if (existingIndex == currentSelectionStartIndex) { |
2117 | selectionStartIndex = selectables.length; |
2118 | } |
2119 | if (existingIndex == currentSelectionEndIndex) { |
2120 | selectionEndIndex = selectables.length; |
2121 | } |
2122 | selectables.add(existingSelectables[existingIndex]); |
2123 | existingIndex += 1; |
2124 | continue; |
2125 | } |
2126 | |
2127 | // If the merging selectable falls in the selection range, their selection |
2128 | // needs to be updated. |
2129 | final Selectable mergingSelectable = mergingSelectables[mergingIndex]; |
2130 | if (existingIndex < max(currentSelectionStartIndex, currentSelectionEndIndex) && |
2131 | existingIndex > min(currentSelectionStartIndex, currentSelectionEndIndex)) { |
2132 | ensureChildUpdated(mergingSelectable); |
2133 | } |
2134 | mergingSelectable.addListener(_handleSelectableGeometryChange); |
2135 | selectables.add(mergingSelectable); |
2136 | mergingIndex += 1; |
2137 | } |
2138 | assert(mergingIndex == mergingSelectables.length && |
2139 | existingIndex == existingSelectables.length && |
2140 | selectables.length == existingIndex + mergingIndex); |
2141 | assert(selectionStartIndex >= -1 || selectionStartIndex < selectables.length); |
2142 | assert(selectionEndIndex >= -1 || selectionEndIndex < selectables.length); |
2143 | // selection indices should not be set to -1 unless they originally were. |
2144 | assert((currentSelectionStartIndex == -1) == (selectionStartIndex == -1)); |
2145 | assert((currentSelectionEndIndex == -1) == (selectionEndIndex == -1)); |
2146 | currentSelectionEndIndex = selectionEndIndex; |
2147 | currentSelectionStartIndex = selectionStartIndex; |
2148 | _additions = <Selectable>{}; |
2149 | } |
2150 | |
2151 | void _removeSelectable(Selectable selectable) { |
2152 | assert(selectables.contains(selectable), 'The selectable is not in this registrar.' ); |
2153 | final int index = selectables.indexOf(selectable); |
2154 | selectables.removeAt(index); |
2155 | if (index <= currentSelectionEndIndex) { |
2156 | currentSelectionEndIndex -= 1; |
2157 | } |
2158 | if (index <= currentSelectionStartIndex) { |
2159 | currentSelectionStartIndex -= 1; |
2160 | } |
2161 | selectable.removeListener(_handleSelectableGeometryChange); |
2162 | } |
2163 | |
2164 | /// Called when this delegate finishes updating the [Selectable]s. |
2165 | @protected |
2166 | @mustCallSuper |
2167 | void didChangeSelectables() { |
2168 | _updateSelectionGeometry(); |
2169 | } |
2170 | |
2171 | @override |
2172 | SelectionGeometry get value => _selectionGeometry; |
2173 | SelectionGeometry _selectionGeometry = const SelectionGeometry( |
2174 | hasContent: false, |
2175 | status: SelectionStatus.none, |
2176 | ); |
2177 | |
2178 | /// Updates the [value] in this class and notifies listeners if necessary. |
2179 | void _updateSelectionGeometry() { |
2180 | final SelectionGeometry newValue = getSelectionGeometry(); |
2181 | if (_selectionGeometry != newValue) { |
2182 | _selectionGeometry = newValue; |
2183 | notifyListeners(); |
2184 | } |
2185 | _updateHandleLayersAndOwners(); |
2186 | } |
2187 | |
2188 | static Rect _getBoundingBox(Selectable selectable) { |
2189 | Rect result = selectable.boundingBoxes.first; |
2190 | for (int index = 1; index < selectable.boundingBoxes.length; index += 1) { |
2191 | result = result.expandToInclude(selectable.boundingBoxes[index]); |
2192 | } |
2193 | return result; |
2194 | } |
2195 | |
2196 | /// The compare function this delegate used for determining the selection |
2197 | /// order of the selectables. |
2198 | /// |
2199 | /// Defaults to screen order. |
2200 | @protected |
2201 | Comparator<Selectable> get compareOrder => _compareScreenOrder; |
2202 | |
2203 | static int _compareScreenOrder(Selectable a, Selectable b) { |
2204 | final Rect rectA = MatrixUtils.transformRect( |
2205 | a.getTransformTo(null), |
2206 | _getBoundingBox(a), |
2207 | ); |
2208 | final Rect rectB = MatrixUtils.transformRect( |
2209 | b.getTransformTo(null), |
2210 | _getBoundingBox(b), |
2211 | ); |
2212 | final int result = _compareVertically(rectA, rectB); |
2213 | if (result != 0) { |
2214 | return result; |
2215 | } |
2216 | return _compareHorizontally(rectA, rectB); |
2217 | } |
2218 | |
2219 | /// Compares two rectangles in the screen order solely by their vertical |
2220 | /// positions. |
2221 | /// |
2222 | /// Returns positive if a is lower, negative if a is higher, 0 if their |
2223 | /// order can't be determine solely by their vertical position. |
2224 | static int _compareVertically(Rect a, Rect b) { |
2225 | // The rectangles overlap so defer to horizontal comparison. |
2226 | if ((a.top - b.top < _kSelectableVerticalComparingThreshold && a.bottom - b.bottom > - _kSelectableVerticalComparingThreshold) || |
2227 | (b.top - a.top < _kSelectableVerticalComparingThreshold && b.bottom - a.bottom > - _kSelectableVerticalComparingThreshold)) { |
2228 | return 0; |
2229 | } |
2230 | if ((a.top - b.top).abs() > _kSelectableVerticalComparingThreshold) { |
2231 | return a.top > b.top ? 1 : -1; |
2232 | } |
2233 | return a.bottom > b.bottom ? 1 : -1; |
2234 | } |
2235 | |
2236 | /// Compares two rectangles in the screen order by their horizontal positions |
2237 | /// assuming one of the rectangles enclose the other rect vertically. |
2238 | /// |
2239 | /// Returns positive if a is lower, negative if a is higher. |
2240 | static int _compareHorizontally(Rect a, Rect b) { |
2241 | // a encloses b. |
2242 | if (a.left - b.left < precisionErrorTolerance && a.right - b.right > - precisionErrorTolerance) { |
2243 | return -1; |
2244 | } |
2245 | // b encloses a. |
2246 | if (b.left - a.left < precisionErrorTolerance && b.right - a.right > - precisionErrorTolerance) { |
2247 | return 1; |
2248 | } |
2249 | if ((a.left - b.left).abs() > precisionErrorTolerance) { |
2250 | return a.left > b.left ? 1 : -1; |
2251 | } |
2252 | return a.right > b.right ? 1 : -1; |
2253 | } |
2254 | |
2255 | void _handleSelectableGeometryChange() { |
2256 | // Geometries of selectable children may change multiple times when handling |
2257 | // selection events. Ignore these updates since the selection geometry of |
2258 | // this delegate will be updated after handling the selection events. |
2259 | if (_isHandlingSelectionEvent) { |
2260 | return; |
2261 | } |
2262 | _updateSelectionGeometry(); |
2263 | } |
2264 | |
2265 | /// Gets the combined [SelectionGeometry] for child [Selectable]s. |
2266 | @protected |
2267 | SelectionGeometry getSelectionGeometry() { |
2268 | if (currentSelectionEndIndex == -1 || |
2269 | currentSelectionStartIndex == -1 || |
2270 | selectables.isEmpty) { |
2271 | // There is no valid selection. |
2272 | return SelectionGeometry( |
2273 | status: SelectionStatus.none, |
2274 | hasContent: selectables.isNotEmpty, |
2275 | ); |
2276 | } |
2277 | |
2278 | if (!_extendSelectionInProgress) { |
2279 | currentSelectionStartIndex = _adjustSelectionIndexBasedOnSelectionGeometry( |
2280 | currentSelectionStartIndex, |
2281 | currentSelectionEndIndex, |
2282 | ); |
2283 | currentSelectionEndIndex = _adjustSelectionIndexBasedOnSelectionGeometry( |
2284 | currentSelectionEndIndex, |
2285 | currentSelectionStartIndex, |
2286 | ); |
2287 | } |
2288 | |
2289 | // Need to find the non-null start selection point. |
2290 | SelectionGeometry startGeometry = selectables[currentSelectionStartIndex].value; |
2291 | final bool forwardSelection = currentSelectionEndIndex >= currentSelectionStartIndex; |
2292 | int startIndexWalker = currentSelectionStartIndex; |
2293 | while (startIndexWalker != currentSelectionEndIndex && startGeometry.startSelectionPoint == null) { |
2294 | startIndexWalker += forwardSelection ? 1 : -1; |
2295 | startGeometry = selectables[startIndexWalker].value; |
2296 | } |
2297 | |
2298 | SelectionPoint? startPoint; |
2299 | if (startGeometry.startSelectionPoint != null) { |
2300 | final Matrix4 startTransform = getTransformFrom(selectables[startIndexWalker]); |
2301 | final Offset start = MatrixUtils.transformPoint(startTransform, startGeometry.startSelectionPoint!.localPosition); |
2302 | // It can be NaN if it is detached or off-screen. |
2303 | if (start.isFinite) { |
2304 | startPoint = SelectionPoint( |
2305 | localPosition: start, |
2306 | lineHeight: startGeometry.startSelectionPoint!.lineHeight, |
2307 | handleType: startGeometry.startSelectionPoint!.handleType, |
2308 | ); |
2309 | } |
2310 | } |
2311 | |
2312 | // Need to find the non-null end selection point. |
2313 | SelectionGeometry endGeometry = selectables[currentSelectionEndIndex].value; |
2314 | int endIndexWalker = currentSelectionEndIndex; |
2315 | while (endIndexWalker != currentSelectionStartIndex && endGeometry.endSelectionPoint == null) { |
2316 | endIndexWalker += forwardSelection ? -1 : 1; |
2317 | endGeometry = selectables[endIndexWalker].value; |
2318 | } |
2319 | SelectionPoint? endPoint; |
2320 | if (endGeometry.endSelectionPoint != null) { |
2321 | final Matrix4 endTransform = getTransformFrom(selectables[endIndexWalker]); |
2322 | final Offset end = MatrixUtils.transformPoint(endTransform, endGeometry.endSelectionPoint!.localPosition); |
2323 | // It can be NaN if it is detached or off-screen. |
2324 | if (end.isFinite) { |
2325 | endPoint = SelectionPoint( |
2326 | localPosition: end, |
2327 | lineHeight: endGeometry.endSelectionPoint!.lineHeight, |
2328 | handleType: endGeometry.endSelectionPoint!.handleType, |
2329 | ); |
2330 | } |
2331 | } |
2332 | |
2333 | // Need to collect selection rects from selectables ranging from the |
2334 | // currentSelectionStartIndex to the currentSelectionEndIndex. |
2335 | final List<Rect> selectionRects = <Rect>[]; |
2336 | final Rect? drawableArea = hasSize ? Rect |
2337 | .fromLTWH(0, 0, containerSize.width, containerSize.height) : null; |
2338 | for (int index = currentSelectionStartIndex; index <= currentSelectionEndIndex; index++) { |
2339 | final List<Rect> currSelectableSelectionRects = selectables[index].value.selectionRects; |
2340 | final List<Rect> selectionRectsWithinDrawableArea = currSelectableSelectionRects.map((Rect selectionRect) { |
2341 | final Matrix4 transform = getTransformFrom(selectables[index]); |
2342 | final Rect localRect = MatrixUtils.transformRect(transform, selectionRect); |
2343 | return drawableArea?.intersect(localRect) ?? localRect; |
2344 | }).where((Rect selectionRect) { |
2345 | return selectionRect.isFinite && !selectionRect.isEmpty; |
2346 | }).toList(); |
2347 | selectionRects.addAll(selectionRectsWithinDrawableArea); |
2348 | } |
2349 | |
2350 | return SelectionGeometry( |
2351 | startSelectionPoint: startPoint, |
2352 | endSelectionPoint: endPoint, |
2353 | selectionRects: selectionRects, |
2354 | status: startGeometry != endGeometry |
2355 | ? SelectionStatus.uncollapsed |
2356 | : startGeometry.status, |
2357 | // Would have at least one selectable child. |
2358 | hasContent: true, |
2359 | ); |
2360 | } |
2361 | |
2362 | // The currentSelectionStartIndex or currentSelectionEndIndex may not be |
2363 | // the current index that contains selection edges. This can happen if the |
2364 | // selection edge is in between two selectables. One of the selectable will |
2365 | // have its selection collapsed at the index 0 or contentLength depends on |
2366 | // whether the selection is reversed or not. The current selection index can |
2367 | // be point to either one. |
2368 | // |
2369 | // This method adjusts the index to point to selectable with valid selection. |
2370 | int _adjustSelectionIndexBasedOnSelectionGeometry(int currentIndex, int towardIndex) { |
2371 | final bool forward = towardIndex > currentIndex; |
2372 | while (currentIndex != towardIndex && |
2373 | selectables[currentIndex].value.status != SelectionStatus.uncollapsed) { |
2374 | currentIndex += forward ? 1 : -1; |
2375 | } |
2376 | return currentIndex; |
2377 | } |
2378 | |
2379 | @override |
2380 | void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { |
2381 | if (_startHandleLayer == startHandle && _endHandleLayer == endHandle) { |
2382 | return; |
2383 | } |
2384 | _startHandleLayer = startHandle; |
2385 | _endHandleLayer = endHandle; |
2386 | _updateHandleLayersAndOwners(); |
2387 | } |
2388 | |
2389 | /// Pushes both handle layers to the selectables that contain selection edges. |
2390 | /// |
2391 | /// This method needs to be called every time the selectables that contain the |
2392 | /// selection edges change, i.e. [currentSelectionStartIndex] or |
2393 | /// [currentSelectionEndIndex] changes. Otherwise, the handle may be painted |
2394 | /// in the wrong place. |
2395 | void _updateHandleLayersAndOwners() { |
2396 | LayerLink? effectiveStartHandle = _startHandleLayer; |
2397 | LayerLink? effectiveEndHandle = _endHandleLayer; |
2398 | if (effectiveStartHandle != null || effectiveEndHandle != null) { |
2399 | final Rect? drawableArea = hasSize ? Rect |
2400 | .fromLTWH(0, 0, containerSize.width, containerSize.height) |
2401 | .inflate(_kSelectionHandleDrawableAreaPadding) : null; |
2402 | final bool hideStartHandle = value.startSelectionPoint == null || drawableArea == null || !drawableArea.contains(value.startSelectionPoint!.localPosition); |
2403 | final bool hideEndHandle = value.endSelectionPoint == null || drawableArea == null|| !drawableArea.contains(value.endSelectionPoint!.localPosition); |
2404 | effectiveStartHandle = hideStartHandle ? null : _startHandleLayer; |
2405 | effectiveEndHandle = hideEndHandle ? null : _endHandleLayer; |
2406 | } |
2407 | if (currentSelectionStartIndex == -1 || currentSelectionEndIndex == -1) { |
2408 | // No valid selection. |
2409 | if (_startHandleLayerOwner != null) { |
2410 | _startHandleLayerOwner!.pushHandleLayers(null, null); |
2411 | _startHandleLayerOwner = null; |
2412 | } |
2413 | if (_endHandleLayerOwner != null) { |
2414 | _endHandleLayerOwner!.pushHandleLayers(null, null); |
2415 | _endHandleLayerOwner = null; |
2416 | } |
2417 | return; |
2418 | } |
2419 | |
2420 | if (selectables[currentSelectionStartIndex] != _startHandleLayerOwner) { |
2421 | _startHandleLayerOwner?.pushHandleLayers(null, null); |
2422 | } |
2423 | if (selectables[currentSelectionEndIndex] != _endHandleLayerOwner) { |
2424 | _endHandleLayerOwner?.pushHandleLayers(null, null); |
2425 | } |
2426 | |
2427 | _startHandleLayerOwner = selectables[currentSelectionStartIndex]; |
2428 | |
2429 | if (currentSelectionStartIndex == currentSelectionEndIndex) { |
2430 | // Selection edges is on the same selectable. |
2431 | _endHandleLayerOwner = _startHandleLayerOwner; |
2432 | _startHandleLayerOwner!.pushHandleLayers(effectiveStartHandle, effectiveEndHandle); |
2433 | return; |
2434 | } |
2435 | |
2436 | _startHandleLayerOwner!.pushHandleLayers(effectiveStartHandle, null); |
2437 | _endHandleLayerOwner = selectables[currentSelectionEndIndex]; |
2438 | _endHandleLayerOwner!.pushHandleLayers(null, effectiveEndHandle); |
2439 | } |
2440 | |
2441 | /// Copies the selected contents of all [Selectable]s. |
2442 | @override |
2443 | SelectedContent? getSelectedContent() { |
2444 | final List<SelectedContent> selections = <SelectedContent>[ |
2445 | for (final Selectable selectable in selectables) |
2446 | if (selectable.getSelectedContent() case final SelectedContent data) data, |
2447 | ]; |
2448 | if (selections.isEmpty) { |
2449 | return null; |
2450 | } |
2451 | final StringBuffer buffer = StringBuffer(); |
2452 | for (final SelectedContent selection in selections) { |
2453 | buffer.write(selection.plainText); |
2454 | } |
2455 | return SelectedContent( |
2456 | plainText: buffer.toString(), |
2457 | ); |
2458 | } |
2459 | |
2460 | // Clears the selection on all selectables not in the range of |
2461 | // currentSelectionStartIndex..currentSelectionEndIndex. |
2462 | // |
2463 | // If one of the edges does not exist, then this method will clear the selection |
2464 | // in all selectables except the existing edge. |
2465 | // |
2466 | // If neither of the edges exist this method immediately returns. |
2467 | void _flushInactiveSelections() { |
2468 | if (currentSelectionStartIndex == -1 && currentSelectionEndIndex == -1) { |
2469 | return; |
2470 | } |
2471 | if (currentSelectionStartIndex == -1 || currentSelectionEndIndex == -1) { |
2472 | final int skipIndex = currentSelectionStartIndex == -1 ? currentSelectionEndIndex : currentSelectionStartIndex; |
2473 | selectables |
2474 | .where((Selectable target) => target != selectables[skipIndex]) |
2475 | .forEach((Selectable target) => dispatchSelectionEventToChild(target, const ClearSelectionEvent())); |
2476 | return; |
2477 | } |
2478 | final int skipStart = min(currentSelectionStartIndex, currentSelectionEndIndex); |
2479 | final int skipEnd = max(currentSelectionStartIndex, currentSelectionEndIndex); |
2480 | for (int index = 0; index < selectables.length; index += 1) { |
2481 | if (index >= skipStart && index <= skipEnd) { |
2482 | continue; |
2483 | } |
2484 | dispatchSelectionEventToChild(selectables[index], const ClearSelectionEvent()); |
2485 | } |
2486 | } |
2487 | |
2488 | /// Selects all contents of all [Selectable]s. |
2489 | @protected |
2490 | SelectionResult handleSelectAll(SelectAllSelectionEvent event) { |
2491 | for (final Selectable selectable in selectables) { |
2492 | dispatchSelectionEventToChild(selectable, event); |
2493 | } |
2494 | currentSelectionStartIndex = 0; |
2495 | currentSelectionEndIndex = selectables.length - 1; |
2496 | return SelectionResult.none; |
2497 | } |
2498 | |
2499 | SelectionResult _handleSelectBoundary(SelectionEvent event) { |
2500 | assert(event is SelectWordSelectionEvent || event is SelectParagraphSelectionEvent, 'This method should only be given selection events that select text boundaries.' ); |
2501 | late final Offset effectiveGlobalPosition; |
2502 | if (event.type == SelectionEventType.selectWord) { |
2503 | effectiveGlobalPosition = (event as SelectWordSelectionEvent).globalPosition; |
2504 | } else if (event.type == SelectionEventType.selectParagraph) { |
2505 | effectiveGlobalPosition = (event as SelectParagraphSelectionEvent).globalPosition; |
2506 | } |
2507 | SelectionResult? lastSelectionResult; |
2508 | for (int index = 0; index < selectables.length; index += 1) { |
2509 | bool globalRectsContainPosition = false; |
2510 | if (selectables[index].boundingBoxes.isNotEmpty) { |
2511 | for (final Rect rect in selectables[index].boundingBoxes) { |
2512 | final Rect globalRect = MatrixUtils.transformRect(selectables[index].getTransformTo(null), rect); |
2513 | if (globalRect.contains(effectiveGlobalPosition)) { |
2514 | globalRectsContainPosition = true; |
2515 | break; |
2516 | } |
2517 | } |
2518 | } |
2519 | if (globalRectsContainPosition) { |
2520 | final SelectionGeometry existingGeometry = selectables[index].value; |
2521 | lastSelectionResult = dispatchSelectionEventToChild(selectables[index], event); |
2522 | if (index == selectables.length - 1 && lastSelectionResult == SelectionResult.next) { |
2523 | return SelectionResult.next; |
2524 | } |
2525 | if (lastSelectionResult == SelectionResult.next) { |
2526 | continue; |
2527 | } |
2528 | if (index == 0 && lastSelectionResult == SelectionResult.previous) { |
2529 | return SelectionResult.previous; |
2530 | } |
2531 | if (selectables[index].value != existingGeometry) { |
2532 | // Geometry has changed as a result of select word, need to clear the |
2533 | // selection of other selectables to keep selection in sync. |
2534 | selectables |
2535 | .where((Selectable target) => target != selectables[index]) |
2536 | .forEach((Selectable target) => dispatchSelectionEventToChild(target, const ClearSelectionEvent())); |
2537 | currentSelectionStartIndex = currentSelectionEndIndex = index; |
2538 | } |
2539 | return SelectionResult.end; |
2540 | } else { |
2541 | if (lastSelectionResult == SelectionResult.next) { |
2542 | currentSelectionStartIndex = currentSelectionEndIndex = index - 1; |
2543 | return SelectionResult.end; |
2544 | } |
2545 | } |
2546 | } |
2547 | assert(lastSelectionResult == null); |
2548 | return SelectionResult.end; |
2549 | } |
2550 | |
2551 | /// Selects a word in a [Selectable] at the location |
2552 | /// [SelectWordSelectionEvent.globalPosition]. |
2553 | @protected |
2554 | SelectionResult handleSelectWord(SelectWordSelectionEvent event) { |
2555 | return _handleSelectBoundary(event); |
2556 | } |
2557 | |
2558 | /// Selects a paragraph in a [Selectable] at the location |
2559 | /// [SelectParagraphSelectionEvent.globalPosition]. |
2560 | @protected |
2561 | SelectionResult handleSelectParagraph(SelectParagraphSelectionEvent event) { |
2562 | return _handleSelectBoundary(event); |
2563 | } |
2564 | |
2565 | /// Removes the selection of all [Selectable]s this delegate manages. |
2566 | @protected |
2567 | SelectionResult handleClearSelection(ClearSelectionEvent event) { |
2568 | for (final Selectable selectable in selectables) { |
2569 | dispatchSelectionEventToChild(selectable, event); |
2570 | } |
2571 | currentSelectionEndIndex = -1; |
2572 | currentSelectionStartIndex = -1; |
2573 | return SelectionResult.none; |
2574 | } |
2575 | |
2576 | /// Extend current selection in a certain [TextGranularity]. |
2577 | @protected |
2578 | SelectionResult handleGranularlyExtendSelection(GranularlyExtendSelectionEvent event) { |
2579 | assert((currentSelectionStartIndex == -1) == (currentSelectionEndIndex == -1)); |
2580 | if (currentSelectionStartIndex == -1) { |
2581 | if (event.forward) { |
2582 | currentSelectionStartIndex = currentSelectionEndIndex = 0; |
2583 | } else { |
2584 | currentSelectionStartIndex = currentSelectionEndIndex = selectables.length; |
2585 | } |
2586 | } |
2587 | int targetIndex = event.isEnd ? currentSelectionEndIndex : currentSelectionStartIndex; |
2588 | SelectionResult result = dispatchSelectionEventToChild(selectables[targetIndex], event); |
2589 | if (event.forward) { |
2590 | assert(result != SelectionResult.previous); |
2591 | while (targetIndex < selectables.length - 1 && result == SelectionResult.next) { |
2592 | targetIndex += 1; |
2593 | result = dispatchSelectionEventToChild(selectables[targetIndex], event); |
2594 | assert(result != SelectionResult.previous); |
2595 | } |
2596 | } else { |
2597 | assert(result != SelectionResult.next); |
2598 | while (targetIndex > 0 && result == SelectionResult.previous) { |
2599 | targetIndex -= 1; |
2600 | result = dispatchSelectionEventToChild(selectables[targetIndex], event); |
2601 | assert(result != SelectionResult.next); |
2602 | } |
2603 | } |
2604 | if (event.isEnd) { |
2605 | currentSelectionEndIndex = targetIndex; |
2606 | } else { |
2607 | currentSelectionStartIndex = targetIndex; |
2608 | } |
2609 | return result; |
2610 | } |
2611 | |
2612 | /// Extend current selection in a certain [TextGranularity]. |
2613 | @protected |
2614 | SelectionResult handleDirectionallyExtendSelection(DirectionallyExtendSelectionEvent event) { |
2615 | assert((currentSelectionStartIndex == -1) == (currentSelectionEndIndex == -1)); |
2616 | if (currentSelectionStartIndex == -1) { |
2617 | currentSelectionStartIndex = currentSelectionEndIndex = switch (event.direction) { |
2618 | SelectionExtendDirection.previousLine || SelectionExtendDirection.backward => selectables.length - 1, |
2619 | SelectionExtendDirection.nextLine || SelectionExtendDirection.forward => 0, |
2620 | }; |
2621 | } |
2622 | int targetIndex = event.isEnd ? currentSelectionEndIndex : currentSelectionStartIndex; |
2623 | SelectionResult result = dispatchSelectionEventToChild(selectables[targetIndex], event); |
2624 | switch (event.direction) { |
2625 | case SelectionExtendDirection.previousLine: |
2626 | assert(result == SelectionResult.end || result == SelectionResult.previous); |
2627 | if (result == SelectionResult.previous) { |
2628 | if (targetIndex > 0) { |
2629 | targetIndex -= 1; |
2630 | result = dispatchSelectionEventToChild( |
2631 | selectables[targetIndex], |
2632 | event.copyWith(direction: SelectionExtendDirection.backward), |
2633 | ); |
2634 | assert(result == SelectionResult.end); |
2635 | } |
2636 | } |
2637 | case SelectionExtendDirection.nextLine: |
2638 | assert(result == SelectionResult.end || result == SelectionResult.next); |
2639 | if (result == SelectionResult.next) { |
2640 | if (targetIndex < selectables.length - 1) { |
2641 | targetIndex += 1; |
2642 | result = dispatchSelectionEventToChild( |
2643 | selectables[targetIndex], |
2644 | event.copyWith(direction: SelectionExtendDirection.forward), |
2645 | ); |
2646 | assert(result == SelectionResult.end); |
2647 | } |
2648 | } |
2649 | case SelectionExtendDirection.forward: |
2650 | case SelectionExtendDirection.backward: |
2651 | assert(result == SelectionResult.end); |
2652 | } |
2653 | if (event.isEnd) { |
2654 | currentSelectionEndIndex = targetIndex; |
2655 | } else { |
2656 | currentSelectionStartIndex = targetIndex; |
2657 | } |
2658 | return result; |
2659 | } |
2660 | |
2661 | /// Updates the selection edges. |
2662 | @protected |
2663 | SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) { |
2664 | if (event.type == SelectionEventType.endEdgeUpdate) { |
2665 | return currentSelectionEndIndex == -1 ? _initSelection(event, isEnd: true) : _adjustSelection(event, isEnd: true); |
2666 | } |
2667 | return currentSelectionStartIndex == -1 ? _initSelection(event, isEnd: false) : _adjustSelection(event, isEnd: false); |
2668 | } |
2669 | |
2670 | @override |
2671 | SelectionResult dispatchSelectionEvent(SelectionEvent event) { |
2672 | final bool selectionWillBeInProgress = event is! ClearSelectionEvent; |
2673 | if (!_selectionInProgress && selectionWillBeInProgress) { |
2674 | // Sort the selectable every time a selection start. |
2675 | selectables.sort(compareOrder); |
2676 | } |
2677 | _selectionInProgress = selectionWillBeInProgress; |
2678 | _isHandlingSelectionEvent = true; |
2679 | late SelectionResult result; |
2680 | switch (event.type) { |
2681 | case SelectionEventType.startEdgeUpdate: |
2682 | case SelectionEventType.endEdgeUpdate: |
2683 | _extendSelectionInProgress = false; |
2684 | result = handleSelectionEdgeUpdate(event as SelectionEdgeUpdateEvent); |
2685 | case SelectionEventType.clear: |
2686 | _extendSelectionInProgress = false; |
2687 | result = handleClearSelection(event as ClearSelectionEvent); |
2688 | case SelectionEventType.selectAll: |
2689 | _extendSelectionInProgress = false; |
2690 | result = handleSelectAll(event as SelectAllSelectionEvent); |
2691 | case SelectionEventType.selectWord: |
2692 | _extendSelectionInProgress = false; |
2693 | result = handleSelectWord(event as SelectWordSelectionEvent); |
2694 | case SelectionEventType.selectParagraph: |
2695 | _extendSelectionInProgress = false; |
2696 | result = handleSelectParagraph(event as SelectParagraphSelectionEvent); |
2697 | case SelectionEventType.granularlyExtendSelection: |
2698 | _extendSelectionInProgress = true; |
2699 | result = handleGranularlyExtendSelection(event as GranularlyExtendSelectionEvent); |
2700 | case SelectionEventType.directionallyExtendSelection: |
2701 | _extendSelectionInProgress = true; |
2702 | result = handleDirectionallyExtendSelection(event as DirectionallyExtendSelectionEvent); |
2703 | } |
2704 | _isHandlingSelectionEvent = false; |
2705 | _updateSelectionGeometry(); |
2706 | return result; |
2707 | } |
2708 | |
2709 | @override |
2710 | void dispose() { |
2711 | for (final Selectable selectable in selectables) { |
2712 | selectable.removeListener(_handleSelectableGeometryChange); |
2713 | } |
2714 | selectables = const <Selectable>[]; |
2715 | _scheduledSelectableUpdate = false; |
2716 | super.dispose(); |
2717 | } |
2718 | |
2719 | /// Ensures the [Selectable] child has received up to date selection event. |
2720 | /// |
2721 | /// This method is called when a new [Selectable] is added to the delegate, |
2722 | /// and its screen location falls into the previous selection. |
2723 | /// |
2724 | /// Subclasses are responsible for updating the selection of this newly added |
2725 | /// [Selectable]. |
2726 | @protected |
2727 | void ensureChildUpdated(Selectable selectable); |
2728 | |
2729 | /// Dispatches a selection event to a specific [Selectable]. |
2730 | /// |
2731 | /// Override this method if subclasses need to generate additional events or |
2732 | /// treatments prior to sending the [SelectionEvent]. |
2733 | @protected |
2734 | SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) { |
2735 | return selectable.dispatchSelectionEvent(event); |
2736 | } |
2737 | |
2738 | /// Initializes the selection of the selectable children. |
2739 | /// |
2740 | /// The goal is to find the selectable child that contains the selection edge. |
2741 | /// Returns [SelectionResult.end] if the selection edge ends on any of the |
2742 | /// children. Otherwise, it returns [SelectionResult.previous] if the selection |
2743 | /// does not reach any of its children. Returns [SelectionResult.next] |
2744 | /// if the selection reaches the end of its children. |
2745 | /// |
2746 | /// Ideally, this method should only be called twice at the beginning of the |
2747 | /// drag selection, once for start edge update event, once for end edge update |
2748 | /// event. |
2749 | SelectionResult _initSelection(SelectionEdgeUpdateEvent event, {required bool isEnd}) { |
2750 | assert((isEnd && currentSelectionEndIndex == -1) || (!isEnd && currentSelectionStartIndex == -1)); |
2751 | int newIndex = -1; |
2752 | bool hasFoundEdgeIndex = false; |
2753 | SelectionResult? result; |
2754 | for (int index = 0; index < selectables.length && !hasFoundEdgeIndex; index += 1) { |
2755 | final Selectable child = selectables[index]; |
2756 | final SelectionResult childResult = dispatchSelectionEventToChild(child, event); |
2757 | switch (childResult) { |
2758 | case SelectionResult.next: |
2759 | case SelectionResult.none: |
2760 | newIndex = index; |
2761 | case SelectionResult.end: |
2762 | newIndex = index; |
2763 | result = SelectionResult.end; |
2764 | hasFoundEdgeIndex = true; |
2765 | case SelectionResult.previous: |
2766 | hasFoundEdgeIndex = true; |
2767 | if (index == 0) { |
2768 | newIndex = 0; |
2769 | result = SelectionResult.previous; |
2770 | } |
2771 | result ??= SelectionResult.end; |
2772 | case SelectionResult.pending: |
2773 | newIndex = index; |
2774 | result = SelectionResult.pending; |
2775 | hasFoundEdgeIndex = true; |
2776 | } |
2777 | } |
2778 | |
2779 | if (newIndex == -1) { |
2780 | assert(selectables.isEmpty); |
2781 | return SelectionResult.none; |
2782 | } |
2783 | if (isEnd) { |
2784 | currentSelectionEndIndex = newIndex; |
2785 | } else { |
2786 | currentSelectionStartIndex = newIndex; |
2787 | } |
2788 | _flushInactiveSelections(); |
2789 | // The result can only be null if the loop went through the entire list |
2790 | // without any of the selection returned end or previous. In this case, the |
2791 | // caller of this method needs to find the next selectable in their list. |
2792 | return result ?? SelectionResult.next; |
2793 | } |
2794 | |
2795 | /// Adjusts the selection based on the drag selection update event if there |
2796 | /// is already a selectable child that contains the selection edge. |
2797 | /// |
2798 | /// This method starts by sending the selection event to the current |
2799 | /// selectable that contains the selection edge, and finds forward or backward |
2800 | /// if that selectable no longer contains the selection edge. |
2801 | SelectionResult _adjustSelection(SelectionEdgeUpdateEvent event, {required bool isEnd}) { |
2802 | assert(() { |
2803 | if (isEnd) { |
2804 | assert(currentSelectionEndIndex < selectables.length && currentSelectionEndIndex >= 0); |
2805 | return true; |
2806 | } |
2807 | assert(currentSelectionStartIndex < selectables.length && currentSelectionStartIndex >= 0); |
2808 | return true; |
2809 | }()); |
2810 | SelectionResult? finalResult; |
2811 | // Determines if the edge being adjusted is within the current viewport. |
2812 | // - If so, we begin the search for the new selection edge position at the |
2813 | // currentSelectionEndIndex/currentSelectionStartIndex. |
2814 | // - If not, we attempt to locate the new selection edge starting from |
2815 | // the opposite end. |
2816 | // - If neither edge is in the current viewport, the search for the new |
2817 | // selection edge position begins at 0. |
2818 | // |
2819 | // This can happen when there is a scrollable child and the edge being adjusted |
2820 | // has been scrolled out of view. |
2821 | final bool isCurrentEdgeWithinViewport = isEnd ? _selectionGeometry.endSelectionPoint != null : _selectionGeometry.startSelectionPoint != null; |
2822 | final bool isOppositeEdgeWithinViewport = isEnd ? _selectionGeometry.startSelectionPoint != null : _selectionGeometry.endSelectionPoint != null; |
2823 | int newIndex = switch ((isEnd, isCurrentEdgeWithinViewport, isOppositeEdgeWithinViewport)) { |
2824 | (true, true, true) => currentSelectionEndIndex, |
2825 | (true, true, false) => currentSelectionEndIndex, |
2826 | (true, false, true) => currentSelectionStartIndex, |
2827 | (true, false, false) => 0, |
2828 | (false, true, true) => currentSelectionStartIndex, |
2829 | (false, true, false) => currentSelectionStartIndex, |
2830 | (false, false, true) => currentSelectionEndIndex, |
2831 | (false, false, false) => 0, |
2832 | }; |
2833 | bool? forward; |
2834 | late SelectionResult currentSelectableResult; |
2835 | // This loop sends the selection event to one of the following to determine |
2836 | // the direction of the search. |
2837 | // - currentSelectionEndIndex/currentSelectionStartIndex if the current edge |
2838 | // is in the current viewport. |
2839 | // - The opposite edge index if the current edge is not in the current viewport. |
2840 | // - Index 0 if neither edge is in the current viewport. |
2841 | // |
2842 | // If the result is `SelectionResult.next`, this loop look backward. |
2843 | // Otherwise, it looks forward. |
2844 | // |
2845 | // The terminate condition are: |
2846 | // 1. the selectable returns end, pending, none. |
2847 | // 2. the selectable returns previous when looking forward. |
2848 | // 2. the selectable returns next when looking backward. |
2849 | while (newIndex < selectables.length && newIndex >= 0 && finalResult == null) { |
2850 | currentSelectableResult = dispatchSelectionEventToChild(selectables[newIndex], event); |
2851 | switch (currentSelectableResult) { |
2852 | case SelectionResult.end: |
2853 | case SelectionResult.pending: |
2854 | case SelectionResult.none: |
2855 | finalResult = currentSelectableResult; |
2856 | case SelectionResult.next: |
2857 | if (forward == false) { |
2858 | newIndex += 1; |
2859 | finalResult = SelectionResult.end; |
2860 | } else if (newIndex == selectables.length - 1) { |
2861 | finalResult = currentSelectableResult; |
2862 | } else { |
2863 | forward = true; |
2864 | newIndex += 1; |
2865 | } |
2866 | case SelectionResult.previous: |
2867 | if (forward ?? false) { |
2868 | newIndex -= 1; |
2869 | finalResult = SelectionResult.end; |
2870 | } else if (newIndex == 0) { |
2871 | finalResult = currentSelectableResult; |
2872 | } else { |
2873 | forward = false; |
2874 | newIndex -= 1; |
2875 | } |
2876 | } |
2877 | } |
2878 | if (isEnd) { |
2879 | currentSelectionEndIndex = newIndex; |
2880 | } else { |
2881 | currentSelectionStartIndex = newIndex; |
2882 | } |
2883 | _flushInactiveSelections(); |
2884 | return finalResult!; |
2885 | } |
2886 | } |
2887 | |
2888 | /// Signature for a widget builder that builds a context menu for the given |
2889 | /// [SelectableRegionState]. |
2890 | /// |
2891 | /// See also: |
2892 | /// |
2893 | /// * [EditableTextContextMenuBuilder], which performs the same role for |
2894 | /// [EditableText]. |
2895 | typedef SelectableRegionContextMenuBuilder = Widget Function( |
2896 | BuildContext context, |
2897 | SelectableRegionState selectableRegionState, |
2898 | ); |
2899 | |