Pārlūkot izejas kodu

refactor: abstract selection and keyboard from editor state

Lucas.Xu 2 gadi atpakaļ
vecāks
revīzija
a831ddc589

+ 117 - 0
frontend/app_flowy/packages/flowy_editor/example/assets/document.json

@@ -64,6 +64,123 @@
           "heading": "h2"
         }
       },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Click the '?' at the bottom right for help and support."
+          }
+        ],
+        "attributes": {}
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Click the '?' at the bottom right for help and support."
+          }
+        ],
+        "attributes": {}
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Click the '?' at the bottom right for help and support."
+          }
+        ],
+        "attributes": {}
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Click the '?' at the bottom right for help and support."
+          }
+        ],
+        "attributes": {}
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Click the '?' at the bottom right for help and support."
+          }
+        ],
+        "attributes": {}
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Click the '?' at the bottom right for help and support."
+          }
+        ],
+        "attributes": {}
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Click the '?' at the bottom right for help and support."
+          }
+        ],
+        "attributes": {}
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Click the '?' at the bottom right for help and support."
+          }
+        ],
+        "attributes": {}
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Click the '?' at the bottom right for help and support."
+          }
+        ],
+        "attributes": {}
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Click the '?' at the bottom right for help and support."
+          }
+        ],
+        "attributes": {}
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Click the '?' at the bottom right for help and support."
+          }
+        ],
+        "attributes": {}
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Click the '?' at the bottom right for help and support."
+          }
+        ],
+        "attributes": {}
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "Click the '?' at the bottom right for help and support."
+          }
+        ],
+        "attributes": {}
+      },
       {
         "type": "text",
         "delta": [

+ 3 - 1
frontend/app_flowy/packages/flowy_editor/example/lib/main.dart

@@ -94,7 +94,9 @@ class _MyHomePageState extends State<MyHomePage> {
               document: document,
               renderPlugins: renderPlugins,
             );
-            return _editorState.build(context);
+            return FlowyEditor(
+              editorState: _editorState,
+            );
           }
         },
       ),

+ 13 - 58
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart

@@ -33,66 +33,21 @@ class _EditorNodeWidget extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    return RawGestureDetector(
-      behavior: HitTestBehavior.translucent,
-      gestures: {
-        PanGestureRecognizer:
-            GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
-          () => PanGestureRecognizer(),
-          (recognizer) {
-            recognizer
-              ..onStart = _onPanStart
-              ..onUpdate = _onPanUpdate
-              ..onEnd = _onPanEnd;
-          },
-        ),
-        TapGestureRecognizer:
-            GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
-          () => TapGestureRecognizer(),
-          (recongizer) {
-            recongizer..onTapDown = _onTapDown;
-          },
-        )
-      },
-      child: SingleChildScrollView(
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: node.children
-              .map(
-                (e) => editorState.renderPlugins.buildWidget(
-                  context: NodeWidgetContext(
-                    buildContext: context,
-                    node: e,
-                    editorState: editorState,
-                  ),
+    return SingleChildScrollView(
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: node.children
+            .map(
+              (e) => editorState.renderPlugins.buildWidget(
+                context: NodeWidgetContext(
+                  buildContext: context,
+                  node: e,
+                  editorState: editorState,
                 ),
-              )
-              .toList(),
-        ),
+              ),
+            )
+            .toList(),
       ),
     );
   }
-
-  void _onTapDown(TapDownDetails details) {
-    editorState.panStartOffset = null;
-    editorState.panEndOffset = null;
-    editorState.updateSelection();
-
-    editorState.tapOffset = details.globalPosition;
-    editorState.updateCursor();
-  }
-
-  void _onPanStart(DragStartDetails details) {
-    editorState.panStartOffset = details.globalPosition;
-    editorState.updateSelection();
-  }
-
-  void _onPanUpdate(DragUpdateDetails details) {
-    editorState.panEndOffset = details.globalPosition;
-    editorState.updateSelection();
-  }
-
-  void _onPanEnd(DragEndDetails details) {
-    // do nothing
-  }
 }

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart

@@ -40,7 +40,7 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget>
   String get src => widget.node.attributes['image_src'] as String;
 
   @override
-  List<Rect> getOverlayRectsInRange(Offset start, Offset end) {
+  List<Rect> getSelectionRectsInSelection(Offset start, Offset end) {
     final renderBox = context.findRenderObject() as RenderBox;
     final size = renderBox.size;
     final boxOffset = renderBox.localToGlobal(Offset.zero);

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart

@@ -56,7 +56,7 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
       _textKey.currentContext?.findRenderObject() as RenderParagraph;
 
   @override
-  List<Rect> getOverlayRectsInRange(Offset start, Offset end) {
+  List<Rect> getSelectionRectsInSelection(Offset start, Offset end) {
     var textSelection =
         TextSelection(baseOffset: 0, extentOffset: node.toRawString().length);
     // Returns select all if the start or end exceeds the size of the box

+ 2 - 2
frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart

@@ -109,8 +109,8 @@ class EditorState {
       final key = node.key;
       if (key != null && key.currentState is Selectable) {
         final selectable = key.currentState as Selectable;
-        final overlayRects =
-            selectable.getOverlayRectsInRange(panStartOffset!, panEndOffset!);
+        final overlayRects = selectable.getSelectionRectsInSelection(
+            panStartOffset!, panEndOffset!);
         for (final rect in overlayRects) {
           // TODO: refactor overlay implement.
           final overlay = OverlayEntry(builder: ((context) {

+ 1 - 0
frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart

@@ -11,3 +11,4 @@ export 'package:flowy_editor/operation/transaction.dart';
 export 'package:flowy_editor/operation/transaction_builder.dart';
 export 'package:flowy_editor/operation/operation.dart';
 export 'package:flowy_editor/editor_state.dart';
+export 'package:flowy_editor/flowy_editor_service.dart';

+ 33 - 0
frontend/app_flowy/packages/flowy_editor/lib/flowy_editor_service.dart

@@ -0,0 +1,33 @@
+import 'package:flowy_editor/flowy_keyboard_service.dart';
+import 'package:flowy_editor/flowy_selection_service.dart';
+
+import 'editor_state.dart';
+import 'package:flutter/material.dart';
+
+class FlowyEditor extends StatefulWidget {
+  const FlowyEditor({
+    Key? key,
+    required this.editorState,
+  }) : super(key: key);
+
+  final EditorState editorState;
+
+  @override
+  State<FlowyEditor> createState() => _FlowyEditorState();
+}
+
+class _FlowyEditorState extends State<FlowyEditor> {
+  EditorState get editorState => widget.editorState;
+
+  @override
+  Widget build(BuildContext context) {
+    return FlowySelectionWidget(
+      editorState: editorState,
+      child: FlowyKeyboardWidget(
+        handlers: const [],
+        editorState: editorState,
+        child: editorState.build(context),
+      ),
+    );
+  }
+}

+ 70 - 0
frontend/app_flowy/packages/flowy_editor/lib/flowy_keyboard_service.dart

@@ -0,0 +1,70 @@
+import 'package:flutter/services.dart';
+
+import 'editor_state.dart';
+import 'package:flutter/material.dart';
+
+abstract class FlowyKeyboardHandler {
+  final EditorState editorState;
+  final RawKeyEvent rawKeyEvent;
+
+  FlowyKeyboardHandler({
+    required this.editorState,
+    required this.rawKeyEvent,
+  });
+
+  KeyEventResult onKeyDown();
+}
+
+/// Process keyboard events
+class FlowyKeyboardWidget extends StatefulWidget {
+  const FlowyKeyboardWidget({
+    Key? key,
+    required this.handlers,
+    required this.editorState,
+    required this.child,
+  }) : super(key: key);
+
+  final EditorState editorState;
+  final Widget child;
+  final List<FlowyKeyboardHandler> handlers;
+
+  @override
+  State<FlowyKeyboardWidget> createState() => _FlowyKeyboardWidgetState();
+}
+
+class _FlowyKeyboardWidgetState extends State<FlowyKeyboardWidget> {
+  final FocusNode focusNode = FocusNode(debugLabel: 'flowy_keyboard_service');
+
+  @override
+  Widget build(BuildContext context) {
+    return Focus(
+      focusNode: focusNode,
+      autofocus: true,
+      onKey: _onKey,
+      child: widget.child,
+    );
+  }
+
+  KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
+    if (event is! RawKeyDownEvent) {
+      return KeyEventResult.ignored;
+    }
+
+    for (final handler in widget.handlers) {
+      debugPrint('handle keyboard event $event by $handler');
+
+      KeyEventResult result = handler.onKeyDown();
+
+      switch (result) {
+        case KeyEventResult.handled:
+          return KeyEventResult.handled;
+        case KeyEventResult.skipRemainingHandlers:
+          return KeyEventResult.skipRemainingHandlers;
+        case KeyEventResult.ignored:
+          break;
+      }
+    }
+
+    return KeyEventResult.ignored;
+  }
+}

+ 279 - 0
frontend/app_flowy/packages/flowy_editor/lib/flowy_selection_service.dart

@@ -0,0 +1,279 @@
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+
+import 'editor_state.dart';
+import 'document/node.dart';
+import '../render/selectable.dart';
+
+/// Process selection and cursor
+mixin _FlowySelectionService<T extends StatefulWidget> on State<T> {
+  /// [Pan] and [Tap] must be mutually exclusive.
+  /// Pan
+  Offset? panStartOffset;
+  Offset? panEndOffset;
+
+  /// Tap
+  Offset? tapOffset;
+
+  void updateSelection();
+
+  void updateCursor();
+
+  /// Returns selected node(s)
+  /// Returns empty list if no nodes are being selected.
+  List<Node> get selectedNodes;
+
+  /// Compute selected node triggered by [Tap]
+  Node? computeSelectedNodeByTap(
+    Node node,
+    Offset offset,
+  );
+
+  /// Compute selected nodes triggered by [Pan]
+  List<Node> computeSelectedNodesByPan(
+    Node node,
+    Offset start,
+    Offset end,
+  );
+
+  /// Pan
+  bool isNodeInSelection(
+    Node node,
+    Offset start,
+    Offset end,
+  );
+
+  /// Tap
+  bool isNodeInOffset(
+    Node node,
+    Offset offset,
+  );
+}
+
+class FlowySelectionWidget extends StatefulWidget {
+  const FlowySelectionWidget({
+    Key? key,
+    required this.editorState,
+    required this.child,
+  }) : super(key: key);
+
+  final EditorState editorState;
+  final Widget child;
+
+  @override
+  State<FlowySelectionWidget> createState() => _FlowySelectionWidgetState();
+}
+
+class _FlowySelectionWidgetState extends State<FlowySelectionWidget>
+    with _FlowySelectionService {
+  List<OverlayEntry> selectionOverlays = [];
+
+  EditorState get editorState => widget.editorState;
+
+  @override
+  Widget build(BuildContext context) {
+    return RawGestureDetector(
+      behavior: HitTestBehavior.translucent,
+      gestures: {
+        PanGestureRecognizer:
+            GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
+          () => PanGestureRecognizer(),
+          (recognizer) {
+            recognizer
+              ..onStart = _onPanStart
+              ..onUpdate = _onPanUpdate
+              ..onEnd = _onPanEnd;
+          },
+        ),
+        TapGestureRecognizer:
+            GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
+          () => TapGestureRecognizer(),
+          (recongizer) {
+            recongizer.onTapDown = _onTapDown;
+          },
+        )
+      },
+      child: widget.child,
+    );
+  }
+
+  @override
+  void updateSelection() {
+    _clearOverlay();
+
+    final nodes = selectedNodes;
+    if (nodes.isEmpty || panStartOffset == null || panEndOffset == null) {
+      assert(panStartOffset == null);
+      assert(panEndOffset == null);
+      return;
+    }
+
+    for (final node in nodes) {
+      final selectable = node.key?.currentState as Selectable?;
+      if (selectable != null) {
+        final selectionRects = selectable.getSelectionRectsInSelection(
+            panStartOffset!, panEndOffset!);
+        for (final rect in selectionRects) {
+          final overlay = OverlayEntry(
+            builder: ((context) => Positioned.fromRect(
+                  rect: rect,
+                  child: Container(
+                    color: Colors.yellow.withAlpha(100),
+                  ),
+                )),
+          );
+          selectionOverlays.add(overlay);
+        }
+      }
+    }
+    Overlay.of(context)?.insertAll(selectionOverlays);
+  }
+
+  @override
+  void updateCursor() {
+    _clearOverlay();
+
+    if (tapOffset == null) {
+      assert(tapOffset == null);
+      return;
+    }
+
+    final nodes = selectedNodes;
+    if (nodes.isEmpty) {
+      return;
+    }
+
+    final selectedNode = nodes.first;
+    final selectable = selectedNode.key?.currentState as Selectable?;
+    if (selectable != null) {
+      final rect = selectable.getCursorRect(tapOffset!);
+      final cursor = OverlayEntry(
+        builder: ((context) => Positioned.fromRect(
+              rect: rect,
+              child: Container(
+                color: Colors.blue,
+              ),
+            )),
+      );
+      selectionOverlays.add(cursor);
+    }
+    Overlay.of(context)?.insertAll(selectionOverlays);
+  }
+
+  @override
+  List<Node> get selectedNodes {
+    if (panStartOffset != null && panEndOffset != null) {
+      return computeSelectedNodesByPan(
+          editorState.document.root, panStartOffset!, panEndOffset!);
+    } else if (tapOffset != null) {
+      final reuslt =
+          computeSelectedNodeByTap(editorState.document.root, tapOffset!);
+      if (reuslt != null) {
+        return [reuslt];
+      }
+    }
+    return [];
+  }
+
+  @override
+  Node? computeSelectedNodeByTap(Node node, Offset offset) {
+    assert(this.tapOffset != null);
+    final tapOffset = this.tapOffset;
+    if (tapOffset != null) {}
+
+    if (node.parent != null && node.key != null) {
+      if (isNodeInOffset(node, offset)) {
+        return node;
+      }
+    }
+
+    for (final child in node.children) {
+      final result = computeSelectedNodeByTap(child, offset);
+      if (result != null) {
+        return result;
+      }
+    }
+
+    return null;
+  }
+
+  @override
+  List<Node> computeSelectedNodesByPan(Node node, Offset start, Offset end) {
+    List<Node> result = [];
+    if (node.parent != null && node.key != null) {
+      if (isNodeInSelection(node, start, end)) {
+        result.add(node);
+      }
+    }
+    for (final child in node.children) {
+      result.addAll(computeSelectedNodesByPan(child, start, end));
+    }
+    // TODO: sort the result
+    return result;
+  }
+
+  @override
+  bool isNodeInOffset(Node node, Offset offset) {
+    assert(node.key != null);
+    final renderBox =
+        node.key?.currentContext?.findRenderObject() as RenderBox?;
+    if (renderBox != null) {
+      final boxOffset = renderBox.localToGlobal(Offset.zero);
+      final boxRect = boxOffset & renderBox.size;
+      return boxRect.contains(offset);
+    }
+    return false;
+  }
+
+  @override
+  bool isNodeInSelection(Node node, Offset start, Offset end) {
+    assert(node.key != null);
+    final renderBox =
+        node.key?.currentContext?.findRenderObject() as RenderBox?;
+    if (renderBox != null) {
+      final rect = Rect.fromPoints(start, end);
+      final boxOffset = renderBox.localToGlobal(Offset.zero);
+      final boxRect = boxOffset & renderBox.size;
+      return rect.overlaps(boxRect);
+    }
+    return false;
+  }
+
+  void _onTapDown(TapDownDetails details) {
+    debugPrint('on tap down');
+
+    // TODO: use setter to make them exclusive??
+    tapOffset = details.globalPosition;
+    panStartOffset = null;
+    panEndOffset = null;
+
+    updateCursor();
+  }
+
+  void _onPanStart(DragStartDetails details) {
+    debugPrint('on pan start');
+
+    panStartOffset = details.globalPosition;
+    panEndOffset = null;
+    tapOffset = null;
+  }
+
+  void _onPanUpdate(DragUpdateDetails details) {
+    // debugPrint('on pan update');
+
+    panEndOffset = details.globalPosition;
+    tapOffset = null;
+
+    updateSelection();
+  }
+
+  void _onPanEnd(DragEndDetails details) {
+    // do nothing
+  }
+
+  void _clearOverlay() {
+    selectionOverlays
+      ..forEach((overlay) => overlay.remove())
+      ..clear();
+  }
+}

+ 2 - 2
frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart

@@ -1,7 +1,7 @@
-import 'package:flutter/services.dart';
-
 import '../render/selectable.dart';
 import 'editor_state.dart';
+
+import 'package:flutter/services.dart';
 import 'package:flutter/material.dart';
 
 class Keyboard extends StatelessWidget {

+ 2 - 2
frontend/app_flowy/packages/flowy_editor/lib/render/selectable.dart

@@ -4,9 +4,9 @@ import 'package:flutter/material.dart';
 mixin Selectable<T extends StatefulWidget> on State<T> {
   /// Returns a [Rect] list for overlay.
   /// [start] and [end] are global offsets.
-  List<Rect> getOverlayRectsInRange(Offset start, Offset end);
+  List<Rect> getSelectionRectsInSelection(Offset start, Offset end);
 
-  /// Returns a [Offset] for cursor
+  /// Returns a [Rect] for cursor
   Rect getCursorRect(Offset start);
 }