瀏覽代碼

Merge pull request #727 from AppFlowy-IO/feat/handle-arrow-keys

Feat/handle arrow keys
Vincent Chan 2 年之前
父節點
當前提交
583d838344

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

@@ -63,6 +63,11 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable {
     throw UnimplementedError();
   }
 
+  @override
+  Offset localToGlobal(Offset offset) {
+    throw UnimplementedError();
+  }
+
   @override
   Rect getCursorRectInPosition(Position position) {
     // TODO: implement getCursorRectInPosition

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

@@ -70,6 +70,11 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
     );
   }
 
+  @override
+  Offset localToGlobal(Offset offset) {
+    return _renderParagraph.localToGlobal(offset);
+  }
+
   @override
   List<Rect> getRectsInSelection(Selection selection) {
     assert(pathEquals(selection.start.path, selection.end.path));

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

@@ -33,7 +33,22 @@ class EditorState {
   final service = FlowyService();
 
   final UndoManager undoManager = UndoManager();
-  Selection? cursorSelection;
+  Selection? _cursorSelection;
+
+  Selection? get cursorSelection {
+    return _cursorSelection;
+  }
+
+  /// add the set reason in the future, don't use setter
+  updateCursorSelection(Selection? cursorSelection) {
+    // broadcast to other users here
+    if (cursorSelection == null) {
+      service.selectionService.clearSelection();
+    } else {
+      service.selectionService.updateSelection(cursorSelection);
+    }
+    _cursorSelection = cursorSelection;
+  }
 
   Timer? _debouncedSealHistoryItemTimer;
 
@@ -62,7 +77,7 @@ class EditorState {
     for (final op in transaction.operations) {
       _applyOperation(op);
     }
-    cursorSelection = transaction.afterSelection;
+    updateCursorSelection(transaction.afterSelection);
 
     if (options.recordUndo) {
       final undoItem = undoManager.getUndoHistoryItem();

+ 4 - 0
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart

@@ -255,6 +255,10 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
     return Rect.zero;
   }
 
+  Offset localToGlobal(Offset offset) {
+    return _renderParagraph.localToGlobal(offset);
+  }
+
   TextSpan get _decorateTextSpanWithGlobalStyle => TextSpan(
         children: _textSpan.children
             ?.whereType<TextSpan>()

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

@@ -17,10 +17,10 @@ class CursorWidget extends StatefulWidget {
   final LayerLink layerLink;
 
   @override
-  State<CursorWidget> createState() => _CursorWidgetState();
+  State<CursorWidget> createState() => CursorWidgetState();
 }
 
-class _CursorWidgetState extends State<CursorWidget> {
+class CursorWidgetState extends State<CursorWidget> {
   bool showCursor = true;
   late Timer timer;
 
@@ -28,7 +28,17 @@ class _CursorWidgetState extends State<CursorWidget> {
   void initState() {
     super.initState();
 
-    timer = Timer.periodic(
+    timer = _initTimer();
+  }
+
+  @override
+  void dispose() {
+    timer.cancel();
+    super.dispose();
+  }
+
+  Timer _initTimer() {
+    return Timer.periodic(
         Duration(milliseconds: (widget.blinkingInterval * 1000).toInt()),
         (timer) {
       setState(() {
@@ -37,10 +47,13 @@ class _CursorWidgetState extends State<CursorWidget> {
     });
   }
 
-  @override
-  void dispose() {
+  /// force the cursor widget to show for a while
+  show() {
+    setState(() {
+      showCursor = true;
+    });
     timer.cancel();
-    super.dispose();
+    timer = _initTimer();
   }
 
   @override

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

@@ -23,6 +23,8 @@ mixin Selectable<T extends StatefulWidget> on State<T> {
   Position getPositionInOffset(Offset start);
   Rect getCursorRectInPosition(Position position);
 
+  Offset localToGlobal(Offset offset);
+
   Position start();
   Position end();
 

+ 133 - 4
frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart

@@ -1,14 +1,143 @@
+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';
 
+int _endOffsetOfNode(Node node) {
+  if (node is TextNode) {
+    return node.delta.length;
+  }
+  return 0;
+}
+
+extension on Position {
+  Position? goLeft(EditorState editorState) {
+    if (offset == 0) {
+      final node = editorState.document.nodeAtPath(path)!;
+      final prevNode = node.previous;
+      if (prevNode != null) {
+        return Position(
+            path: prevNode.path, offset: _endOffsetOfNode(prevNode));
+      }
+      return null;
+    }
+
+    return Position(path: path, offset: offset - 1);
+  }
+
+  Position? goRight(EditorState editorState) {
+    final node = editorState.document.nodeAtPath(path)!;
+    final lengthOfNode = _endOffsetOfNode(node);
+    if (offset >= lengthOfNode) {
+      final nextNode = node.next;
+      if (nextNode != null) {
+        return Position(path: nextNode.path, offset: 0);
+      }
+      return null;
+    }
+
+    return Position(path: path, offset: offset + 1);
+  }
+}
+
+Position? _goUp(EditorState editorState) {
+  final rects = editorState.service.selectionService.rects();
+  if (rects.isEmpty) {
+    return null;
+  }
+  final first = rects.first;
+  final firstOffset = Offset(first.left, first.top);
+  final hitOffset = firstOffset - Offset(0, first.height * 0.5);
+  return editorState.service.selectionService.hitTest(hitOffset);
+}
+
+Position? _goDown(EditorState editorState) {
+  final rects = editorState.service.selectionService.rects();
+  if (rects.isEmpty) {
+    return null;
+  }
+  final first = rects.last;
+  final firstOffset = Offset(first.right, first.bottom);
+  final hitOffset = firstOffset + Offset(0, first.height * 0.5);
+  return editorState.service.selectionService.hitTest(hitOffset);
+}
+
+KeyEventResult _handleShiftKey(EditorState editorState, RawKeyEvent event) {
+  final currentSelection = editorState.cursorSelection;
+  if (currentSelection == null) {
+    return KeyEventResult.ignored;
+  }
+
+  if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
+    final leftPosition = currentSelection.end.goLeft(editorState);
+    editorState.updateCursorSelection(leftPosition == null
+        ? null
+        : Selection(start: currentSelection.start, end: leftPosition));
+    return KeyEventResult.handled;
+  } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
+    final rightPosition = currentSelection.start.goRight(editorState);
+    editorState.updateCursorSelection(rightPosition == null
+        ? null
+        : Selection(start: rightPosition, end: currentSelection.end));
+    return KeyEventResult.handled;
+  } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
+    final position = _goUp(editorState);
+    editorState.updateCursorSelection(position == null
+        ? null
+        : Selection(start: position, end: currentSelection.end));
+    return KeyEventResult.handled;
+  } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
+    final position = _goDown(editorState);
+    editorState.updateCursorSelection(position == null
+        ? null
+        : Selection(start: currentSelection.start, end: position));
+    return KeyEventResult.handled;
+  }
+  return KeyEventResult.ignored;
+}
+
 FlowyKeyEventHandler arrowKeysHandler = (editorState, event) {
-  if (event.logicalKey != LogicalKeyboardKey.arrowUp &&
-      event.logicalKey != LogicalKeyboardKey.arrowDown &&
-      event.logicalKey != LogicalKeyboardKey.arrowLeft &&
-      event.logicalKey != LogicalKeyboardKey.arrowRight) {
+  if (event.isShiftPressed) {
+    return _handleShiftKey(editorState, event);
+  }
+
+  final currentSelection = editorState.cursorSelection;
+  if (currentSelection == null) {
     return KeyEventResult.ignored;
   }
 
+  if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
+    if (currentSelection.isCollapsed) {
+      final leftPosition = currentSelection.start.goLeft(editorState);
+      if (leftPosition != null) {
+        editorState.updateCursorSelection(Selection.collapsed(leftPosition));
+      }
+    } else {
+      editorState
+          .updateCursorSelection(currentSelection.collapse(atStart: true));
+    }
+    return KeyEventResult.handled;
+  } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
+    if (currentSelection.isCollapsed) {
+      final rightPosition = currentSelection.end.goRight(editorState);
+      if (rightPosition != null) {
+        editorState.updateCursorSelection(Selection.collapsed(rightPosition));
+      }
+    } else {
+      editorState.updateCursorSelection(currentSelection.collapse());
+    }
+    return KeyEventResult.handled;
+  } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
+    final position = _goUp(editorState);
+    editorState.updateCursorSelection(
+        position == null ? null : Selection.collapsed(position));
+    return KeyEventResult.handled;
+  } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
+    final position = _goDown(editorState);
+    editorState.updateCursorSelection(
+        position == null ? null : Selection.collapsed(position));
+    return KeyEventResult.handled;
+  }
+
   return KeyEventResult.ignored;
 };

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

@@ -1,7 +1,7 @@
-import 'package:flowy_editor/document/path.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/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/extensions/object_extensions.dart';
@@ -28,6 +28,10 @@ mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
   ///
   void clearSelection();
 
+  List<Rect> rects();
+
+  Position? hitTest(Offset? offset);
+
   ///
   List<Node> getNodesInSelection(Selection selection);
 
@@ -50,7 +54,7 @@ mixin FlowySelectionService<T extends StatefulWidget> on State<T> {
   /// [start] is the offset under the global coordinate system.
   Node? computeNodeInOffset(Node node, Offset offset);
 
-  /// Return the [Node]s in multiple selection. Emtpy list would be returned
+  /// Return the [Node]s in multiple selection. Empty list would be returned
   ///   if no nodes are in range.
   ///
   /// [start] is the offset under the global coordinate system.
@@ -110,6 +114,8 @@ class _FlowySelectionState extends State<FlowySelection>
   /// Tap
   Offset? tapOffset;
 
+  final List<Rect> _rects = [];
+
   EditorState get editorState => widget.editorState;
 
   @override
@@ -171,8 +177,13 @@ class _FlowySelectionState extends State<FlowySelection>
     );
   }
 
+  List<Rect> rects() {
+    return _rects;
+  }
+
   @override
   void updateSelection(Selection selection) {
+    _rects.clear();
     _clearSelection();
 
     // cursor
@@ -274,16 +285,32 @@ class _FlowySelectionState extends State<FlowySelection>
 
     tapOffset = details.globalPosition;
 
-    final nodes = getNodesInRange(tapOffset!);
-    if (nodes.isNotEmpty) {
-      assert(nodes.length == 1);
-      final selectable = nodes.first.selectable;
-      if (selectable != null) {
-        final position = selectable.getPositionInOffset(tapOffset!);
-        final selection = Selection.collapsed(position);
-        updateSelection(selection);
-      }
+    final position = hitTest(tapOffset);
+    if (position == null) {
+      return;
+    }
+    final selection = Selection.collapsed(position);
+    editorState.updateCursorSelection(selection);
+  }
+
+  @override
+  Position? hitTest(Offset? offset) {
+    if (offset == null) {
+      editorState.updateCursorSelection(null);
+      return null;
+    }
+    final nodes = getNodesInRange(offset);
+    if (nodes.isEmpty) {
+      editorState.updateCursorSelection(null);
+      return null;
+    }
+    assert(nodes.length == 1);
+    final selectable = nodes.first.selectable;
+    if (selectable == null) {
+      editorState.updateCursorSelection(null);
+      return null;
     }
+    return selectable.getPositionInOffset(offset);
   }
 
   void _onPanStart(DragStartDetails details) {
@@ -314,7 +341,7 @@ class _FlowySelectionState extends State<FlowySelection>
       final selection = Selection(
           start: isDownward ? start : end, end: isDownward ? end : start);
       debugPrint('[_onPanUpdate] $selection');
-      updateSelection(selection);
+      editorState.updateCursorSelection(selection);
     }
   }
 
@@ -385,6 +412,7 @@ class _FlowySelectionState extends State<FlowySelection>
       final rects = selectable.getRectsInSelection(newSelection);
 
       for (final rect in rects) {
+        _rects.add(_transformRectToGlobal(selectable, rect));
         final overlay = OverlayEntry(
           builder: ((context) => SelectionWidget(
                 color: widget.selectionColor,
@@ -399,6 +427,11 @@ class _FlowySelectionState extends State<FlowySelection>
     Overlay.of(context)?.insertAll(_selectionOverlays);
   }
 
+  Rect _transformRectToGlobal(Selectable selectable, Rect r) {
+    final Offset topLeft = selectable.localToGlobal(Offset(r.left, r.top));
+    return Rect.fromLTWH(topLeft.dx, topLeft.dy, r.width, r.height);
+  }
+
   void _updateCursor(Position position) {
     final node = editorState.document.root.childAtPath(position.path);
 
@@ -413,6 +446,7 @@ class _FlowySelectionState extends State<FlowySelection>
     final selectable = node.selectable;
     final rect = selectable?.getCursorRectInPosition(position);
     if (rect != null) {
+      _rects.add(_transformRectToGlobal(selectable!, rect));
       final cursor = OverlayEntry(
         builder: ((context) => CursorWidget(
               key: _cursorKey,
@@ -423,9 +457,15 @@ class _FlowySelectionState extends State<FlowySelection>
       );
       _cursorOverlays.add(cursor);
       Overlay.of(context)?.insertAll(_cursorOverlays);
+      _forceShowCursor();
     }
   }
 
+  _forceShowCursor() {
+    final currentState = _cursorKey.currentState as CursorWidgetState?;
+    currentState?.show();
+  }
+
   List<Node> _selectedNodesInSelection(Node node, Selection selection) {
     List<Node> result = [];
     if (node.parent != null) {

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/pubspec.yaml

@@ -24,11 +24,11 @@ dev_dependencies:
 
 # The following section is specific to Flutter packages.
 flutter:
-
   # To add assets to your package, add an assets section, like this:
   assets:
     - assets/images/uncheck.svg
     - assets/images/
+    - assets/document.json
   #   - images/a_dot_burr.jpeg
   #   - images/a_dot_ham.jpeg
   #