Browse Source

Merge pull request #646 from LucasXu0/feat/flowy_editor

feat: add toString function info TextNode and fix example build problem
Lucas.Xu 2 years ago
parent
commit
52521396af

+ 758 - 0
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>[_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>[
+///       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<FlowySelectableText> createState() => _FlowySelectableTextState();
+
+  @override
+  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+    super.debugFillProperties(properties);
+    properties
+        .add(DiagnosticsProperty<String>('data', data, defaultValue: null));
+    properties.add(DiagnosticsProperty<String>('semanticsLabel', semanticsLabel,
+        defaultValue: null));
+    properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode,
+        defaultValue: null));
+    properties.add(
+        DiagnosticsProperty<TextStyle>('style', style, defaultValue: null));
+    properties.add(
+        DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false));
+    properties.add(DiagnosticsProperty<bool>('showCursor', showCursor,
+        defaultValue: false));
+    properties.add(IntProperty('minLines', minLines, defaultValue: null));
+    properties.add(IntProperty('maxLines', maxLines, defaultValue: null));
+    properties.add(
+        EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null));
+    properties.add(EnumProperty<TextDirection>('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<Radius>('cursorRadius', cursorRadius,
+        defaultValue: null));
+    properties.add(DiagnosticsProperty<Color>('cursorColor', cursorColor,
+        defaultValue: null));
+    properties.add(FlagProperty('selectionEnabled',
+        value: selectionEnabled,
+        defaultValue: true,
+        ifFalse: 'selection disabled'));
+    properties.add(DiagnosticsProperty<TextSelectionControls>(
+        'selectionControls', selectionControls,
+        defaultValue: null));
+    properties.add(DiagnosticsProperty<ScrollPhysics>(
+        'scrollPhysics', scrollPhysics,
+        defaultValue: null));
+    properties.add(DiagnosticsProperty<TextHeightBehavior>(
+        'textHeightBehavior', textHeightBehavior,
+        defaultValue: null));
+  }
+}
+
+class _FlowySelectableTextState extends State<FlowySelectableText>
+    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<EditableTextState> editableTextKey =
+      GlobalKey<EditableTextState>();
+
+  @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,
+      ),
+    );
+  }
+}

+ 161 - 229
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:flowy_editor/flowy_editor.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter/services.dart';
 import 'package:url_launcher/url_launcher_string.dart';
 import 'package:url_launcher/url_launcher_string.dart';
+import 'flowy_selectable_text.dart';
 
 
 class TextNodeBuilder extends NodeWidgetBuilder {
 class TextNodeBuilder extends NodeWidgetBuilder {
   TextNodeBuilder.create({
   TextNodeBuilder.create({
@@ -23,98 +24,6 @@ class TextNodeBuilder extends NodeWidgetBuilder {
   }
   }
 }
 }
 
 
-extension on Attributes {
-  TextStyle toTextStyle() {
-    return TextStyle(
-      color: this['color'] != null ? Colors.red : Colors.black,
-      fontSize: this['font-size'] != null ? 30 : 15,
-    );
-  }
-}
-
-TextSpan _textInsertToTextSpan(TextInsert textInsert) {
-  FontWeight? fontWeight;
-  FontStyle? fontStyle;
-  TextDecoration? decoration;
-  GestureRecognizer? gestureRecognizer;
-  Color? color;
-  final attributes = textInsert.attributes;
-  if (attributes?['bold'] == true) {
-    fontWeight = FontWeight.bold;
-  }
-  if (attributes?['italic'] == true) {
-    fontStyle = FontStyle.italic;
-  }
-  if (attributes?["underline"] == true) {
-    decoration = TextDecoration.underline;
-  }
-  if (attributes?["href"] is String) {
-    color = const Color.fromARGB(255, 55, 120, 245);
-    decoration = TextDecoration.underline;
-    gestureRecognizer = TapGestureRecognizer()
-      ..onTap = () {
-        // TODO: open the link
-      };
-  }
-  return TextSpan(
-      text: textInsert.content,
-      style: TextStyle(
-        fontWeight: fontWeight,
-        fontStyle: fontStyle,
-        decoration: decoration,
-        color: color,
-      ),
-      recognizer: gestureRecognizer);
-}
-
-extension on TextNode {
-  List<TextSpan> toTextSpans() {
-    final result = <TextSpan>[];
-
-    for (final op in delta.operations) {
-      if (op is TextInsert) {
-        result.add(_textInsertToTextSpan(op));
-      }
-    }
-
-    return result;
-  }
-}
-
-TextSelection? _globalSelectionToLocal(Node node, Selection? globalSel) {
-  if (globalSel == null) {
-    return null;
-  }
-  final nodePath = node.path;
-
-  if (!pathEquals(nodePath, globalSel.start.path)) {
-    return null;
-  }
-  if (globalSel.isCollapsed()) {
-    return TextSelection(
-        baseOffset: globalSel.start.offset, extentOffset: globalSel.end.offset);
-  } else {
-    if (pathEquals(globalSel.start.path, globalSel.end.path)) {
-      return TextSelection(
-          baseOffset: globalSel.start.offset,
-          extentOffset: globalSel.end.offset);
-    }
-  }
-  return null;
-}
-
-Selection? _localSelectionToGlobal(Node node, TextSelection? sel) {
-  if (sel == null) {
-    return null;
-  }
-  final nodePath = node.path;
-
-  return Selection(
-    start: Position(path: nodePath, offset: sel.baseOffset),
-    end: Position(path: nodePath, offset: sel.extentOffset),
-  );
-}
-
 class _TextNodeWidget extends StatefulWidget {
 class _TextNodeWidget extends StatefulWidget {
   final Node node;
   final Node node;
   final EditorState editorState;
   final EditorState editorState;
@@ -129,27 +38,82 @@ class _TextNodeWidget extends StatefulWidget {
   State<_TextNodeWidget> createState() => __TextNodeWidgetState();
   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>
 class __TextNodeWidgetState extends State<_TextNodeWidget>
     implements DeltaTextInputClient {
     implements DeltaTextInputClient {
-  final _focusNode = FocusNode(debugLabel: "input");
   TextNode get node => widget.node as TextNode;
   TextNode get node => widget.node as TextNode;
   EditorState get editorState => widget.editorState;
   EditorState get editorState => widget.editorState;
 
 
-  TextEditingValue get textEditingValue => TextEditingValue(
-        text: node.toRawString(),
-      );
-
   TextInputConnection? _textInputConnection;
   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) {
   _backDeleteTextAtSelection(TextSelection? sel) {
     if (sel == null) {
     if (sel == null) {
       return;
       return;
@@ -190,75 +154,11 @@ class __TextNodeWidgetState extends State<_TextNodeWidget>
 
 
   _setEditingStateFromGlobal() {
   _setEditingStateFromGlobal() {
     _textInputConnection?.setEditingState(TextEditingValue(
     _textInputConnection?.setEditingState(TextEditingValue(
-        text: _textContentOfDelta(node.delta),
+        text: node.toRawString(),
         selection: _globalSelectionToLocal(node, editorState.cursorSelection) ??
         selection: _globalSelectionToLocal(node, editorState.cursorSelection) ??
             const TextSelection.collapsed(offset: 0)));
             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: _textContentOfDelta(node.delta),
-                    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
   @override
   void connectionClosed() {
   void connectionClosed() {
     // TODO: implement connectionClosed
     // TODO: implement connectionClosed
@@ -271,7 +171,7 @@ class __TextNodeWidgetState extends State<_TextNodeWidget>
   @override
   @override
   // TODO: implement currentTextEditingValue
   // TODO: implement currentTextEditingValue
   TextEditingValue? get currentTextEditingValue => TextEditingValue(
   TextEditingValue? get currentTextEditingValue => TextEditingValue(
-      text: _textContentOfDelta(node.delta),
+      text: node.toRawString(),
       selection: _globalSelectionToLocal(node, editorState.cursorSelection) ??
       selection: _globalSelectionToLocal(node, editorState.cursorSelection) ??
           const TextSelection.collapsed(offset: 0));
           const TextSelection.collapsed(offset: 0));
 
 
@@ -334,69 +234,101 @@ class __TextNodeWidgetState extends State<_TextNodeWidget>
 }
 }
 
 
 extension on TextNode {
 extension on TextNode {
-  List<TextSpan> toTextSpans() => delta.operations
-      .whereType<TextInsert>()
-      .map((op) => _textInsertToTextSpan(op))
-      .toList();
-
-  String toRawString() => delta.operations
-      .whereType<TextInsert>()
-      .map((op) => op.content)
-      .toString();
+  TextSpan toTextSpan() => TextSpan(
+      children: delta.operations
+          .whereType<TextInsert>()
+          .map((op) => op.toTextSpan())
+          .toList());
 }
 }
 
 
-TextSpan _textInsertToTextSpan(TextInsert textInsert) {
-  FontWeight? fontWeight;
-  FontStyle? fontStyle;
-  TextDecoration? decoration;
-  GestureRecognizer? gestureRecognizer;
-  Color? color;
-  Color highLightColor = Colors.transparent;
-  double fontSize = 16.0;
-  final attributes = textInsert.attributes;
-  if (attributes?['bold'] == true) {
-    fontWeight = FontWeight.bold;
-  }
-  if (attributes?['italic'] == true) {
-    fontStyle = FontStyle.italic;
-  }
-  if (attributes?['underline'] == true) {
-    decoration = TextDecoration.underline;
-  }
-  if (attributes?['strikethrough'] == true) {
-    decoration = TextDecoration.lineThrough;
+extension on TextInsert {
+  TextSpan toTextSpan() {
+    FontWeight? fontWeight;
+    FontStyle? fontStyle;
+    TextDecoration? decoration;
+    GestureRecognizer? gestureRecognizer;
+    Color? color;
+    Color highLightColor = Colors.transparent;
+    double fontSize = 16.0;
+    final attributes = this.attributes;
+    if (attributes?['bold'] == true) {
+      fontWeight = FontWeight.bold;
+    }
+    if (attributes?['italic'] == true) {
+      fontStyle = FontStyle.italic;
+    }
+    if (attributes?['underline'] == true) {
+      decoration = TextDecoration.underline;
+    }
+    if (attributes?['strikethrough'] == true) {
+      decoration = TextDecoration.lineThrough;
+    }
+    if (attributes?['highlight'] is String) {
+      highLightColor = Color(int.parse(attributes!['highlight']));
+    }
+    if (attributes?['href'] is String) {
+      color = const Color.fromARGB(255, 55, 120, 245);
+      decoration = TextDecoration.underline;
+      gestureRecognizer = TapGestureRecognizer()
+        ..onTap = () {
+          launchUrlString(attributes?['href']);
+        };
+    }
+    final heading = attributes?['heading'] as String?;
+    if (heading != null) {
+      // TODO: make it better
+      if (heading == 'h1') {
+        fontSize = 30.0;
+      } else if (heading == 'h2') {
+        fontSize = 20.0;
+      }
+      fontWeight = FontWeight.bold;
+    }
+    return TextSpan(
+      text: content,
+      style: TextStyle(
+        fontWeight: fontWeight,
+        fontStyle: fontStyle,
+        decoration: decoration,
+        color: color,
+        fontSize: fontSize,
+        backgroundColor: highLightColor,
+      ),
+      recognizer: gestureRecognizer,
+    );
   }
   }
-  if (attributes?['highlight'] is String) {
-    highLightColor = Color(int.parse(attributes!['highlight']));
+}
+
+TextSelection? _globalSelectionToLocal(Node node, Selection? globalSel) {
+  if (globalSel == null) {
+    return null;
   }
   }
-  if (attributes?['href'] is String) {
-    color = const Color.fromARGB(255, 55, 120, 245);
-    decoration = TextDecoration.underline;
-    gestureRecognizer = TapGestureRecognizer()
-      ..onTap = () {
-        launchUrlString(attributes?['href']);
-      };
+  final nodePath = node.path;
+
+  if (!pathEquals(nodePath, globalSel.start.path)) {
+    return null;
   }
   }
-  final heading = attributes?['heading'] as String?;
-  if (heading != null) {
-    // TODO: make it better
-    if (heading == 'h1') {
-      fontSize = 30.0;
-    } else if (heading == 'h2') {
-      fontSize = 20.0;
+  if (globalSel.isCollapsed()) {
+    return TextSelection(
+        baseOffset: globalSel.start.offset, extentOffset: globalSel.end.offset);
+  } else {
+    if (pathEquals(globalSel.start.path, globalSel.end.path)) {
+      return TextSelection(
+          baseOffset: globalSel.start.offset,
+          extentOffset: globalSel.end.offset);
     }
     }
-    fontWeight = FontWeight.bold;
   }
   }
-  return TextSpan(
-    text: textInsert.content,
-    style: TextStyle(
-      fontWeight: fontWeight,
-      fontStyle: fontStyle,
-      decoration: decoration,
-      color: color,
-      fontSize: fontSize,
-      backgroundColor: highLightColor,
-    ),
-    recognizer: gestureRecognizer,
+  return null;
+}
+
+Selection? _localSelectionToGlobal(Node node, TextSelection? sel) {
+  if (sel == null) {
+    return null;
+  }
+  final nodePath = node.path;
+
+  return Selection(
+    start: Position(path: nodePath, offset: sel.baseOffset),
+    end: Position(path: nodePath, offset: sel.extentOffset),
   );
   );
 }
 }

+ 5 - 0
frontend/app_flowy/packages/flowy_editor/lib/document/node.dart

@@ -183,4 +183,9 @@ class TextNode extends Node {
     map['delta'] = _delta.toJson();
     map['delta'] = _delta.toJson();
     return map;
     return map;
   }
   }
+
+  String toRawString() => _delta.operations
+      .whereType<TextInsert>()
+      .map((op) => op.content)
+      .toString();
 }
 }