From e09fada0a5d09a42771ac581fa51b2e3bcc5ed6d Mon Sep 17 00:00:00 2001 From: Renzo Olivares Date: Thu, 13 Apr 2023 00:59:30 -0700 Subject: [PATCH] [Simplistic_Editor] Use new context menu API (#1733) --- simplistic_editor/lib/basic_text_field.dart | 51 ++- .../lib/basic_text_input_client.dart | 315 +++++++++++++----- 2 files changed, 282 insertions(+), 84 deletions(-) diff --git a/simplistic_editor/lib/basic_text_field.dart b/simplistic_editor/lib/basic_text_field.dart index a082e396a..c9bf9b138 100644 --- a/simplistic_editor/lib/basic_text_field.dart +++ b/simplistic_editor/lib/basic_text_field.dart @@ -11,11 +11,32 @@ class BasicTextField extends StatefulWidget { required this.controller, required this.style, required this.focusNode, + this.contextMenuBuilder = _defaultContextMenuBuilder, }); final TextEditingController controller; final TextStyle style; final FocusNode focusNode; + final BasicTextFieldContextMenuBuilder? contextMenuBuilder; + + static Widget _defaultContextMenuBuilder( + BuildContext context, + ClipboardStatus clipboardStatus, + VoidCallback? onCopy, + VoidCallback? onCut, + VoidCallback? onPaste, + VoidCallback? onSelectAll, + TextSelectionToolbarAnchors anchors, + ) { + return AdaptiveTextSelectionToolbar.editable( + clipboardStatus: clipboardStatus, + onCopy: onCopy, + onCut: onCut, + onPaste: onPaste, + onSelectAll: onSelectAll, + anchors: anchors, + ); + } @override State createState() => _BasicTextFieldState(); @@ -86,21 +107,31 @@ class _BasicTextFieldState extends State { @override Widget build(BuildContext context) { switch (Theme.of(this.context).platform) { + // ignore: todo + // TODO(Renzo-Olivares): Remove use of deprecated members once + // TextSelectionControls.buildToolbar has been deleted. + // See https://github.com/flutter/flutter/pull/124611 and + // https://github.com/flutter/flutter/pull/124262 for more details. case TargetPlatform.iOS: - _textSelectionControls = cupertinoTextSelectionControls; + // ignore: deprecated_member_use + _textSelectionControls = cupertinoTextSelectionHandleControls; break; case TargetPlatform.macOS: - _textSelectionControls = cupertinoDesktopTextSelectionControls; + // ignore: deprecated_member_use + _textSelectionControls = cupertinoDesktopTextSelectionHandleControls; break; case TargetPlatform.android: case TargetPlatform.fuchsia: - _textSelectionControls = materialTextSelectionControls; + // ignore: deprecated_member_use + _textSelectionControls = materialTextSelectionHandleControls; break; case TargetPlatform.linux: - _textSelectionControls = desktopTextSelectionControls; + // ignore: deprecated_member_use + _textSelectionControls = desktopTextSelectionHandleControls; break; case TargetPlatform.windows: - _textSelectionControls = desktopTextSelectionControls; + // ignore: deprecated_member_use + _textSelectionControls = desktopTextSelectionHandleControls; break; } @@ -109,8 +140,17 @@ class _BasicTextFieldState extends State { behavior: HitTestBehavior.translucent, onPanStart: (dragStartDetails) => _onDragStart(dragStartDetails), onPanUpdate: (dragUpdateDetails) => _onDragUpdate(dragUpdateDetails), + onSecondaryTapDown: (secondaryTapDownDetails) { + _renderEditable.selectWordsInRange( + from: secondaryTapDownDetails.globalPosition, + cause: SelectionChangedCause.tap); + _renderEditable.handleSecondaryTapDown(secondaryTapDownDetails); + _textInputClient!.hideToolbar(); + _textInputClient!.showToolbar(); + }, onTap: () { _textInputClient!.requestKeyboard(); + _textInputClient!.hideToolbar(); }, onTapDown: (tapDownDetails) { _renderEditable.handleTapDown(tapDownDetails); @@ -160,6 +200,7 @@ class _BasicTextFieldState extends State { selectionControls: _textSelectionControls, onSelectionChanged: _handleSelectionChanged, showSelectionHandles: _showSelectionHandles, + contextMenuBuilder: widget.contextMenuBuilder, ), ), ), diff --git a/simplistic_editor/lib/basic_text_input_client.dart b/simplistic_editor/lib/basic_text_input_client.dart index c0860bee0..2947631ed 100644 --- a/simplistic_editor/lib/basic_text_input_client.dart +++ b/simplistic_editor/lib/basic_text_input_client.dart @@ -15,6 +15,18 @@ import 'replacements.dart'; typedef SelectionChangedCallback = void Function( TextSelection selection, SelectionChangedCause? cause); +/// Signature for a widget builder that builds a context menu for the given +/// editable field. +typedef BasicTextFieldContextMenuBuilder = Widget Function( + BuildContext context, + ClipboardStatus clipboardStatus, + VoidCallback? onCopy, + VoidCallback? onCut, + VoidCallback? onPaste, + VoidCallback? onSelectAll, + TextSelectionToolbarAnchors anchors, +); + /// A basic text input client. An implementation of [DeltaTextInputClient] meant to /// send/receive information from the framework to the platform's text input plugin /// and vice-versa. @@ -25,6 +37,7 @@ class BasicTextInputClient extends StatefulWidget { required this.style, required this.focusNode, this.selectionControls, + this.contextMenuBuilder, required this.onSelectionChanged, required this.showSelectionHandles, }); @@ -35,6 +48,7 @@ class BasicTextInputClient extends StatefulWidget { final TextSelectionControls? selectionControls; final bool showSelectionHandles; final SelectionChangedCallback onSelectionChanged; + final BasicTextFieldContextMenuBuilder? contextMenuBuilder; @override State createState() => BasicTextInputClientState(); @@ -50,6 +64,7 @@ class BasicTextInputClientState extends State @override void initState() { super.initState(); + _clipboardStatus?.addListener(_onChangedClipboardStatus); widget.focusNode.addListener(_handleFocusChanged); widget.controller.addListener(_didChangeTextEditingValue); } @@ -63,18 +78,11 @@ class BasicTextInputClientState extends State @override void dispose() { widget.controller.removeListener(_didChangeTextEditingValue); + _clipboardStatus?.removeListener(_onChangedClipboardStatus); + _clipboardStatus?.dispose(); super.dispose(); } - @override - void didChangeInputControl( - TextInputControl? oldControl, TextInputControl? newControl) { - if (_hasFocus && _hasInputConnection) { - oldControl?.hide(); - newControl?.show(); - } - } - /// [DeltaTextInputClient] method implementations. @override void connectionClosed() { @@ -94,6 +102,15 @@ class BasicTextInputClientState extends State @override TextEditingValue? get currentTextEditingValue => _value; + @override + void didChangeInputControl( + TextInputControl? oldControl, TextInputControl? newControl) { + if (_hasFocus && _hasInputConnection) { + oldControl?.hide(); + newControl?.show(); + } + } + @override void insertTextPlaceholder(Size size) { // Will not implement. This method is used for Scribble support. @@ -296,27 +313,15 @@ class BasicTextInputClientState extends State ); } - void _userUpdateTextEditingValueWithDelta( - TextEditingDelta textEditingDelta, SelectionChangedCause cause) { - TextEditingValue value = _value; - - value = textEditingDelta.apply(value); - - if (widget.controller is ReplacementTextEditingController) { - (widget.controller as ReplacementTextEditingController) - .syncReplacementRanges(textEditingDelta); - } - - if (value != _value) { - manager.updateTextEditingDeltaHistory([textEditingDelta]); - } - - userUpdateTextEditingValue(value, cause); + void _onChangedClipboardStatus() { + setState(() { + // Inform the widget that the value of clipboardStatus has changed. + }); } /// Keyboard text editing actions. // The Handling of the default text editing shortcuts with deltas - // needs to be in the framework somehow. This should go through some kind of + // needs to be in the framework somehow. This should go through some kind of // generic "replace" method like in EditableText. // EditableText converts intents like DeleteCharacterIntent to a generic // ReplaceTextIntent. I wonder if that could be done at a higher level, so @@ -448,6 +453,24 @@ class BasicTextInputClientState extends State ); } + void _userUpdateTextEditingValueWithDelta( + TextEditingDelta textEditingDelta, SelectionChangedCause cause) { + TextEditingValue value = _value; + + value = textEditingDelta.apply(value); + + if (widget.controller is ReplacementTextEditingController) { + (widget.controller as ReplacementTextEditingController) + .syncReplacementRanges(textEditingDelta); + } + + if (value != _value) { + manager.updateTextEditingDeltaHistory([textEditingDelta]); + } + + userUpdateTextEditingValue(value, cause); + } + /// For updates to text editing value. void _didChangeTextEditingValue() { _updateRemoteTextEditingValueIfNeeded(); @@ -455,28 +478,6 @@ class BasicTextInputClientState extends State setState(() {}); } - void _toggleToolbar() { - assert(_selectionOverlay != null); - if (_selectionOverlay!.toolbarIsVisible) { - hideToolbar(false); - } else { - showToolbar(); - } - } - - // When the framework's text editing value changes we should update the text editing - // value contained within the selection overlay or we might observe unexpected behavior. - void _updateOrDisposeOfSelectionOverlayIfNeeded() { - if (_selectionOverlay != null) { - if (_hasFocus) { - _selectionOverlay!.update(_value); - } else { - _selectionOverlay!.dispose(); - _selectionOverlay = null; - } - } - } - // Only update the platform's text input plugin's text editing value when it has changed // to avoid sending duplicate update messages to the engine. void _updateRemoteTextEditingValueIfNeeded() { @@ -488,6 +489,7 @@ class BasicTextInputClientState extends State } } + /// For correctly positioning the candidate menu on macOS. // Sends the current composing rect to the iOS text input plugin via the text // input channel. We need to keep sending the information even if no text is // currently marked, as the information usually lags behind. The text input @@ -542,6 +544,20 @@ class BasicTextInputClientState extends State // Not implemented. } + @override + bool get cutEnabled => !textEditingValue.selection.isCollapsed; + + @override + bool get copyEnabled => !textEditingValue.selection.isCollapsed; + + @override + bool get pasteEnabled => + _clipboardStatus == null || + _clipboardStatus!.value == ClipboardStatus.pasteable; + + @override + bool get selectAllEnabled => textEditingValue.text.isNotEmpty; + @override void copySelection(SelectionChangedCause cause) { final TextSelection copyRange = textEditingValue.selection; @@ -599,17 +615,6 @@ class BasicTextInputClientState extends State _clipboardStatus?.update(); } - @override - void hideToolbar([bool hideHandles = true]) { - if (hideHandles) { - // Hide the handles and the toolbar. - _selectionOverlay?.hide(); - } else if (_selectionOverlay?.toolbarIsVisible ?? false) { - // Hide only the toolbar but not the handles. - _selectionOverlay?.hideToolbar(); - } - } - @override Future pasteText(SelectionChangedCause cause) async { final TextSelection pasteRange = textEditingValue.selection; @@ -649,6 +654,18 @@ class BasicTextInputClientState extends State ), cause, ); + if (cause == SelectionChangedCause.toolbar) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + break; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + hideToolbar(); + } + } } @override @@ -699,6 +716,17 @@ class BasicTextInputClientState extends State } } + @override + void hideToolbar([bool hideHandles = true]) { + if (hideHandles) { + // Hide the handles and the toolbar. + _selectionOverlay?.hide(); + } else if (_selectionOverlay?.toolbarIsVisible ?? false) { + // Hide only the toolbar but not the handles. + _selectionOverlay?.hideToolbar(); + } + } + /// For TextSelection. final LayerLink _startHandleLayerLink = LayerLink(); final LayerLink _endHandleLayerLink = LayerLink(); @@ -711,14 +739,14 @@ class BasicTextInputClientState extends State void _handleSelectionChanged( TextSelection selection, SelectionChangedCause? cause) { // We return early if the selection is not valid. This can happen when the - // text of [EditableText] is updated at the same time as the selection is + // text of the editable is updated at the same time as the selection is // changed by a gesture event. if (!widget.controller.isSelectionWithinTextBounds(selection)) return; widget.controller.selection = selection; // This will show the keyboard for all selection changes on the - // EditableText except for those triggered by a keyboard input. + // editable except for those triggered by a keyboard input. // Typically BasicTextInputClient shouldn't take user keyboard input if // it's not focused already. switch (cause) { @@ -738,28 +766,12 @@ class BasicTextInputClientState extends State } break; } - if (widget.selectionControls == null) { + if (widget.selectionControls == null && widget.contextMenuBuilder == null) { _selectionOverlay?.dispose(); _selectionOverlay = null; } else { if (_selectionOverlay == null) { - _selectionOverlay = TextSelectionOverlay( - clipboardStatus: _clipboardStatus, - context: context, - value: _value, - debugRequiredFor: widget, - toolbarLayerLink: _toolbarLayerLink, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - renderObject: renderEditable, - selectionControls: widget.selectionControls, - selectionDelegate: this, - dragStartBehavior: DragStartBehavior.start, - onSelectionHandleTapped: () { - _toggleToolbar(); - }, - magnifierConfiguration: TextMagnifierConfiguration.disabled, - ); + _selectionOverlay = _createSelectionOverlay(); } else { _selectionOverlay!.update(_value); } @@ -780,6 +792,136 @@ class BasicTextInputClientState extends State } } + TextSelectionOverlay _createSelectionOverlay() { + final TextSelectionOverlay selectionOverlay = TextSelectionOverlay( + clipboardStatus: _clipboardStatus, + context: context, + value: _value, + debugRequiredFor: widget, + toolbarLayerLink: _toolbarLayerLink, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + renderObject: renderEditable, + selectionControls: widget.selectionControls, + selectionDelegate: this, + dragStartBehavior: DragStartBehavior.start, + onSelectionHandleTapped: () { + _toggleToolbar(); + }, + contextMenuBuilder: widget.contextMenuBuilder == null || kIsWeb + ? null + : (context) { + return widget.contextMenuBuilder!( + context, + _clipboardStatus!.value, + copyEnabled + ? () => copySelection(SelectionChangedCause.toolbar) + : null, + cutEnabled + ? () => cutSelection(SelectionChangedCause.toolbar) + : null, + pasteEnabled + ? () => pasteText(SelectionChangedCause.toolbar) + : null, + selectAllEnabled + ? () => selectAll(SelectionChangedCause.toolbar) + : null, + _contextMenuAnchors, + ); + }, + magnifierConfiguration: TextMagnifierConfiguration.disabled, + ); + + return selectionOverlay; + } + + void _toggleToolbar() { + final TextSelectionOverlay selectionOverlay = + _selectionOverlay ??= _createSelectionOverlay(); + + if (selectionOverlay.toolbarIsVisible) { + hideToolbar(false); + } else { + showToolbar(); + } + } + + // When the framework's text editing value changes we should update the text editing + // value contained within the selection overlay or we might observe unexpected behavior. + void _updateOrDisposeOfSelectionOverlayIfNeeded() { + if (_selectionOverlay != null) { + if (_hasFocus) { + _selectionOverlay!.update(_value); + } else { + _selectionOverlay!.dispose(); + _selectionOverlay = null; + } + } + } + + /// Gets the line heights at the start and end of the selection for the given + /// editable. + _GlyphHeights _getGlyphHeights() { + final TextSelection selection = textEditingValue.selection; + + // Only calculate handle rects if the text in the previous frame + // is the same as the text in the current frame. This is done because + // widget.renderObject contains the renderEditable from the previous frame. + // If the text changed between the current and previous frames then + // widget.renderObject.getRectForComposingRange might fail. In cases where + // the current frame is different from the previous we fall back to + // renderObject.preferredLineHeight. + final InlineSpan span = renderEditable.text!; + final String prevText = span.toPlainText(); + final String currText = textEditingValue.text; + if (prevText != currText || !selection.isValid || selection.isCollapsed) { + return _GlyphHeights( + start: renderEditable.preferredLineHeight, + end: renderEditable.preferredLineHeight, + ); + } + + final String selectedGraphemes = selection.textInside(currText); + final int firstSelectedGraphemeExtent = + selectedGraphemes.characters.first.length; + final Rect? startCharacterRect = + renderEditable.getRectForComposingRange(TextRange( + start: selection.start, + end: selection.start + firstSelectedGraphemeExtent, + )); + final int lastSelectedGraphemeExtent = + selectedGraphemes.characters.last.length; + final Rect? endCharacterRect = + renderEditable.getRectForComposingRange(TextRange( + start: selection.end - lastSelectedGraphemeExtent, + end: selection.end, + )); + return _GlyphHeights( + start: startCharacterRect?.height ?? renderEditable.preferredLineHeight, + end: endCharacterRect?.height ?? renderEditable.preferredLineHeight, + ); + } + + /// Returns the anchor points for the default context menu. + TextSelectionToolbarAnchors get _contextMenuAnchors { + if (renderEditable.lastSecondaryTapDownPosition != null) { + return TextSelectionToolbarAnchors( + primaryAnchor: renderEditable.lastSecondaryTapDownPosition!, + ); + } + + final _GlyphHeights glyphHeights = _getGlyphHeights(); + final TextSelection selection = textEditingValue.selection; + final List points = + renderEditable.getEndpointsForSelection(selection); + return TextSelectionToolbarAnchors.fromSelection( + renderBox: renderEditable, + startGlyphHeight: glyphHeights.start, + endGlyphHeight: glyphHeights.end, + selectionEndpoints: points, + ); + } + @override Widget build(BuildContext context) { return Actions( @@ -1018,3 +1160,18 @@ class _Editable extends MultiChildRenderObjectWidget { ..setPromptRectRange(promptRectRange); } } + +/// The start and end glyph heights of some range of text. +@immutable +class _GlyphHeights { + const _GlyphHeights({ + required this.start, + required this.end, + }); + + /// The glyph height of the first line. + final double start; + + /// The glyph height of the last line. + final double end; +}