浏览代码

feat: add keyboard and cursor

Lucas.Xu 2 年之前
父节点
当前提交
d200371002

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

@@ -3,12 +3,6 @@
       "type": "editor",
       "attributes": {},
       "children": [
-        {
-          "type": "image",
-          "attributes": {
-            "image_src": "https://images.squarespace-cdn.com/content/v1/617f6f16b877c06711e87373/c3f23723-37f4-44d7-9c5d-6e2a53064ae7/Asset+10.png?format=1500w"
-          }
-        },
         {
           "type": "text",
           "delta": [

+ 5 - 2
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/document_node_widget.dart

@@ -50,7 +50,7 @@ class _EditorNodeWidget extends StatelessWidget {
             GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
           () => TapGestureRecognizer(),
           (recongizer) {
-            recongizer..onTap = _onTap;
+            recongizer..onTapDown = _onTapDown;
           },
         )
       },
@@ -73,10 +73,13 @@ class _EditorNodeWidget extends StatelessWidget {
     );
   }
 
-  void _onTap() {
+  void _onTapDown(TapDownDetails details) {
     editorState.panStartOffset = null;
     editorState.panEndOffset = null;
     editorState.updateSelection();
+
+    editorState.tapOffset = details.globalPosition;
+    editorState.updateCursor();
   }
 
   void _onPanStart(DragStartDetails details) {

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

@@ -1,5 +1,6 @@
 import 'package:flowy_editor/flowy_editor.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
 
 class ImageNodeBuilder extends NodeWidgetBuilder {
   ImageNodeBuilder.create({
@@ -32,7 +33,8 @@ class _ImageNodeWidget extends StatefulWidget {
   State<_ImageNodeWidget> createState() => __ImageNodeWidgetState();
 }
 
-class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable {
+class __ImageNodeWidgetState extends State<_ImageNodeWidget>
+    with Selectable, KeyboardEventsRespondable {
   Node get node => widget.node;
   EditorState get editorState => widget.editorState;
   String get src => widget.node.attributes['image_src'] as String;
@@ -45,6 +47,27 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable {
     return [boxOffset & size];
   }
 
+  @override
+  Rect getCursorRect(Offset start) {
+    final renderBox = context.findRenderObject() as RenderBox;
+    final size = Size(5, renderBox.size.height);
+    final boxOffset = renderBox.localToGlobal(Offset.zero);
+    final cursorOffset =
+        Offset(renderBox.size.width + boxOffset.dx, boxOffset.dy);
+    return cursorOffset & size;
+  }
+
+  @override
+  KeyEventResult onKeyDown(RawKeyEvent event) {
+    if (event.logicalKey == LogicalKeyboardKey.backspace) {
+      TransactionBuilder(editorState)
+        ..deleteNode(node)
+        ..commit();
+      return KeyEventResult.handled;
+    }
+    return KeyEventResult.ignored;
+  }
+
   @override
   Widget build(BuildContext context) {
     return _build(context);

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

@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
 import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
 import 'package:url_launcher/url_launcher_string.dart';
 
 class SelectedTextNodeBuilder extends NodeWidgetBuilder {
@@ -43,22 +44,24 @@ class _SelectedTextNodeWidget extends StatefulWidget {
 }
 
 class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
-    with Selectable {
+    with Selectable, KeyboardEventsRespondable {
   TextNode get node => widget.node as TextNode;
   EditorState get editorState => widget.editorState;
 
   final _textKey = GlobalKey();
+  TextSelection? _textSelection;
 
   RenderParagraph get _renderParagraph =>
       _textKey.currentContext?.findRenderObject() as RenderParagraph;
 
   @override
   List<Rect> getOverlayRectsInRange(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
     // TODO: don't need to compute everytime.
-    var rects = _computeSelectionRects(
-      TextSelection(baseOffset: 0, extentOffset: node.toRawString().length),
-    );
+    var rects = _computeSelectionRects(textSelection);
+    _textSelection = textSelection;
 
     if (end.dy > start.dy) {
       // downward
@@ -74,13 +77,44 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
 
     final selectionBaseOffset = _getTextPositionAtOffset(start).offset;
     final selectionExtentOffset = _getTextPositionAtOffset(end).offset;
-    final textSelection = TextSelection(
+    textSelection = TextSelection(
       baseOffset: selectionBaseOffset,
       extentOffset: selectionExtentOffset,
     );
+    _textSelection = textSelection;
     return _computeSelectionRects(textSelection);
   }
 
+  @override
+  Rect getCursorRect(Offset start) {
+    final selectionBaseOffset = _getTextPositionAtOffset(start).offset;
+    final textSelection = TextSelection.collapsed(offset: selectionBaseOffset);
+    _textSelection = textSelection;
+    return _computeCursorRect(textSelection.baseOffset);
+  }
+
+  @override
+  KeyEventResult onKeyDown(RawKeyEvent event) {
+    if (event.logicalKey == LogicalKeyboardKey.backspace) {
+      final textSelection = _textSelection;
+      // TODO: just handle upforward delete.
+      if (textSelection != null) {
+        if (textSelection.isCollapsed) {
+          TransactionBuilder(editorState)
+            ..deleteText(node, textSelection.start - 1, 1)
+            ..commit();
+        } else {
+          TransactionBuilder(editorState)
+            ..deleteText(node, textSelection.start,
+                textSelection.baseOffset - textSelection.extentOffset)
+            ..commit();
+        }
+      }
+      return KeyEventResult.handled;
+    }
+    return KeyEventResult.ignored;
+  }
+
   @override
   Widget build(BuildContext context) {
     Widget richText;
@@ -124,6 +158,20 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget>
             box.toRect().size)
         .toList();
   }
+
+  Rect _computeCursorRect(int offset) {
+    final position = TextPosition(offset: offset);
+    var cursorOffset = _renderParagraph.getOffsetForCaret(position, Rect.zero);
+    cursorOffset = _renderParagraph.localToGlobal(cursorOffset);
+    final cursorHeight = _renderParagraph.getFullHeightForCaret(position)!;
+    const cursorWidth = 2;
+    return Rect.fromLTWH(
+      cursorOffset.dx - (cursorWidth / 2),
+      cursorOffset.dy,
+      cursorWidth.toDouble(),
+      cursorHeight.toDouble(),
+    );
+  }
 }
 
 extension on TextNode {

+ 82 - 14
frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart

@@ -1,4 +1,7 @@
+import 'dart:collection';
+
 import 'package:flowy_editor/document/node.dart';
+import 'package:flowy_editor/keyboard.dart';
 import 'package:flowy_editor/operation/operation.dart';
 import 'package:flowy_editor/render/selectable.dart';
 import 'package:flutter/material.dart';
@@ -13,6 +16,7 @@ class EditorState {
   final StateTree document;
   final RenderPlugins renderPlugins;
 
+  Offset? tapOffset;
   Offset? panStartOffset;
   Offset? panEndOffset;
 
@@ -25,11 +29,14 @@ class EditorState {
 
   /// TODO: move to a better place.
   Widget build(BuildContext context) {
-    return renderPlugins.buildWidget(
-      context: NodeWidgetContext(
-        buildContext: context,
-        node: document.root,
-        editorState: this,
+    return Keyboard(
+      editorState: this,
+      child: renderPlugins.buildWidget(
+        context: NodeWidgetContext(
+          buildContext: context,
+          node: document.root,
+          editorState: this,
+        ),
       ),
     );
   }
@@ -55,18 +62,45 @@ class EditorState {
 
   List<OverlayEntry> selectionOverlays = [];
 
+  void updateCursor() {
+    if (tapOffset == null) {
+      return;
+    }
+
+    // TODO: upward and backward
+    final selectedNode = _calculateSelectedNode(document.root, tapOffset!);
+    if (selectedNode.isEmpty) {
+      return;
+    }
+    final key = selectedNode.first.key;
+    if (key != null && key.currentState is Selectable) {
+      final selectable = key.currentState as Selectable;
+      final rect = selectable.getCursorRect(tapOffset!);
+      final overlay = OverlayEntry(builder: ((context) {
+        return Positioned.fromRect(
+          rect: rect,
+          child: Container(
+            color: Colors.red,
+          ),
+        );
+      }));
+      selectionOverlays.add(overlay);
+      Overlay.of(selectable.context)?.insert(overlay);
+    }
+  }
+
   void updateSelection() {
     selectionOverlays
       ..forEach((element) => element.remove())
       ..clear();
 
-    final selectedNodes = _selectedNodes;
-    if (selectedNodes.isEmpty) {
+    final selectedNodes = this.selectedNodes;
+    if (selectedNodes.isEmpty ||
+        panStartOffset == null ||
+        panEndOffset == null) {
       return;
     }
 
-    assert(panStartOffset != null && panEndOffset != null);
-
     for (final node in selectedNodes) {
       final key = node.key;
       if (key != null && key.currentState is Selectable) {
@@ -90,12 +124,46 @@ class EditorState {
     }
   }
 
-  List<Node> get _selectedNodes {
-    if (panStartOffset == null || panEndOffset == null) {
-      return [];
+  List<Node> get selectedNodes {
+    if (panStartOffset != null && panEndOffset != null) {
+      return _calculateSelectedNodes(
+          document.root, panStartOffset!, panEndOffset!);
+    }
+    if (tapOffset != null) {
+      return _calculateSelectedNode(document.root, tapOffset!);
+    }
+    return [];
+  }
+
+  List<Node> _calculateSelectedNode(Node node, Offset offset) {
+    List<Node> result = [];
+
+    /// Skip the node without parent because it is the topmost node.
+    /// Skip the node without key because it cannot get the [RenderObject].
+    if (node.parent != null && node.key != null) {
+      if (_isNodeInOffset(node, offset)) {
+        result.add(node);
+      }
+    }
+
+    ///
+    for (final child in node.children) {
+      result.addAll(_calculateSelectedNode(child, offset));
+    }
+
+    return result;
+  }
+
+  bool _isNodeInOffset(Node node, Offset offset) {
+    assert(node.key != null);
+    final renderBox =
+        node.key?.currentContext?.findRenderObject() as RenderBox?;
+    if (renderBox == null) {
+      return false;
     }
-    return _calculateSelectedNodes(
-        document.root, panStartOffset!, panEndOffset!);
+    final boxOffset = renderBox.localToGlobal(Offset.zero);
+    final boxRect = boxOffset & renderBox.size;
+    return boxRect.contains(offset);
   }
 
   List<Node> _calculateSelectedNodes(Node node, Offset start, Offset end) {

+ 45 - 0
frontend/app_flowy/packages/flowy_editor/lib/keyboard.dart

@@ -0,0 +1,45 @@
+import 'package:flutter/services.dart';
+
+import '../render/selectable.dart';
+import 'editor_state.dart';
+import 'package:flutter/material.dart';
+
+class Keyboard extends StatelessWidget {
+  final Widget child;
+  final focusNode = FocusNode();
+  final EditorState editorState;
+
+  Keyboard({
+    Key? key,
+    required this.child,
+    required this.editorState,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return Focus(
+      focusNode: focusNode,
+      autofocus: true,
+      onKey: _onKey,
+      child: child,
+    );
+  }
+
+  KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
+    if (event is! RawKeyDownEvent) {
+      return KeyEventResult.ignored;
+    }
+    List<KeyEventResult> result = [];
+    for (final node in editorState.selectedNodes) {
+      if (node.key != null &&
+          node.key?.currentState is KeyboardEventsRespondable) {
+        final respondable = node.key!.currentState as KeyboardEventsRespondable;
+        result.add(respondable.onKeyDown(event));
+      }
+    }
+    if (result.contains(KeyEventResult.handled)) {
+      return KeyEventResult.handled;
+    }
+    return KeyEventResult.ignored;
+  }
+}

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

@@ -5,4 +5,11 @@ 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);
+
+  /// Returns a [Offset] for cursor
+  Rect getCursorRect(Offset start);
+}
+
+mixin KeyboardEventsRespondable<T extends StatefulWidget> on State<T> {
+  KeyEventResult onKeyDown(RawKeyEvent event);
 }

+ 0 - 0
frontend/app_flowy/packages/flowy_editor/lib/render/selection_overlay.dart