From fa82b1ba10df9c3192642f444d234851280e79c0 Mon Sep 17 00:00:00 2001 From: singerdmx Date: Sat, 19 Dec 2020 02:12:53 -0800 Subject: [PATCH] Implement class _TextSelectionHandleOverlay --- lib/models/documents/nodes/embed.dart | 18 + lib/widgets/cursor.dart | 105 ++++- lib/widgets/delegate.dart | 20 +- lib/widgets/editor.dart | 152 ++++++- lib/widgets/keyboard_listener.dart | 4 +- lib/widgets/text_block.dart | 2 +- lib/widgets/text_selection.dart | 627 +++++++++++++++++++++++++- 7 files changed, 885 insertions(+), 43 deletions(-) diff --git a/lib/models/documents/nodes/embed.dart b/lib/models/documents/nodes/embed.dart index b3cdd0d7..9f0ed59d 100644 --- a/lib/models/documents/nodes/embed.dart +++ b/lib/models/documents/nodes/embed.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + class Embeddable { static const TYPE_KEY = '_type'; static const INLINE_KEY = '_inline'; @@ -8,8 +10,12 @@ class Embeddable { Embeddable(this.type, this.inline, Map data) : assert(type != null), assert(inline != null), + assert(!data.containsKey(TYPE_KEY)), + assert(!data.containsKey(INLINE_KEY)), _data = Map.from(data); + Map get data => UnmodifiableMapView(_data); + Map toJson() { Map m = Map.from(_data); m[TYPE_KEY] = type; @@ -28,6 +34,18 @@ class Embeddable { } return BlockEmbed(type, data: data); } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Embeddable && + runtimeType == other.runtimeType && + type == other.type && + inline == other.inline && + _data == other._data; + + @override + int get hashCode => type.hashCode ^ inline.hashCode ^ _data.hashCode; } class Span extends Embeddable { diff --git a/lib/widgets/cursor.dart b/lib/widgets/cursor.dart index f4c7b9ee..5d7ad3c1 100644 --- a/lib/widgets/cursor.dart +++ b/lib/widgets/cursor.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -32,16 +34,16 @@ class CursorStyle { @override bool operator ==(Object other) => identical(this, other) || - other is CursorStyle && - runtimeType == other.runtimeType && - color == other.color && - backgroundColor == other.backgroundColor && - width == other.width && - height == other.height && - radius == other.radius && - offset == other.offset && - opacityAnimates == other.opacityAnimates && - paintAboveText == other.paintAboveText; + other is CursorStyle && + runtimeType == other.runtimeType && + color == other.color && + backgroundColor == other.backgroundColor && + width == other.width && + height == other.height && + radius == other.radius && + offset == other.offset && + opacityAnimates == other.opacityAnimates && + paintAboveText == other.paintAboveText; @override int get hashCode => @@ -60,13 +62,16 @@ class CursorCont extends ChangeNotifier { final ValueNotifier _blink; final ValueNotifier color; AnimationController _blinkOpacityCont; + Timer _cursorTimer; + bool _targetCursorVisibility = false; CursorStyle _style; CursorCont({ @required ValueNotifier show, @required CursorStyle style, @required TickerProvider tickerProvider, - }) : assert(show != null), + }) + : assert(show != null), assert(style != null), assert(tickerProvider != null), show = show ?? ValueNotifier(false), @@ -78,7 +83,79 @@ class CursorCont extends ChangeNotifier { _blinkOpacityCont.addListener(_onColorTick); } - void _onColorTick() { + ValueNotifier get cursorBlink => _blink; + + ValueNotifier get cursorColor => color; + + CursorStyle get style => _style; + + set style(CursorStyle value) { + assert(value != null); + if (_style == value) return; + _style = value; + notifyListeners(); + } + + @override + dispose() { + _blinkOpacityCont.removeListener(_onColorTick); + stopCursorTimer(); + _blinkOpacityCont.dispose(); + assert(_cursorTimer == null); + super.dispose(); + } + + _cursorTick(Timer timer) { + _targetCursorVisibility = !_targetCursorVisibility; + double targetOpacity = _targetCursorVisibility ? 1.0 : 0.0; + if (style.opacityAnimates) { + _blinkOpacityCont.animateTo(targetOpacity, curve: Curves.easeOut); + } else { + _blinkOpacityCont.value = targetOpacity; + } + } + + _cursorWaitForStart(Timer timer) { + _cursorTimer?.cancel(); + _cursorTimer = Timer.periodic(Duration(milliseconds: 500), _cursorTick); + } + + void startCursorTimer() { + _targetCursorVisibility = true; + _blinkOpacityCont.value = 1.0; + + if (style.opacityAnimates) { + _cursorTimer = + Timer.periodic(Duration(milliseconds: 150), _cursorWaitForStart); + } else { + _cursorTimer = Timer.periodic(Duration(milliseconds: 500), _cursorTick); + } + } + + stopCursorTimer({bool resetCharTicks = true}) { + _cursorTimer?.cancel(); + _cursorTimer = null; + _targetCursorVisibility = false; + _blinkOpacityCont.value = 0.0; + + if (style.opacityAnimates) { + _blinkOpacityCont.stop(); + _blinkOpacityCont.value = 0.0; + } + } + + startOrStopCursorTimerIfNeeded(bool hasFocus, TextSelection selection) { + if (show.value && + _cursorTimer == null && + hasFocus && + selection.isCollapsed) { + startCursorTimer(); + } else if (_cursorTimer != null && (!hasFocus || !selection.isCollapsed)) { + stopCursorTimer(); + } + } + + _onColorTick() { color.value = _style.color.withOpacity(_blinkOpacityCont.value); _blink.value = show.value && _blinkOpacityCont.value > 0; } @@ -141,11 +218,11 @@ class CursorPainter { caretRect = caretRect.shift(Offset( caretPosition.dx.isFinite ? (caretPosition.dx / pixelMultiple).round() * pixelMultiple - - caretPosition.dx + caretPosition.dx : caretPosition.dx, caretPosition.dy.isFinite ? (caretPosition.dy / pixelMultiple).round() * pixelMultiple - - caretPosition.dy + caretPosition.dy : caretPosition.dy)); Paint paint = Paint()..color = color; diff --git a/lib/widgets/delegate.dart b/lib/widgets/delegate.dart index e4376ef0..aee7a058 100644 --- a/lib/widgets/delegate.dart +++ b/lib/widgets/delegate.dart @@ -3,6 +3,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_quill/models/documents/nodes/leaf.dart'; +import 'package:flutter_quill/widgets/text_selection.dart'; import 'editor.dart'; @@ -127,7 +128,22 @@ class EditorTextSelectionGestureDetectorBuilder { onDragSelectionEnd(DragEndDetails details) {} Widget build(HitTestBehavior behavior, Widget child) { - // TODO - return null; + return EditorTextSelectionGestureDetector( + onTapDown: onTapDown, + onForcePressStart: + delegate.getForcePressEnabled() ? onForcePressStart : null, + onForcePressEnd: delegate.getForcePressEnabled() ? onForcePressEnd : null, + onSingleTapUp: onSingleTapUp, + onSingleTapCancel: onSingleTapCancel, + onSingleLongTapStart: onSingleLongTapStart, + onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate, + onSingleLongTapEnd: onSingleLongTapEnd, + onDoubleTapDown: onDoubleTapDown, + onDragSelectionStart: onDragSelectionStart, + onDragSelectionUpdate: onDragSelectionUpdate, + onDragSelectionEnd: onDragSelectionEnd, + behavior: behavior, + child: child, + ); } } diff --git a/lib/widgets/editor.dart b/lib/widgets/editor.dart index b13f2115..0643d9b4 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -755,15 +756,134 @@ class RawEditorState extends EditorState @override void didUpdateWidget(RawEditor oldWidget) { super.didUpdateWidget(oldWidget); - // TODO + + _cursorCont.show.value = widget.showCursor; + _cursorCont.style = widget.cursorStyle; + + if (widget.controller != oldWidget.controller) { + oldWidget.controller.removeListener(_didChangeTextEditingValue); + widget.controller.addListener(_didChangeTextEditingValue); + updateRemoteValueIfNeeded(); + } + + if (widget.scrollController != null && + widget.scrollController != _scrollController) { + _scrollController.removeListener(_updateSelectionOverlayForScroll); + _scrollController = widget.scrollController; + _scrollController.addListener(_updateSelectionOverlayForScroll); + } + + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode.removeListener(_handleFocusChanged); + _focusAttachment?.detach(); + _focusAttachment = widget.focusNode.attach(context, + onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event)); + widget.focusNode.addListener(_handleFocusChanged); + updateKeepAlive(); + } + + if (widget.controller.selection != oldWidget.controller.selection) { + _selectionOverlay?.update(textEditingValue); + } + + _selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); + if (widget.readOnly) { + closeConnectionIfNeeded(); + } else { + if (oldWidget.readOnly && _hasFocus) { + openConnectionIfNeeded(); + } + } + } + + bool _shouldShowSelectionHandles() { + return widget.showSelectionHandles && + !widget.controller.selection.isCollapsed; } handleDelete(bool forward) { - // TODO + TextSelection selection = widget.controller.selection; + String plainText = textEditingValue.text; + assert(selection != null); + int cursorPosition = selection.start; + String textBefore = selection.textBefore(plainText); + String textAfter = selection.textAfter(plainText); + if (selection.isCollapsed) { + if (!forward && textBefore.isNotEmpty) { + final int characterBoundary = + _previousCharacter(textBefore.length, textBefore, true); + textBefore = textBefore.substring(0, characterBoundary); + cursorPosition = characterBoundary; + } + if (forward && textAfter.isNotEmpty && textAfter != '\n') { + final int deleteCount = _nextCharacter(0, textAfter, true); + textAfter = textAfter.substring(deleteCount); + } + } + TextSelection newSelection = + TextSelection.collapsed(offset: cursorPosition); + String newText = textBefore + textAfter; + int size = plainText.length - newText.length; + widget.controller.replaceText( + cursorPosition, + size, + '', + newSelection, + ); } Future handleShortcut(InputShortcut shortcut) async { - // TODO + TextSelection selection = widget.controller.selection; + assert(selection != null); + String plainText = textEditingValue.text; + if (shortcut == InputShortcut.COPY) { + if (!selection.isCollapsed) { + Clipboard.setData(ClipboardData(text: selection.textInside(plainText))); + } + return; + } + if (shortcut == InputShortcut.CUT && !widget.readOnly) { + if (!selection.isCollapsed) { + final data = selection.textInside(plainText); + Clipboard.setData(ClipboardData(text: data)); + + widget.controller.replaceText( + selection.start, + data.length, + '', + TextSelection.collapsed(offset: selection.start), + ); + + textEditingValue = TextEditingValue( + text: + selection.textBefore(plainText) + selection.textAfter(plainText), + selection: TextSelection.collapsed(offset: selection.start), + ); + } + return; + } + if (shortcut == InputShortcut.PASTE && !widget.readOnly) { + ClipboardData data = await Clipboard.getData(Clipboard.kTextPlain); + if (data != null) { + widget.controller.replaceText( + selection.start, + selection.end - selection.start, + data.text, + TextSelection.collapsed(offset: selection.start + data.text.length), + ); + } + return; + } + if (shortcut == InputShortcut.SELECT_ALL && + widget.enableInteractiveSelection) { + widget.controller.updateSelection( + selection.copyWith( + baseOffset: 0, + extentOffset: textEditingValue.text.length, + ), + ChangeSource.REMOTE); + return; + } } @override @@ -872,9 +992,16 @@ class RenderEditor extends RenderEditableContainerBox LayerLink _startHandleLayerLink; LayerLink _endHandleLayerLink; TextSelectionChangedHandler onSelectionChanged; + final ValueNotifier _selectionStartInViewport = + ValueNotifier(true); + + ValueListenable get selectionStartInViewport => + _selectionStartInViewport; - RenderEditor( - List children, + ValueListenable get selectionEndInViewport => _selectionEndInViewport; + final ValueNotifier _selectionEndInViewport = ValueNotifier(true); + + RenderEditor(List children, TextDirection textDirection, hasFocus, EdgeInsetsGeometry padding, @@ -1011,14 +1138,14 @@ class RenderEditableContainerBox extends RenderBox RenderBoxContainerDefaultsMixin { containerNode.Container _container; - TextDirection _textDirection; + TextDirection textDirection; EdgeInsetsGeometry _padding; EdgeInsets _resolvedPadding; RenderEditableContainerBox(List children, this._container, - this._textDirection, this._padding) + this.textDirection, this._padding) : assert(_container != null), - assert(_textDirection != null), + assert(textDirection != null), assert(_padding != null), assert(_padding.isNonNegative) { addAll(children); @@ -1037,13 +1164,6 @@ class RenderEditableContainerBox extends RenderBox markNeedsLayout(); } - setTextDirection(TextDirection t) { - if (_textDirection == t) { - return; - } - _textDirection = t; - } - EdgeInsetsGeometry getPadding() => _padding; setPadding(EdgeInsetsGeometry value) { @@ -1062,7 +1182,7 @@ class RenderEditableContainerBox extends RenderBox if (_resolvedPadding != null) { return; } - _resolvedPadding = _padding.resolve(_textDirection); + _resolvedPadding = _padding.resolve(textDirection); _resolvedPadding = _resolvedPadding.copyWith(left: _resolvedPadding.left); assert(_resolvedPadding.isNonNegative); diff --git a/lib/widgets/keyboard_listener.dart b/lib/widgets/keyboard_listener.dart index ea4ba100..329132ab 100644 --- a/lib/widgets/keyboard_listener.dart +++ b/lib/widgets/keyboard_listener.dart @@ -1,6 +1,6 @@ import 'package:flutter/services.dart'; -enum InputShortcut { CUT, COPY, PAST, SELECT_ALL } +enum InputShortcut { CUT, COPY, PASTE, SELECT_ALL } typedef CursorMoveCallback = void Function( LogicalKeyboardKey key, bool wordModifier, bool lineModifier, bool shift); @@ -54,7 +54,7 @@ class KeyboardListener { static final Map _keyToShortcut = { LogicalKeyboardKey.keyX: InputShortcut.CUT, LogicalKeyboardKey.keyC: InputShortcut.COPY, - LogicalKeyboardKey.keyV: InputShortcut.PAST, + LogicalKeyboardKey.keyV: InputShortcut.PASTE, LogicalKeyboardKey.keyA: InputShortcut.SELECT_ALL, }; diff --git a/lib/widgets/text_block.dart b/lib/widgets/text_block.dart index 36dd35ca..cdbe19d7 100644 --- a/lib/widgets/text_block.dart +++ b/lib/widgets/text_block.dart @@ -490,7 +490,7 @@ class _EditableBlock extends MultiChildRenderObjectWidget { void updateRenderObject( BuildContext context, covariant RenderEditableTextBlock renderObject) { renderObject.setContainer(block); - renderObject.setTextDirection(textDirection); + renderObject.textDirection = textDirection; renderObject.setPadding(_padding); renderObject.decoration = decoration; renderObject.contentPadding = _contentPadding; diff --git a/lib/widgets/text_selection.dart b/lib/widgets/text_selection.dart index a4fd3e58..2a0f94da 100644 --- a/lib/widgets/text_selection.dart +++ b/lib/widgets/text_selection.dart @@ -1,8 +1,12 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_quill/models/documents/nodes/node.dart'; import 'editor.dart'; @@ -17,6 +21,8 @@ TextSelection localSelection(Node node, TextSelection selection, fromParent) { extentOffset: math.min(selection.end - offset, node.length - 1)); } +enum _TextSelectionHandlePosition { START, END } + class EditorTextSelectionOverlay { TextEditingValue value; bool handlesVisible = false; @@ -35,8 +41,7 @@ class EditorTextSelectionOverlay { List _handles; OverlayEntry toolbar; - EditorTextSelectionOverlay( - this.value, + EditorTextSelectionOverlay(this.value, this.handlesVisible, this.context, this.debugRequiredFor, @@ -60,7 +65,23 @@ class EditorTextSelectionOverlay { duration: Duration(milliseconds: 150), vsync: overlay); } - setHandlesVisible(bool visible) {} + TextSelection get _selection => value.selection; + + Animation get _toolbarOpacity => _toolbarController.view; + + setHandlesVisible(bool visible) { + assert(visible != null); + if (handlesVisible == visible) { + return; + } + handlesVisible = visible; + if (SchedulerBinding.instance.schedulerPhase == + SchedulerPhase.persistentCallbacks) { + SchedulerBinding.instance.addPostFrameCallback(markNeedsBuild); + } else { + markNeedsBuild(); + } + } hideHandles() { if (_handles == null) { @@ -78,7 +99,6 @@ class EditorTextSelectionOverlay { toolbar = null; } - /// Shows the toolbar by inserting it into the [context]'s overlay. showToolbar() { assert(toolbar == null); toolbar = OverlayEntry(builder: _buildToolbar); @@ -87,13 +107,110 @@ class EditorTextSelectionOverlay { _toolbarController.forward(from: 0.0); } + Widget _buildHandle(BuildContext context, + _TextSelectionHandlePosition position) { + if ((_selection.isCollapsed && + position == _TextSelectionHandlePosition.END) || + selectionCtrls == null) { + return Container(); + } + return Visibility( + visible: handlesVisible, + child: _TextSelectionHandleOverlay( + onSelectionHandleChanged: (TextSelection newSelection) { + _handleSelectionHandleChanged(newSelection, position); + }, + onSelectionHandleTapped: onSelectionHandleTapped, + startHandleLayerLink: startHandleLayerLink, + endHandleLayerLink: endHandleLayerLink, + renderObject: renderObject, + selection: _selection, + selectionControls: selectionCtrls, + position: position, + dragStartBehavior: dragStartBehavior, + )); + } + + update(TextEditingValue newValue) { + if (value == newValue) { + return; + } + value = newValue; + if (SchedulerBinding.instance.schedulerPhase == + SchedulerPhase.persistentCallbacks) { + SchedulerBinding.instance.addPostFrameCallback(markNeedsBuild); + } else { + markNeedsBuild(); + } + } + + _handleSelectionHandleChanged(TextSelection newSelection, + _TextSelectionHandlePosition position) { + TextPosition textPosition; + switch (position) { + case _TextSelectionHandlePosition.START: + textPosition = newSelection.base; + break; + case _TextSelectionHandlePosition.END: + textPosition = newSelection.extent; + break; + default: + throw ('Invalid position'); + } + selectionDelegate.textEditingValue = + value.copyWith(selection: newSelection, composing: TextRange.empty); + selectionDelegate.bringIntoView(textPosition); + } + Widget _buildToolbar(BuildContext context) { if (selectionCtrls == null) { return Container(); } + + List endpoints = + renderObject.getEndpointsForSelection(_selection); + + Rect editingRegion = Rect.fromPoints( + renderObject.localToGlobal(Offset.zero), + renderObject.localToGlobal(renderObject.size.bottomRight(Offset.zero)), + ); + + double baseLineHeight = renderObject.preferredLineHeight(_selection.base); + double extentLineHeight = + renderObject.preferredLineHeight(_selection.extent); + double smallestLineHeight = math.min(baseLineHeight, extentLineHeight); + bool isMultiline = endpoints.last.point.dy - endpoints.first.point.dy > + smallestLineHeight / 2; + + double midX = isMultiline + ? editingRegion.width / 2 + : (endpoints.first.point.dx + endpoints.last.point.dx) / 2; + + Offset midpoint = Offset( + midX, + endpoints[0].point.dy - baseLineHeight, + ); + + return FadeTransition( + opacity: _toolbarOpacity, + child: CompositedTransformFollower( + link: toolbarLayerLink, + showWhenUnlinked: false, + offset: -editingRegion.topLeft, + child: selectionCtrls.buildToolbar( + context, + editingRegion, + baseLineHeight, + midpoint, + endpoints, + selectionDelegate, + clipboardStatus, + ), + ), + ); } - markNeedsBuild() { + markNeedsBuild([Duration duration]) { if (_handles != null) { _handles[0].markNeedsBuild(); _handles[1].markNeedsBuild(); @@ -118,11 +235,505 @@ class EditorTextSelectionOverlay { } } +class _TextSelectionHandleOverlay extends StatefulWidget { + const _TextSelectionHandleOverlay({ + Key key, + @required this.selection, + @required this.position, + @required this.startHandleLayerLink, + @required this.endHandleLayerLink, + @required this.renderObject, + @required this.onSelectionHandleChanged, + @required this.onSelectionHandleTapped, + @required this.selectionControls, + this.dragStartBehavior = DragStartBehavior.start, + }) : super(key: key); + + final TextSelection selection; + final _TextSelectionHandlePosition position; + final LayerLink startHandleLayerLink; + final LayerLink endHandleLayerLink; + final RenderEditor renderObject; + final ValueChanged onSelectionHandleChanged; + final VoidCallback onSelectionHandleTapped; + final TextSelectionControls selectionControls; + final DragStartBehavior dragStartBehavior; + + @override + _TextSelectionHandleOverlayState createState() => + _TextSelectionHandleOverlayState(); + + ValueListenable get _visibility { + switch (position) { + case _TextSelectionHandlePosition.START: + return renderObject.selectionStartInViewport; + case _TextSelectionHandlePosition.END: + return renderObject.selectionEndInViewport; + } + return null; + } +} + +class _TextSelectionHandleOverlayState + extends State<_TextSelectionHandleOverlay> + with SingleTickerProviderStateMixin { + AnimationController _controller; + + Animation get _opacity => _controller.view; + + @override + void initState() { + super.initState(); + + _controller = + AnimationController(duration: Duration(milliseconds: 150), vsync: this); + + _handleVisibilityChanged(); + widget._visibility.addListener(_handleVisibilityChanged); + } + + _handleVisibilityChanged() { + if (widget._visibility.value) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + + @override + didUpdateWidget(_TextSelectionHandleOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + oldWidget._visibility.removeListener(_handleVisibilityChanged); + _handleVisibilityChanged(); + widget._visibility.addListener(_handleVisibilityChanged); + } + + @override + void dispose() { + widget._visibility.removeListener(_handleVisibilityChanged); + _controller.dispose(); + super.dispose(); + } + + _handleDragStart(DragStartDetails details) {} + + _handleDragUpdate(DragUpdateDetails details) { + TextPosition position = + widget.renderObject.getPositionForOffset(details.globalPosition); + if (widget.selection.isCollapsed) { + widget.onSelectionHandleChanged(TextSelection.fromPosition(position)); + return; + } + + bool isNormalized = + widget.selection.extentOffset >= widget.selection.baseOffset; + TextSelection newSelection; + switch (widget.position) { + case _TextSelectionHandlePosition.START: + newSelection = TextSelection( + baseOffset: + isNormalized ? position.offset : widget.selection.baseOffset, + extentOffset: + isNormalized ? widget.selection.extentOffset : position.offset, + ); + break; + case _TextSelectionHandlePosition.END: + newSelection = TextSelection( + baseOffset: + isNormalized ? widget.selection.baseOffset : position.offset, + extentOffset: + isNormalized ? position.offset : widget.selection.extentOffset, + ); + break; + } + + widget.onSelectionHandleChanged(newSelection); + } + + _handleTap() { + if (widget.onSelectionHandleTapped != null) + widget.onSelectionHandleTapped(); + } + + @override + Widget build(BuildContext context) { + LayerLink layerLink; + TextSelectionHandleType type; + + switch (widget.position) { + case _TextSelectionHandlePosition.START: + layerLink = widget.startHandleLayerLink; + type = _chooseType( + widget.renderObject.textDirection, + TextSelectionHandleType.left, + TextSelectionHandleType.right, + ); + break; + case _TextSelectionHandlePosition.END: + assert(!widget.selection.isCollapsed); + layerLink = widget.endHandleLayerLink; + type = _chooseType( + widget.renderObject.textDirection, + TextSelectionHandleType.right, + TextSelectionHandleType.left, + ); + break; + } + + TextPosition textPosition = + widget.position == _TextSelectionHandlePosition.START + ? widget.selection.base + : widget.selection.extent; + double lineHeight = widget.renderObject.preferredLineHeight(textPosition); + Offset handleAnchor = + widget.selectionControls.getHandleAnchor(type, lineHeight); + Size handleSize = widget.selectionControls.getHandleSize(lineHeight); + + Rect handleRect = Rect.fromLTWH( + -handleAnchor.dx, + -handleAnchor.dy, + handleSize.width, + handleSize.height, + ); + + Rect interactiveRect = handleRect.expandToInclude( + Rect.fromCircle( + center: handleRect.center, radius: kMinInteractiveDimension / 2), + ); + RelativeRect padding = RelativeRect.fromLTRB( + math.max((interactiveRect.width - handleRect.width) / 2, 0), + math.max((interactiveRect.height - handleRect.height) / 2, 0), + math.max((interactiveRect.width - handleRect.width) / 2, 0), + math.max((interactiveRect.height - handleRect.height) / 2, 0), + ); + + return CompositedTransformFollower( + link: layerLink, + offset: interactiveRect.topLeft, + showWhenUnlinked: false, + child: FadeTransition( + opacity: _opacity, + child: Container( + alignment: Alignment.topLeft, + width: interactiveRect.width, + height: interactiveRect.height, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + dragStartBehavior: widget.dragStartBehavior, + onPanStart: _handleDragStart, + onPanUpdate: _handleDragUpdate, + onTap: _handleTap, + child: Padding( + padding: EdgeInsets.only( + left: padding.left, + top: padding.top, + right: padding.right, + bottom: padding.bottom, + ), + child: widget.selectionControls.buildHandle( + context, + type, + lineHeight, + ), + ), + ), + ), + ), + ); + } + + TextSelectionHandleType _chooseType(TextDirection textDirection, + TextSelectionHandleType ltrType, + TextSelectionHandleType rtlType,) { + if (widget.selection.isCollapsed) return TextSelectionHandleType.collapsed; + + assert(textDirection != null); + switch (textDirection) { + case TextDirection.ltr: + return ltrType; + case TextDirection.rtl: + return rtlType; + } + return null; + } +} + class EditorTextSelectionGestureDetector extends StatefulWidget { + const EditorTextSelectionGestureDetector({ + Key key, + this.onTapDown, + this.onForcePressStart, + this.onForcePressEnd, + this.onSingleTapUp, + this.onSingleTapCancel, + this.onSingleLongTapStart, + this.onSingleLongTapMoveUpdate, + this.onSingleLongTapEnd, + this.onDoubleTapDown, + this.onDragSelectionStart, + this.onDragSelectionUpdate, + this.onDragSelectionEnd, + this.behavior, + @required this.child, + }) + : assert(child != null), + super(key: key); + + final GestureTapDownCallback onTapDown; + + final GestureForcePressStartCallback onForcePressStart; + + final GestureForcePressEndCallback onForcePressEnd; + + final GestureTapUpCallback onSingleTapUp; + + final GestureTapCancelCallback onSingleTapCancel; + + final GestureLongPressStartCallback onSingleLongTapStart; + + final GestureLongPressMoveUpdateCallback onSingleLongTapMoveUpdate; + + final GestureLongPressEndCallback onSingleLongTapEnd; + + final GestureTapDownCallback onDoubleTapDown; + + final GestureDragStartCallback onDragSelectionStart; + + final DragSelectionUpdateCallback onDragSelectionUpdate; + + final GestureDragEndCallback onDragSelectionEnd; + + final HitTestBehavior behavior; + + final Widget child; + + @override + State createState() => + _EditorTextSelectionGestureDetectorState(); +} + +class _EditorTextSelectionGestureDetectorState + extends State { + Timer _doubleTapTimer; + Offset _lastTapOffset; + bool _isDoubleTap = false; + @override - State createState() { - // TODO: implement createState - throw UnimplementedError(); + void dispose() { + _doubleTapTimer?.cancel(); + _dragUpdateThrottleTimer?.cancel(); + super.dispose(); } + _handleTapDown(TapDownDetails details) { + if (widget.onTapDown != null) { + widget.onTapDown(details); + } + if (_doubleTapTimer != null && + _isWithinDoubleTapTolerance(details.globalPosition)) { + if (widget.onDoubleTapDown != null) { + widget.onDoubleTapDown(details); + } + + _doubleTapTimer.cancel(); + _doubleTapTimeout(); + _isDoubleTap = true; + } + } + + _handleTapUp(TapUpDetails details) { + if (!_isDoubleTap) { + if (widget.onSingleTapUp != null) { + widget.onSingleTapUp(details); + } + _lastTapOffset = details.globalPosition; + _doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout); + } + _isDoubleTap = false; + } + + _handleTapCancel() { + if (widget.onSingleTapCancel != null) { + widget.onSingleTapCancel(); + } + } + + DragStartDetails _lastDragStartDetails; + DragUpdateDetails _lastDragUpdateDetails; + Timer _dragUpdateThrottleTimer; + + _handleDragStart(DragStartDetails details) { + assert(_lastDragStartDetails == null); + _lastDragStartDetails = details; + if (widget.onDragSelectionStart != null) { + widget.onDragSelectionStart(details); + } + } + + _handleDragUpdate(DragUpdateDetails details) { + _lastDragUpdateDetails = details; + _dragUpdateThrottleTimer ??= + Timer(Duration(milliseconds: 50), _handleDragUpdateThrottled); + } + + _handleDragUpdateThrottled() { + assert(_lastDragStartDetails != null); + assert(_lastDragUpdateDetails != null); + if (widget.onDragSelectionUpdate != null) { + widget.onDragSelectionUpdate( + _lastDragStartDetails, _lastDragUpdateDetails); + } + _dragUpdateThrottleTimer = null; + _lastDragUpdateDetails = null; + } + + _handleDragEnd(DragEndDetails details) { + assert(_lastDragStartDetails != null); + if (_dragUpdateThrottleTimer != null) { + _dragUpdateThrottleTimer.cancel(); + _handleDragUpdateThrottled(); + } + if (widget.onDragSelectionEnd != null) { + widget.onDragSelectionEnd(details); + } + _dragUpdateThrottleTimer = null; + _lastDragStartDetails = null; + _lastDragUpdateDetails = null; + } + + _forcePressStarted(ForcePressDetails details) { + _doubleTapTimer?.cancel(); + _doubleTapTimer = null; + if (widget.onForcePressStart != null) { + widget.onForcePressStart(details); + } + } + + _forcePressEnded(ForcePressDetails details) { + if (widget.onForcePressEnd != null) { + widget.onForcePressEnd(details); + } + } + + _handleLongPressStart(LongPressStartDetails details) { + if (!_isDoubleTap && widget.onSingleLongTapStart != null) { + widget.onSingleLongTapStart(details); + } + } + + _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { + if (!_isDoubleTap && widget.onSingleLongTapMoveUpdate != null) { + widget.onSingleLongTapMoveUpdate(details); + } + } + + _handleLongPressEnd(LongPressEndDetails details) { + if (!_isDoubleTap && widget.onSingleLongTapEnd != null) { + widget.onSingleLongTapEnd(details); + } + _isDoubleTap = false; + } + + void _doubleTapTimeout() { + _doubleTapTimer = null; + _lastTapOffset = null; + } + + bool _isWithinDoubleTapTolerance(Offset secondTapOffset) { + assert(secondTapOffset != null); + if (_lastTapOffset == null) { + return false; + } + + return (secondTapOffset - _lastTapOffset).distance <= kDoubleTapSlop; + } + + @override + Widget build(BuildContext context) { + final Map gestures = + {}; + + gestures[_TransparentTapGestureRecognizer] = + GestureRecognizerFactoryWithHandlers<_TransparentTapGestureRecognizer>( + () => _TransparentTapGestureRecognizer(debugOwner: this), + (_TransparentTapGestureRecognizer instance) { + instance + ..onTapDown = _handleTapDown + ..onTapUp = _handleTapUp + ..onTapCancel = _handleTapCancel; + }, + ); + + if (widget.onSingleLongTapStart != null || + widget.onSingleLongTapMoveUpdate != null || + widget.onSingleLongTapEnd != null) { + gestures[LongPressGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => + LongPressGestureRecognizer( + debugOwner: this, kind: PointerDeviceKind.touch), + (LongPressGestureRecognizer instance) { + instance + ..onLongPressStart = _handleLongPressStart + ..onLongPressMoveUpdate = _handleLongPressMoveUpdate + ..onLongPressEnd = _handleLongPressEnd; + }, + ); + } + + if (widget.onDragSelectionStart != null || + widget.onDragSelectionUpdate != null || + widget.onDragSelectionEnd != null) { + gestures[HorizontalDragGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => + HorizontalDragGestureRecognizer( + debugOwner: this, kind: PointerDeviceKind.mouse), + (HorizontalDragGestureRecognizer instance) { + instance + ..dragStartBehavior = DragStartBehavior.down + ..onStart = _handleDragStart + ..onUpdate = _handleDragUpdate + ..onEnd = _handleDragEnd; + }, + ); + } + + if (widget.onForcePressStart != null || widget.onForcePressEnd != null) { + gestures[ForcePressGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => ForcePressGestureRecognizer(debugOwner: this), + (ForcePressGestureRecognizer instance) { + instance + ..onStart = + widget.onForcePressStart != null ? _forcePressStarted : null + ..onEnd = widget.onForcePressEnd != null + ? _forcePressEnded + : null; + }, + ); + } + + return RawGestureDetector( + gestures: gestures, + excludeFromSemantics: true, + behavior: widget.behavior, + child: widget.child, + ); + } +} + +class _TransparentTapGestureRecognizer extends TapGestureRecognizer { + _TransparentTapGestureRecognizer({ + Object debugOwner, + }) : super(debugOwner: debugOwner); + + @override + void rejectGesture(int pointer) { + if (state == GestureRecognizerState.ready) { + acceptGesture(pointer); + } else { + super.rejectGesture(pointer); + } + } }