From ce953d802acb0d800ff91dd08161d8c616974c74 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Wed, 20 Jul 2022 10:54:35 +0800 Subject: [PATCH] feat: duplicate selectable_text.dart from flutter and modify selection implement --- .../lib/plugin/flowy_selectable_text.dart | 758 ++++++++++++++++++ .../example/lib/plugin/text_node_widget.dart | 160 ++-- .../flowy_editor/lib/document/node.dart | 10 +- 3 files changed, 839 insertions(+), 89 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/example/lib/plugin/flowy_selectable_text.dart diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/flowy_selectable_text.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/flowy_selectable_text.dart new file mode 100644 index 0000000000..0454f1cdc1 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/flowy_selectable_text.dart @@ -0,0 +1,758 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; + +import 'package:flutter/material.dart'; + +/// An eyeballed value that moves the cursor slightly left of where it is +/// rendered for text on Android so its positioning more accurately matches the +/// native iOS text cursor positioning. +/// +/// This value is in device pixels, not logical pixels as is typically used +/// throughout the codebase. +const int iOSHorizontalOffset = -2; + +class _TextSpanEditingController extends TextEditingController { + _TextSpanEditingController({required TextSpan textSpan}) + : assert(textSpan != null), + _textSpan = textSpan, + super(text: textSpan.toPlainText(includeSemanticsLabels: false)); + + final TextSpan _textSpan; + + @override + TextSpan buildTextSpan( + {required BuildContext context, + TextStyle? style, + required bool withComposing}) { + // This does not care about composing. + return TextSpan( + style: style, + children: [_textSpan], + ); + } + + @override + set text(String? newText) { + // This should never be reached. + throw UnimplementedError(); + } +} + +class _SelectableTextSelectionGestureDetectorBuilder + extends TextSelectionGestureDetectorBuilder { + _SelectableTextSelectionGestureDetectorBuilder({ + required _FlowySelectableTextState state, + }) : _state = state, + super(delegate: state); + + final _FlowySelectableTextState _state; + + @override + void onForcePressStart(ForcePressDetails details) { + super.onForcePressStart(details); + if (delegate.selectionEnabled && shouldShowSelectionToolbar) { + editableText.showToolbar(); + } + } + + @override + void onForcePressEnd(ForcePressDetails details) { + // Not required. + } + + @override + void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { + if (delegate.selectionEnabled) { + renderEditable.selectWordsInRange( + from: details.globalPosition - details.offsetFromOrigin, + to: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + } + } + + @override + void onSingleTapUp(TapUpDetails details) { + editableText.hideToolbar(); + if (delegate.selectionEnabled) { + switch (Theme.of(_state.context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + // renderEditable.selectWordEdge(cause: SelectionChangedCause.tap); + // break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + break; + } + } + _state.widget.onTap?.call(); + } + + @override + void onSingleLongTapStart(LongPressStartDetails details) { + if (delegate.selectionEnabled) { + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + Feedback.forLongPress(_state.context); + } + } +} + +/// A run of selectable text with a single style. +/// +/// The [FlowySelectableText] widget displays a string of text with a single style. +/// The string might break across multiple lines or might all be displayed on +/// the same line depending on the layout constraints. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=ZSU3ZXOs6hc} +/// +/// The [style] argument is optional. When omitted, the text will use the style +/// from the closest enclosing [DefaultTextStyle]. If the given style's +/// [TextStyle.inherit] property is true (the default), the given style will +/// be merged with the closest enclosing [DefaultTextStyle]. This merging +/// behavior is useful, for example, to make the text bold while using the +/// default font family and size. +/// +/// {@macro flutter.material.textfield.wantKeepAlive} +/// +/// {@tool snippet} +/// +/// ```dart +/// const SelectableText( +/// 'Hello! How are you?', +/// textAlign: TextAlign.center, +/// style: TextStyle(fontWeight: FontWeight.bold), +/// ) +/// ``` +/// {@end-tool} +/// +/// Using the [SelectableText.rich] constructor, the [FlowySelectableText] widget can +/// display a paragraph with differently styled [TextSpan]s. The sample +/// that follows displays "Hello beautiful world" with different styles +/// for each word. +/// +/// {@tool snippet} +/// +/// ```dart +/// const SelectableText.rich( +/// TextSpan( +/// text: 'Hello', // default text style +/// children: [ +/// TextSpan(text: ' beautiful ', style: TextStyle(fontStyle: FontStyle.italic)), +/// TextSpan(text: 'world', style: TextStyle(fontWeight: FontWeight.bold)), +/// ], +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Interactivity +/// +/// To make [FlowySelectableText] react to touch events, use callback [onTap] to achieve +/// the desired behavior. +/// +/// See also: +/// +/// * [Text], which is the non selectable version of this widget. +/// * [TextField], which is the editable version of this widget. +class FlowySelectableText extends StatefulWidget { + /// Creates a selectable text widget. + /// + /// If the [style] argument is null, the text will use the style from the + /// closest enclosing [DefaultTextStyle]. + /// + + /// The [showCursor], [autofocus], [dragStartBehavior], [selectionHeightStyle], + /// [selectionWidthStyle] and [data] parameters must not be null. If specified, + /// the [maxLines] argument must be greater than zero. + const FlowySelectableText( + String this.data, { + Key? key, + this.focusNode, + this.style, + this.strutStyle, + this.textAlign, + this.textDirection, + this.textScaleFactor, + this.showCursor = false, + this.autofocus = false, + ToolbarOptions? toolbarOptions, + this.minLines, + this.maxLines, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius, + this.cursorColor, + this.selectionHeightStyle = ui.BoxHeightStyle.tight, + this.selectionWidthStyle = ui.BoxWidthStyle.tight, + this.dragStartBehavior = DragStartBehavior.start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.scrollPhysics, + this.semanticsLabel, + this.textHeightBehavior, + this.textWidthBasis, + this.onSelectionChanged, + }) : assert(showCursor != null), + assert(autofocus != null), + assert(dragStartBehavior != null), + assert(selectionHeightStyle != null), + assert(selectionWidthStyle != null), + assert(maxLines == null || maxLines > 0), + assert(minLines == null || minLines > 0), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert( + data != null, + 'A non-null String must be provided to a SelectableText widget.', + ), + textSpan = null, + toolbarOptions = toolbarOptions ?? + const ToolbarOptions( + selectAll: true, + copy: true, + ), + super(key: key); + + /// Creates a selectable text widget with a [TextSpan]. + /// + /// The [textSpan] parameter must not be null and only contain [TextSpan] in + /// [textSpan].children. Other type of [InlineSpan] is not allowed. + /// + /// The [autofocus] and [dragStartBehavior] arguments must not be null. + const FlowySelectableText.rich( + TextSpan this.textSpan, { + Key? key, + this.focusNode, + this.style, + this.strutStyle, + this.textAlign, + this.textDirection, + this.textScaleFactor, + this.showCursor = false, + this.autofocus = false, + ToolbarOptions? toolbarOptions, + this.minLines, + this.maxLines, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius, + this.cursorColor, + this.selectionHeightStyle = ui.BoxHeightStyle.tight, + this.selectionWidthStyle = ui.BoxWidthStyle.tight, + this.dragStartBehavior = DragStartBehavior.start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.scrollPhysics, + this.semanticsLabel, + this.textHeightBehavior, + this.textWidthBasis, + this.onSelectionChanged, + }) : assert(showCursor != null), + assert(autofocus != null), + assert(dragStartBehavior != null), + assert(maxLines == null || maxLines > 0), + assert(minLines == null || minLines > 0), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert( + textSpan != null, + 'A non-null TextSpan must be provided to a SelectableText.rich widget.', + ), + data = null, + toolbarOptions = toolbarOptions ?? + const ToolbarOptions( + selectAll: true, + copy: true, + ), + super(key: key); + + /// The text to display. + /// + /// This will be null if a [textSpan] is provided instead. + final String? data; + + /// The text to display as a [TextSpan]. + /// + /// This will be null if [data] is provided instead. + final TextSpan? textSpan; + + /// Defines the focus for this widget. + /// + /// Text is only selectable when widget is focused. + /// + /// The [focusNode] is a long-lived object that's typically managed by a + /// [StatefulWidget] parent. See [FocusNode] for more information. + /// + /// To give the focus to this widget, provide a [focusNode] and then + /// use the current [FocusScope] to request the focus: + /// + /// ```dart + /// FocusScope.of(context).requestFocus(myFocusNode); + /// ``` + /// + /// This happens automatically when the widget is tapped. + /// + /// To be notified when the widget gains or loses the focus, add a listener + /// to the [focusNode]: + /// + /// ```dart + /// focusNode.addListener(() { print(myFocusNode.hasFocus); }); + /// ``` + /// + /// If null, this widget will create its own [FocusNode] with + /// [FocusNode.skipTraversal] parameter set to `true`, which causes the widget + /// to be skipped over during focus traversal. + final FocusNode? focusNode; + + /// The style to use for the text. + /// + /// If null, defaults [DefaultTextStyle] of context. + final TextStyle? style; + + /// {@macro flutter.widgets.editableText.strutStyle} + final StrutStyle? strutStyle; + + /// {@macro flutter.widgets.editableText.textAlign} + final TextAlign? textAlign; + + /// {@macro flutter.widgets.editableText.textDirection} + final TextDirection? textDirection; + + /// {@macro flutter.widgets.editableText.textScaleFactor} + final double? textScaleFactor; + + /// {@macro flutter.widgets.editableText.autofocus} + final bool autofocus; + + /// {@macro flutter.widgets.editableText.minLines} + final int? minLines; + + /// {@macro flutter.widgets.editableText.maxLines} + final int? maxLines; + + /// {@macro flutter.widgets.editableText.showCursor} + final bool showCursor; + + /// {@macro flutter.widgets.editableText.cursorWidth} + final double cursorWidth; + + /// {@macro flutter.widgets.editableText.cursorHeight} + final double? cursorHeight; + + /// {@macro flutter.widgets.editableText.cursorRadius} + final Radius? cursorRadius; + + /// The color to use when painting the cursor. + /// + /// Defaults to the theme's `cursorColor` when null. + final Color? cursorColor; + + /// Controls how tall the selection highlight boxes are computed to be. + /// + /// See [ui.BoxHeightStyle] for details on available styles. + final ui.BoxHeightStyle selectionHeightStyle; + + /// Controls how wide the selection highlight boxes are computed to be. + /// + /// See [ui.BoxWidthStyle] for details on available styles. + final ui.BoxWidthStyle selectionWidthStyle; + + /// {@macro flutter.widgets.editableText.enableInteractiveSelection} + final bool enableInteractiveSelection; + + /// {@macro flutter.widgets.editableText.selectionControls} + final TextSelectionControls? selectionControls; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// Configuration of toolbar options. + /// + /// Paste and cut will be disabled regardless. + /// + /// If not set, select all and copy will be enabled by default. + final ToolbarOptions toolbarOptions; + + /// {@macro flutter.widgets.editableText.selectionEnabled} + bool get selectionEnabled => enableInteractiveSelection; + + /// Called when the user taps on this selectable text. + /// + /// The selectable text builds a [GestureDetector] to handle input events like tap, + /// to trigger focus requests, to move the caret, adjust the selection, etc. + /// Handling some of those events by wrapping the selectable text with a competing + /// GestureDetector is problematic. + /// + /// To unconditionally handle taps, without interfering with the selectable text's + /// internal gesture detector, provide this callback. + /// + /// To be notified when the text field gains or loses the focus, provide a + /// [focusNode] and add a listener to that. + /// + /// To listen to arbitrary pointer events without competing with the + /// selectable text's internal gesture detector, use a [Listener]. + final GestureTapCallback? onTap; + + /// {@macro flutter.widgets.editableText.scrollPhysics} + final ScrollPhysics? scrollPhysics; + + /// {@macro flutter.widgets.Text.semanticsLabel} + final String? semanticsLabel; + + /// {@macro dart.ui.textHeightBehavior} + final TextHeightBehavior? textHeightBehavior; + + /// {@macro flutter.painting.textPainter.textWidthBasis} + final TextWidthBasis? textWidthBasis; + + /// {@macro flutter.widgets.editableText.onSelectionChanged} + final SelectionChangedCallback? onSelectionChanged; + + @override + State createState() => _FlowySelectableTextState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + .add(DiagnosticsProperty('data', data, defaultValue: null)); + properties.add(DiagnosticsProperty('semanticsLabel', semanticsLabel, + defaultValue: null)); + properties.add(DiagnosticsProperty('focusNode', focusNode, + defaultValue: null)); + properties.add( + DiagnosticsProperty('style', style, defaultValue: null)); + properties.add( + DiagnosticsProperty('autofocus', autofocus, defaultValue: false)); + properties.add(DiagnosticsProperty('showCursor', showCursor, + defaultValue: false)); + properties.add(IntProperty('minLines', minLines, defaultValue: null)); + properties.add(IntProperty('maxLines', maxLines, defaultValue: null)); + properties.add( + EnumProperty('textAlign', textAlign, defaultValue: null)); + properties.add(EnumProperty('textDirection', textDirection, + defaultValue: null)); + properties.add( + DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null)); + properties + .add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0)); + properties + .add(DoubleProperty('cursorHeight', cursorHeight, defaultValue: null)); + properties.add(DiagnosticsProperty('cursorRadius', cursorRadius, + defaultValue: null)); + properties.add(DiagnosticsProperty('cursorColor', cursorColor, + defaultValue: null)); + properties.add(FlagProperty('selectionEnabled', + value: selectionEnabled, + defaultValue: true, + ifFalse: 'selection disabled')); + properties.add(DiagnosticsProperty( + 'selectionControls', selectionControls, + defaultValue: null)); + properties.add(DiagnosticsProperty( + 'scrollPhysics', scrollPhysics, + defaultValue: null)); + properties.add(DiagnosticsProperty( + 'textHeightBehavior', textHeightBehavior, + defaultValue: null)); + } +} + +class _FlowySelectableTextState extends State + implements TextSelectionGestureDetectorBuilderDelegate { + EditableTextState? get _editableText => editableTextKey.currentState; + + late _TextSpanEditingController _controller; + + FocusNode? _focusNode; + FocusNode get _effectiveFocusNode => + widget.focusNode ?? (_focusNode ??= FocusNode(skipTraversal: true)); + + bool _showSelectionHandles = false; + + late _SelectableTextSelectionGestureDetectorBuilder + _selectionGestureDetectorBuilder; + + // API for TextSelectionGestureDetectorBuilderDelegate. + @override + late bool forcePressEnabled; + + @override + final GlobalKey editableTextKey = + GlobalKey(); + + @override + bool get selectionEnabled => widget.selectionEnabled; + // End of API for TextSelectionGestureDetectorBuilderDelegate. + + @override + void initState() { + super.initState(); + _selectionGestureDetectorBuilder = + _SelectableTextSelectionGestureDetectorBuilder(state: this); + _controller = _TextSpanEditingController( + textSpan: widget.textSpan ?? TextSpan(text: widget.data), + ); + _controller.addListener(_onControllerChanged); + } + + @override + void didUpdateWidget(FlowySelectableText oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.data != oldWidget.data || + widget.textSpan != oldWidget.textSpan) { + _controller.removeListener(_onControllerChanged); + _controller = _TextSpanEditingController( + textSpan: widget.textSpan ?? TextSpan(text: widget.data), + ); + _controller.addListener(_onControllerChanged); + } + if (_effectiveFocusNode.hasFocus && _controller.selection.isCollapsed) { + _showSelectionHandles = false; + } else { + _showSelectionHandles = true; + } + } + + @override + void dispose() { + _focusNode?.dispose(); + _controller.removeListener(_onControllerChanged); + super.dispose(); + } + + void _onControllerChanged() { + final bool showSelectionHandles = + !_effectiveFocusNode.hasFocus || !_controller.selection.isCollapsed; + if (showSelectionHandles == _showSelectionHandles) { + return; + } + setState(() { + _showSelectionHandles = showSelectionHandles; + }); + } + + TextSelection? _lastSeenTextSelection; + + void _handleSelectionChanged( + TextSelection selection, SelectionChangedCause? cause) { + final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause); + if (willShowSelectionHandles != _showSelectionHandles) { + setState(() { + _showSelectionHandles = willShowSelectionHandles; + }); + } + // TODO(chunhtai): The selection may be the same. We should remove this + // check once this is fixed https://github.com/flutter/flutter/issues/76349. + if (widget.onSelectionChanged != null && + _lastSeenTextSelection != selection) { + widget.onSelectionChanged!(selection, cause); + } + _lastSeenTextSelection = selection; + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + if (cause == SelectionChangedCause.longPress) { + _editableText?.bringIntoView(selection.base); + } + return; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + // Do nothing. + } + } + + /// Toggle the toolbar when a selection handle is tapped. + void _handleSelectionHandleTapped() { + if (_controller.selection.isCollapsed) { + _editableText!.toggleToolbar(); + } + } + + bool _shouldShowSelectionHandles(SelectionChangedCause? cause) { + // When the text field is activated by something that doesn't trigger the + // selection overlay, we shouldn't show the handles either. + if (!_selectionGestureDetectorBuilder.shouldShowSelectionToolbar) + return false; + + if (_controller.selection.isCollapsed) return false; + + if (cause == SelectionChangedCause.keyboard) return false; + + if (cause == SelectionChangedCause.longPress) return true; + + if (_controller.text.isNotEmpty) return true; + + return false; + } + + @override + Widget build(BuildContext context) { + // TODO(garyq): Assert to block WidgetSpans from being used here are removed, + // but we still do not yet have nice handling of things like carets, clipboard, + // and other features. We should add proper support. Currently, caret handling + // is blocked on SkParagraph switch and https://github.com/flutter/engine/pull/27010 + // should be landed in SkParagraph after the switch is complete. + assert(debugCheckHasMediaQuery(context)); + assert(debugCheckHasDirectionality(context)); + assert( + !(widget.style != null && + widget.style!.inherit == false && + (widget.style!.fontSize == null || + widget.style!.textBaseline == null)), + 'inherit false style must supply fontSize and textBaseline', + ); + + final ThemeData theme = Theme.of(context); + final TextSelectionThemeData selectionTheme = + TextSelectionTheme.of(context); + final FocusNode focusNode = _effectiveFocusNode; + + TextSelectionControls? textSelectionControls = widget.selectionControls; + final bool paintCursorAboveText; + final bool cursorOpacityAnimates; + Offset? cursorOffset; + Color? cursorColor = widget.cursorColor; + final Color selectionColor; + Radius? cursorRadius = widget.cursorRadius; + + switch (theme.platform) { + case TargetPlatform.iOS: + final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); + forcePressEnabled = true; + textSelectionControls ??= cupertinoTextSelectionControls; + paintCursorAboveText = true; + cursorOpacityAnimates = true; + cursorColor ??= + selectionTheme.cursorColor ?? cupertinoTheme.primaryColor; + selectionColor = selectionTheme.selectionColor ?? + cupertinoTheme.primaryColor.withOpacity(0.40); + cursorRadius ??= const Radius.circular(2.0); + cursorOffset = Offset( + iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); + break; + + case TargetPlatform.macOS: + final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); + forcePressEnabled = false; + textSelectionControls ??= cupertinoDesktopTextSelectionControls; + paintCursorAboveText = true; + cursorOpacityAnimates = true; + cursorColor ??= + selectionTheme.cursorColor ?? cupertinoTheme.primaryColor; + selectionColor = selectionTheme.selectionColor ?? + cupertinoTheme.primaryColor.withOpacity(0.40); + cursorRadius ??= const Radius.circular(2.0); + cursorOffset = Offset( + iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); + break; + + case TargetPlatform.android: + case TargetPlatform.fuchsia: + forcePressEnabled = false; + textSelectionControls ??= materialTextSelectionControls; + paintCursorAboveText = false; + cursorOpacityAnimates = false; + cursorColor ??= selectionTheme.cursorColor ?? theme.colorScheme.primary; + selectionColor = selectionTheme.selectionColor ?? + theme.colorScheme.primary.withOpacity(0.40); + break; + + case TargetPlatform.linux: + case TargetPlatform.windows: + forcePressEnabled = false; + textSelectionControls ??= desktopTextSelectionControls; + paintCursorAboveText = false; + cursorOpacityAnimates = false; + cursorColor ??= selectionTheme.cursorColor ?? theme.colorScheme.primary; + selectionColor = selectionTheme.selectionColor ?? + theme.colorScheme.primary.withOpacity(0.40); + break; + } + + final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context); + TextStyle? effectiveTextStyle = widget.style; + if (effectiveTextStyle == null || effectiveTextStyle.inherit) + effectiveTextStyle = defaultTextStyle.style.merge(widget.style); + if (MediaQuery.boldTextOverride(context)) + effectiveTextStyle = effectiveTextStyle + .merge(const TextStyle(fontWeight: FontWeight.bold)); + final Widget child = RepaintBoundary( + child: EditableText( + key: editableTextKey, + style: effectiveTextStyle, + readOnly: true, + textWidthBasis: + widget.textWidthBasis ?? defaultTextStyle.textWidthBasis, + textHeightBehavior: + widget.textHeightBehavior ?? defaultTextStyle.textHeightBehavior, + showSelectionHandles: _showSelectionHandles, + showCursor: widget.showCursor, + controller: _controller, + focusNode: focusNode, + strutStyle: widget.strutStyle ?? const StrutStyle(), + textAlign: + widget.textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start, + textDirection: widget.textDirection, + textScaleFactor: widget.textScaleFactor, + autofocus: widget.autofocus, + forceLine: false, + toolbarOptions: widget.toolbarOptions, + minLines: widget.minLines, + maxLines: widget.maxLines ?? defaultTextStyle.maxLines, + selectionColor: selectionColor, + selectionControls: + widget.selectionEnabled ? textSelectionControls : null, + onSelectionChanged: _handleSelectionChanged, + onSelectionHandleTapped: _handleSelectionHandleTapped, + rendererIgnoresPointer: true, + cursorWidth: widget.cursorWidth, + cursorHeight: widget.cursorHeight, + cursorRadius: cursorRadius, + cursorColor: cursorColor, + selectionHeightStyle: widget.selectionHeightStyle, + selectionWidthStyle: widget.selectionWidthStyle, + cursorOpacityAnimates: cursorOpacityAnimates, + cursorOffset: cursorOffset, + paintCursorAboveText: paintCursorAboveText, + backgroundCursorColor: CupertinoColors.inactiveGray, + enableInteractiveSelection: widget.enableInteractiveSelection, + dragStartBehavior: widget.dragStartBehavior, + scrollPhysics: widget.scrollPhysics, + autofillHints: null, + ), + ); + + return Semantics( + label: widget.semanticsLabel, + excludeSemantics: widget.semanticsLabel != null, + onLongPress: () { + _effectiveFocusNode.requestFocus(); + }, + child: _selectionGestureDetectorBuilder.buildGestureDetector( + behavior: HitTestBehavior.translucent, + child: child, + ), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart index df2039fbd0..0077707fe8 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flowy_editor/flowy_editor.dart'; import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher_string.dart'; +import 'flowy_selectable_text.dart'; class TextNodeBuilder extends NodeWidgetBuilder { TextNodeBuilder.create({ @@ -37,23 +38,82 @@ class _TextNodeWidget extends StatefulWidget { State<_TextNodeWidget> createState() => __TextNodeWidgetState(); } -String _textContentOfDelta(Delta delta) { - return delta.operations.fold("", (previousValue, element) { - if (element is TextInsert) { - return previousValue + element.content; - } - return previousValue; - }); -} - class __TextNodeWidgetState extends State<_TextNodeWidget> implements DeltaTextInputClient { - final _focusNode = FocusNode(debugLabel: "input"); TextNode get node => widget.node as TextNode; EditorState get editorState => widget.editorState; TextInputConnection? _textInputConnection; + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowySelectableText.rich( + node.toTextSpan(), + showCursor: true, + enableInteractiveSelection: true, + onSelectionChanged: _onSelectionChanged, + // autofocus: true, + focusNode: FocusNode( + onKey: _onKey, + ), + ), + if (node.children.isNotEmpty) + ...node.children.map( + (e) => editorState.renderPlugins.buildWidget( + context: NodeWidgetContext( + buildContext: context, + node: e, + editorState: editorState, + ), + ), + ), + const SizedBox( + height: 10, + ), + ], + ); + } + + KeyEventResult _onKey(FocusNode focusNode, RawKeyEvent event) { + if (event is RawKeyDownEvent) { + final sel = _globalSelectionToLocal(node, editorState.cursorSelection); + if (event.logicalKey == LogicalKeyboardKey.backspace) { + _backDeleteTextAtSelection(sel); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.delete) { + _forwardDeleteTextAtSelection(sel); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + } + + void _onSelectionChanged( + TextSelection selection, SelectionChangedCause? cause) { + _textInputConnection?.close(); + _textInputConnection = TextInput.attach( + this, + const TextInputConfiguration( + enableDeltaModel: true, + inputType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + ), + ); + debugPrint('selection: $selection'); + editorState.cursorSelection = _localSelectionToGlobal(node, selection); + _textInputConnection + ?..show() + ..setEditingState( + TextEditingValue( + text: node.toRawString(), + selection: selection, + ), + ); + } + _backDeleteTextAtSelection(TextSelection? sel) { if (sel == null) { return; @@ -94,78 +154,11 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> _setEditingStateFromGlobal() { _textInputConnection?.setEditingState(TextEditingValue( - text: node.toString(), + text: node.toRawString(), selection: _globalSelectionToLocal(node, editorState.cursorSelection) ?? const TextSelection.collapsed(offset: 0))); } - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - KeyboardListener( - focusNode: _focusNode, - onKeyEvent: ((value) { - if (value is KeyDownEvent || value is KeyRepeatEvent) { - final sel = - _globalSelectionToLocal(node, editorState.cursorSelection); - if (value.logicalKey.keyLabel == "Backspace") { - _backDeleteTextAtSelection(sel); - } else if (value.logicalKey.keyLabel == "Delete") { - _forwardDeleteTextAtSelection(sel); - } - } - }), - child: SelectableText.rich( - showCursor: true, - TextSpan( - children: node.toTextSpans(), - ), - onTap: () { - _focusNode.requestFocus(); - }, - onSelectionChanged: ((selection, cause) { - _textInputConnection?.close(); - _textInputConnection = TextInput.attach( - this, - const TextInputConfiguration( - enableDeltaModel: true, - inputType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - ), - ); - debugPrint('selection: $selection'); - editorState.cursorSelection = - _localSelectionToGlobal(node, selection); - _textInputConnection - ?..show() - ..setEditingState( - TextEditingValue( - text: node.toString(), - selection: selection, - ), - ); - }), - ), - ), - if (node.children.isNotEmpty) - ...node.children.map( - (e) => editorState.renderPlugins.buildWidget( - context: NodeWidgetContext( - buildContext: context, - node: e, - editorState: editorState, - ), - ), - ), - const SizedBox( - height: 10, - ), - ], - ); - } - @override void connectionClosed() { // TODO: implement connectionClosed @@ -178,7 +171,7 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> @override // TODO: implement currentTextEditingValue TextEditingValue? get currentTextEditingValue => TextEditingValue( - text: node.toString(), + text: node.toRawString(), selection: _globalSelectionToLocal(node, editorState.cursorSelection) ?? const TextSelection.collapsed(offset: 0)); @@ -241,10 +234,11 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> } extension on TextNode { - List toTextSpans() => delta.operations - .whereType() - .map((op) => op.toTextSpan()) - .toList(); + TextSpan toTextSpan() => TextSpan( + children: delta.operations + .whereType() + .map((op) => op.toTextSpan()) + .toList()); } extension on TextInsert { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart index 9d8b9906b0..58f32d31c0 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart @@ -184,10 +184,8 @@ class TextNode extends Node { return map; } - String toString() { - _delta.operations - .whereType() - .map((op) => op.content) - .toString(); - } + String toRawString() => _delta.operations + .whereType() + .map((op) => op.content) + .toString(); }