فهرست منبع

Merge pull request #743 from LucasXu0/feat/flowy_editor_input_service

Feat/flowy editor input service
Lucas.Xu 2 سال پیش
والد
کامیت
63d8e18051

+ 5 - 12
frontend/app_flowy/packages/flowy_editor/example/assets/example.json

@@ -3,17 +3,6 @@
     "type": "editor",
     "attributes": {},
     "children": [
-      {
-        "type": "text",
-        "delta": [
-          {
-            "insert": "Hello world"
-          }
-        ],
-        "attributes": {
-          "subtype": "quote"
-        }
-      },
       {
         "type": "image",
         "attributes": {
@@ -173,7 +162,11 @@
         "type": "text",
         "delta": [
           {
-            "insert": "Hello world"
+            "insert": "Hello "
+          },
+          {
+            "insert": "world",
+            "attributes": { "bold": true }
           }
         ],
         "attributes": {

+ 2 - 0
frontend/app_flowy/packages/flowy_editor/example/lib/main.dart

@@ -55,6 +55,7 @@ class MyHomePage extends StatefulWidget {
 
 class _MyHomePageState extends State<MyHomePage> {
   late EditorState _editorState;
+  final editorKey = GlobalKey();
   int page = 0;
 
   @override
@@ -116,6 +117,7 @@ class _MyHomePageState extends State<MyHomePage> {
             document: document,
           );
           return FlowyEditor(
+            key: editorKey,
             editorState: _editorState,
             keyEventHandlers: const [],
             customBuilders: {

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

@@ -275,6 +275,11 @@ class Delta {
 
   Delta([List<TextOperation>? ops]) : operations = ops ?? <TextOperation>[];
 
+  Delta addAll(List<TextOperation> textOps) {
+    textOps.forEach(add);
+    return this;
+  }
+
   Delta add(TextOperation textOp) {
     if (textOp.isEmpty) {
       return this;

+ 39 - 2
frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart

@@ -48,6 +48,10 @@ class TransactionBuilder {
     add(DeleteOperation(path: node.path, removedValue: node));
   }
 
+  deleteNodes(List<Node> nodes) {
+    nodes.forEach(deleteNode);
+  }
+
   textEdit(TextNode node, Delta Function() f) {
     beforeSelection = state.cursorSelection;
     final path = node.path;
@@ -59,8 +63,28 @@ class TransactionBuilder {
     add(TextEditOperation(path: path, delta: delta, inverted: inverted));
   }
 
-  insertText(TextNode node, int index, String content) {
-    textEdit(node, () => Delta().retain(index).insert(content));
+  mergeText(TextNode firstNode, TextNode secondNode,
+      {int? firstOffset, int secondOffset = 0}) {
+    final firstLength = firstNode.delta.length;
+    final secondLength = secondNode.delta.length;
+    textEdit(
+      firstNode,
+      () => Delta()
+        ..retain(firstOffset ?? firstLength)
+        ..delete(firstLength - (firstOffset ?? firstLength))
+        ..addAll(secondNode.delta.slice(secondOffset, secondLength).operations),
+    );
+    afterSelection = Selection.collapsed(
+      Position(
+        path: firstNode.path,
+        offset: firstOffset ?? firstLength,
+      ),
+    );
+  }
+
+  insertText(TextNode node, int index, String content,
+      [Attributes? attributes]) {
+    textEdit(node, () => Delta().retain(index).insert(content, attributes));
     afterSelection = Selection.collapsed(
         Position(path: node.path, offset: index + content.length));
   }
@@ -75,6 +99,19 @@ class TransactionBuilder {
         Selection.collapsed(Position(path: node.path, offset: index));
   }
 
+  replaceText(TextNode node, int index, int length, String content) {
+    textEdit(
+      node,
+      () => Delta().retain(index).delete(length).insert(content),
+    );
+    afterSelection = Selection.collapsed(
+      Position(
+        path: node.path,
+        offset: index + content.length,
+      ),
+    );
+  }
+
   add(Operation op) {
     final Operation? last = operations.isEmpty ? null : operations.last;
     if (last != null) {

+ 6 - 2
frontend/app_flowy/packages/flowy_editor/lib/render/selection/cursor_widget.dart

@@ -64,8 +64,12 @@ class CursorWidgetState extends State<CursorWidget> {
         link: widget.layerLink,
         offset: widget.rect.topCenter,
         showWhenUnlinked: true,
-        child: Container(
-          color: showCursor ? widget.color : Colors.transparent,
+        // Ignore the gestures in cursor
+        //  to solve the problem that cursor area cannot be selected.
+        child: IgnorePointer(
+          child: Container(
+            color: showCursor ? widget.color : Colors.transparent,
+          ),
         ),
       ),
     );

+ 6 - 2
frontend/app_flowy/packages/flowy_editor/lib/render/selection/flowy_selection_widget.dart → frontend/app_flowy/packages/flowy_editor/lib/render/selection/selection_widget.dart

@@ -25,8 +25,12 @@ class _SelectionWidgetState extends State<SelectionWidget> {
         link: widget.layerLink,
         offset: widget.rect.topLeft,
         showWhenUnlinked: true,
-        child: Container(
-          color: widget.color,
+        // Ignore the gestures in selection overlays
+        //  to solve the problem that selection areas cannot overlap.
+        child: IgnorePointer(
+          child: Container(
+            color: widget.color,
+          ),
         ),
       ),
     );

+ 40 - 23
frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart

@@ -1,24 +1,25 @@
+import 'package:flowy_editor/service/internal_key_event_handlers/delele_text_handler.dart';
+import 'package:flowy_editor/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart';
+import 'package:flutter/material.dart';
+
+import 'package:flowy_editor/editor_state.dart';
 import 'package:flowy_editor/render/editor/editor_entry.dart';
+import 'package:flowy_editor/render/rich_text/bulleted_list_text.dart';
 import 'package:flowy_editor/render/rich_text/checkbox_text.dart';
 import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart';
+import 'package:flowy_editor/render/rich_text/heading_text.dart';
+import 'package:flowy_editor/render/rich_text/number_list_text.dart';
+import 'package:flowy_editor/render/rich_text/quoted_text.dart';
 import 'package:flowy_editor/render/selection/floating_shortcut_widget.dart';
 import 'package:flowy_editor/service/input_service.dart';
-import 'package:flowy_editor/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart';
-import 'package:flowy_editor/service/render_plugin_service.dart';
-import 'package:flowy_editor/service/shortcut_service.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/arrow_keys_handler.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/delete_nodes_handler.dart';
-import 'package:flowy_editor/service/internal_key_event_handlers/delete_single_text_node_handler.dart';
+import 'package:flowy_editor/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/shortcut_handler.dart';
 import 'package:flowy_editor/service/keyboard_service.dart';
+import 'package:flowy_editor/service/render_plugin_service.dart';
 import 'package:flowy_editor/service/selection_service.dart';
-import 'package:flowy_editor/editor_state.dart';
-import 'package:flowy_editor/render/rich_text/bulleted_list_text.dart';
-import 'package:flowy_editor/render/rich_text/heading_text.dart';
-import 'package:flowy_editor/render/rich_text/number_list_text.dart';
-import 'package:flowy_editor/render/rich_text/quoted_text.dart';
-
-import 'package:flutter/material.dart';
+import 'package:flowy_editor/service/shortcut_service.dart';
 
 NodeWidgetBuilders defaultBuilders = {
   'editor': EditorEntryWidgetBuilder(),
@@ -30,6 +31,15 @@ NodeWidgetBuilders defaultBuilders = {
   'text/quote': QuotedTextNodeWidgetBuilder(),
 };
 
+List<FlowyKeyEventHandler> defaultKeyEventHandler = [
+  deleteTextHandler,
+  slashShortcutHandler,
+  flowyDeleteNodesHandler,
+  arrowKeysHandler,
+  enterInEdgeOfTextNodeHandler,
+  updateTextStyleByCommandXHandler,
+];
+
 class FlowyEditor extends StatefulWidget {
   const FlowyEditor({
     Key? key,
@@ -61,13 +71,16 @@ class _FlowyEditorState extends State<FlowyEditor> {
   void initState() {
     super.initState();
 
-    editorState.service.renderPluginService = FlowyRenderPlugin(
-      editorState: editorState,
-      builders: {
-        ...defaultBuilders,
-        ...widget.customBuilders,
-      },
-    );
+    editorState.service.renderPluginService = _createRenderPlugin();
+  }
+
+  @override
+  void didUpdateWidget(covariant FlowyEditor oldWidget) {
+    super.didUpdateWidget(oldWidget);
+
+    if (editorState.service != oldWidget.editorState.service) {
+      editorState.service.renderPluginService = _createRenderPlugin();
+    }
   }
 
   @override
@@ -81,11 +94,7 @@ class _FlowyEditorState extends State<FlowyEditor> {
         child: FlowyKeyboard(
           key: editorState.service.keyboardServiceKey,
           handlers: [
-            slashShortcutHandler,
-            flowyDeleteNodesHandler,
-            deleteSingleTextNodeHandler,
-            arrowKeysHandler,
-            enterInEdgeOfTextNodeHandler,
+            ...defaultKeyEventHandler,
             ...widget.keyEventHandlers,
           ],
           editorState: editorState,
@@ -106,4 +115,12 @@ class _FlowyEditorState extends State<FlowyEditor> {
       ),
     );
   }
+
+  FlowyRenderPlugin _createRenderPlugin() => FlowyRenderPlugin(
+        editorState: editorState,
+        builders: {
+          ...defaultBuilders,
+          ...widget.customBuilders,
+        },
+      );
 }

+ 46 - 3
frontend/app_flowy/packages/flowy_editor/lib/service/input_service.dart

@@ -1,10 +1,11 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+import 'package:flowy_editor/document/node.dart';
 import 'package:flowy_editor/document/position.dart';
 import 'package:flowy_editor/document/selection.dart';
 import 'package:flowy_editor/editor_state.dart';
-import 'package:flowy_editor/document/node.dart';
 import 'package:flowy_editor/operation/transaction_builder.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
 
 mixin FlowyInputService {
   void attach(TextEditingValue textEditingValue);
@@ -93,8 +94,10 @@ class _FlowyInputState extends State<FlowyInput>
     // TODO: implement the detail
     for (final delta in deltas) {
       if (delta is TextEditingDeltaInsertion) {
+        _applyInsert(delta);
       } else if (delta is TextEditingDeltaDeletion) {
       } else if (delta is TextEditingDeltaReplacement) {
+        _applyReplacement(delta);
       } else if (delta is TextEditingDeltaNonTextUpdate) {
         // We don't need to care the [TextEditingDeltaNonTextUpdate].
         // Do nothing.
@@ -102,6 +105,46 @@ class _FlowyInputState extends State<FlowyInput>
     }
   }
 
+  void _applyInsert(TextEditingDeltaInsertion delta) {
+    final selectionService = _editorState.service.selectionService;
+    final currentSelection = selectionService.currentSelection;
+    if (currentSelection == null) {
+      return;
+    }
+    if (currentSelection.isSingle) {
+      final textNode =
+          selectionService.currentSelectedNodes.value.first as TextNode;
+      TransactionBuilder(_editorState)
+        ..insertText(
+          textNode,
+          delta.insertionOffset,
+          delta.textInserted,
+        )
+        ..commit();
+    } else {
+      // TODO: implement
+    }
+  }
+
+  void _applyReplacement(TextEditingDeltaReplacement delta) {
+    final selectionService = _editorState.service.selectionService;
+    final currentSelection = selectionService.currentSelection;
+    if (currentSelection == null) {
+      return;
+    }
+    if (currentSelection.isSingle) {
+      final textNode =
+          selectionService.currentSelectedNodes.value.first as TextNode;
+      final length = delta.replacedRange.end - delta.replacedRange.start;
+      TransactionBuilder(_editorState)
+        ..replaceText(
+            textNode, delta.replacedRange.start, length, delta.replacementText)
+        ..commit();
+    } else {
+      // TODO: implement
+    }
+  }
+
   @override
   void close() {
     _textInputConnection?.close();

+ 82 - 0
frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delele_text_handler.dart

@@ -0,0 +1,82 @@
+import 'package:flowy_editor/flowy_editor.dart';
+import 'package:flowy_editor/service/keyboard_service.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+// Handle delete text.
+FlowyKeyEventHandler deleteTextHandler = (editorState, event) {
+  if (event.logicalKey != LogicalKeyboardKey.backspace) {
+    return KeyEventResult.ignored;
+  }
+
+  final selection = editorState.service.selectionService.currentSelection;
+  if (selection == null) {
+    return KeyEventResult.ignored;
+  }
+
+  final nodes = editorState.service.selectionService.currentSelectedNodes.value;
+  // make sure all nodes is [TextNode].
+  final textNodes = nodes.whereType<TextNode>().toList();
+  if (textNodes.length != nodes.length) {
+    return KeyEventResult.ignored;
+  }
+
+  TransactionBuilder transactionBuilder = TransactionBuilder(editorState);
+  if (textNodes.length == 1) {
+    final textNode = textNodes.first;
+    final index = selection.start.offset - 1;
+    if (index < 0) {
+      // 1. style
+      if (textNode.subtype != null) {
+        transactionBuilder.updateNode(textNode, {
+          'subtype': null,
+        });
+      } else {
+        // 2. non-style
+        // find previous text node.
+        while (textNode.previous != null) {
+          if (textNode.previous is TextNode) {
+            final previous = textNode.previous as TextNode;
+            transactionBuilder
+              ..deleteNode(textNode)
+              ..mergeText(previous, textNode);
+            break;
+          }
+        }
+      }
+    } else {
+      if (selection.isCollapsed) {
+        transactionBuilder.deleteText(
+          textNode,
+          selection.start.offset - 1,
+          1,
+        );
+      } else {
+        transactionBuilder.deleteText(
+          textNode,
+          selection.start.offset,
+          selection.end.offset - selection.start.offset,
+        );
+      }
+    }
+  } else {
+    final first = textNodes.first;
+    final last = textNodes.last;
+    var content = textNodes.last.toRawString();
+    content = content.substring(selection.end.offset, content.length);
+    // Merge the fist and the last text node content,
+    //  and delete the all nodes expect for the first.
+    transactionBuilder
+      ..deleteNodes(textNodes.sublist(1))
+      ..mergeText(
+        first,
+        last,
+        firstOffset: selection.start.offset,
+        secondOffset: selection.end.offset,
+      );
+  }
+
+  transactionBuilder.commit();
+
+  return KeyEventResult.handled;
+};

+ 0 - 69
frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/delete_single_text_node_handler.dart

@@ -1,69 +0,0 @@
-import 'package:flowy_editor/document/node.dart';
-import 'package:flowy_editor/operation/transaction_builder.dart';
-import 'package:flowy_editor/render/selection/selectable.dart';
-import 'package:flowy_editor/service/keyboard_service.dart';
-import 'package:flowy_editor/extensions/object_extensions.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-
-// TODO: need to be refactored, just a example code.
-FlowyKeyEventHandler deleteSingleTextNodeHandler = (editorState, event) {
-  if (event.logicalKey != LogicalKeyboardKey.backspace) {
-    return KeyEventResult.ignored;
-  }
-
-  // final selectionNodes = editorState.selectedNodes;
-  // if (selectionNodes.length == 1 && selectionNodes.first is TextNode) {
-  //   final node = selectionNodes.first.unwrapOrNull<TextNode>();
-  //   final selectable = node?.key?.currentState?.unwrapOrNull<Selectable>();
-  //   if (selectable != null) {
-  //     final textSelection = selectable.getCurrentTextSelection();
-  //     if (textSelection != null) {
-  //       if (textSelection.isCollapsed) {
-  //         /// Three cases:
-  //         /// Delete the zero character,
-  //         ///   1. if there is still text node in front of it, then merge them.
-  //         ///   2. if not, just ignore
-  //         /// Delete the non-zero character,
-  //         ///   3. delete the single character.
-  //         if (textSelection.baseOffset == 0) {
-  //           if (node?.previous != null && node?.previous is TextNode) {
-  //             final previous = node!.previous! as TextNode;
-  //             final newTextSelection = TextSelection.collapsed(
-  //                 offset: previous.toRawString().length);
-  //             final selectionService = editorState.service.selectionService;
-  //             final previousSelectable =
-  //                 previous.key?.currentState?.unwrapOrNull<Selectable>();
-  //             final newOfset = previousSelectable
-  //                 ?.getOffsetByTextSelection(newTextSelection);
-  //             if (newOfset != null) {
-  //               // selectionService.updateCursor(newOfset);
-  //             }
-  //             // merge
-  //             TransactionBuilder(editorState)
-  //               ..deleteNode(node)
-  //               ..insertText(
-  //                   previous, previous.toRawString().length, node.toRawString())
-  //               ..commit();
-  //             return KeyEventResult.handled;
-  //           } else {
-  //             return KeyEventResult.ignored;
-  //           }
-  //         } else {
-  //           TransactionBuilder(editorState)
-  //             ..deleteText(node!, textSelection.baseOffset - 1, 1)
-  //             ..commit();
-  //           final newTextSelection =
-  //               TextSelection.collapsed(offset: textSelection.baseOffset - 1);
-  //           final selectionService = editorState.service.selectionService;
-  //           final newOfset =
-  //               selectable.getOffsetByTextSelection(newTextSelection);
-  //           // selectionService.updateCursor(newOfset);
-  //           return KeyEventResult.handled;
-  //         }
-  //       }
-  //     }
-  //   }
-  // }
-  return KeyEventResult.ignored;
-};

+ 83 - 0
frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart

@@ -0,0 +1,83 @@
+import 'package:flowy_editor/document/node.dart';
+import 'package:flowy_editor/document/selection.dart';
+import 'package:flowy_editor/editor_state.dart';
+import 'package:flowy_editor/operation/transaction_builder.dart';
+import 'package:flowy_editor/service/keyboard_service.dart';
+import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
+import 'package:flutter/material.dart';
+
+FlowyKeyEventHandler updateTextStyleByCommandXHandler = (editorState, event) {
+  if (!event.isMetaPressed || event.character == null) {
+    return KeyEventResult.ignored;
+  }
+
+  final selection = editorState.service.selectionService.currentSelection;
+  final nodes = editorState.service.selectionService.currentSelectedNodes.value
+      .whereType<TextNode>()
+      .toList();
+
+  if (selection == null || nodes.isEmpty) {
+    return KeyEventResult.ignored;
+  }
+
+  switch (event.character!) {
+    // bold
+    case 'B':
+    case 'b':
+      _makeBold(editorState, nodes, selection);
+      return KeyEventResult.handled;
+    default:
+      break;
+  }
+
+  return KeyEventResult.ignored;
+};
+
+// TODO: implement unBold.
+void _makeBold(
+    EditorState editorState, List<TextNode> nodes, Selection selection) {
+  final builder = TransactionBuilder(editorState);
+  if (nodes.length == 1) {
+    builder.formatText(
+      nodes.first,
+      selection.start.offset,
+      selection.end.offset - selection.start.offset,
+      {
+        'bold': true,
+      },
+    );
+  } else {
+    for (var i = 0; i < nodes.length; i++) {
+      final node = nodes[i];
+      if (i == 0) {
+        builder.formatText(
+          node,
+          selection.start.offset,
+          node.toRawString().length - selection.start.offset,
+          {
+            'bold': true,
+          },
+        );
+      } else if (i == nodes.length - 1) {
+        builder.formatText(
+          node,
+          0,
+          selection.end.offset,
+          {
+            'bold': true,
+          },
+        );
+      } else {
+        builder.formatText(
+          node,
+          0,
+          node.toRawString().length,
+          {
+            'bold': true,
+          },
+        );
+      }
+    }
+  }
+  builder.commit();
+}

+ 30 - 16
frontend/app_flowy/packages/flowy_editor/lib/service/render_plugin_service.dart

@@ -75,10 +75,7 @@ class FlowyRenderPlugin extends FlowyRenderPluginService {
     if (builder != null && builder.nodeValidator(node)) {
       final key = GlobalKey(debugLabel: name);
       node.key = key;
-      return _wrap(
-        builder.build(context),
-        context,
-      );
+      return _autoUpdateNodeWidget(builder, context);
     } else {
       assert(false, 'Could not query the builder with this $name');
       // TODO: return a placeholder widget with tips.
@@ -87,14 +84,14 @@ class FlowyRenderPlugin extends FlowyRenderPluginService {
   }
 
   @override
-  void register(String name, NodeWidgetBuilder<Node> builder) {
+  void register(String name, NodeWidgetBuilder builder) {
     debugPrint('[Plugins] registering $name...');
     _validatePlugin(name);
     _builders[name] = builder;
   }
 
   @override
-  void registerAll(Map<String, NodeWidgetBuilder<Node>> builders) {
+  void registerAll(Map<String, NodeWidgetBuilder> builders) {
     builders.forEach(register);
   }
 
@@ -104,18 +101,35 @@ class FlowyRenderPlugin extends FlowyRenderPluginService {
     _builders.remove(name);
   }
 
-  Widget _wrap(Widget widget, NodeWidgetContext context) {
+  Widget _autoUpdateNodeWidget(
+      NodeWidgetBuilder builder, NodeWidgetContext context) {
+    Widget notifier;
+    if (context.node is TextNode) {
+      notifier = ChangeNotifierProvider.value(
+          value: context.node as TextNode,
+          builder: (_, child) {
+            return Consumer<TextNode>(
+              builder: ((_, value, child) {
+                debugPrint('Text Node is rebuilding...');
+                return builder.build(context);
+              }),
+            );
+          });
+    } else {
+      notifier = ChangeNotifierProvider.value(
+          value: context.node,
+          builder: (_, child) {
+            return Consumer<Node>(
+              builder: ((_, value, child) {
+                debugPrint('Node is rebuilding...');
+                return builder.build(context);
+              }),
+            );
+          });
+    }
     return CompositedTransformTarget(
       link: context.node.layerLink,
-      child: ChangeNotifierProvider<Node>.value(
-        value: context.node,
-        builder: (context, child) => Consumer(
-          builder: ((context, value, child) {
-            debugPrint('Node is rebuilding...');
-            return widget;
-          }),
-        ),
-      ),
+      child: notifier,
     );
   }
 

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart

@@ -5,7 +5,7 @@ import 'package:flowy_editor/document/position.dart';
 import 'package:flowy_editor/document/selection.dart';
 import 'package:flowy_editor/render/selection/selectable.dart';
 import 'package:flowy_editor/render/selection/cursor_widget.dart';
-import 'package:flowy_editor/render/selection/flowy_selection_widget.dart';
+import 'package:flowy_editor/render/selection/selection_widget.dart';
 import 'package:flowy_editor/extensions/object_extensions.dart';
 import 'package:flowy_editor/extensions/node_extensions.dart';
 import 'package:flutter/gestures.dart';