Browse Source

feat: implement horizontal rule

Lucas.Xu 2 năm trước cách đây
mục cha
commit
e6d4f9e3f7

+ 4 - 0
frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart

@@ -2,6 +2,7 @@ import 'dart:convert';
 import 'dart:io';
 
 import 'package:example/plugin/code_block_node_widget.dart';
+import 'package:example/plugin/horizontal_rule_node_widget.dart';
 import 'package:example/plugin/tex_block_node_widget.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
@@ -121,15 +122,18 @@ class _MyHomePageState extends State<MyHomePage> {
               customBuilders: {
                 'text/code_block': CodeBlockNodeWidgetBuilder(),
                 'tex': TeXBlockNodeWidgetBuidler(),
+                'horizontal_rule': HorizontalRuleWidgetBuilder(),
               },
               shortcutEvents: [
                 enterInCodeBlock,
                 ignoreKeysInCodeBlock,
                 underscoreToItalic,
+                insertHorizontalRule,
               ],
               selectionMenuItems: [
                 codeBlockMenuItem,
                 teXBlockMenuItem,
+                horizontalRuleMenuItem,
               ],
             ),
           );

+ 163 - 0
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/horizontal_rule_node_widget.dart

@@ -0,0 +1,163 @@
+import 'dart:collection';
+
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter/material.dart';
+
+ShortcutEvent insertHorizontalRule = ShortcutEvent(
+  key: 'Horizontal rule',
+  command: 'Minus',
+  handler: _insertHorzaontalRule,
+);
+
+ShortcutEventHandler _insertHorzaontalRule = (editorState, event) {
+  final selection = editorState.service.selectionService.currentSelection.value;
+  final textNodes = editorState.service.selectionService.currentSelectedNodes
+      .whereType<TextNode>();
+  if (textNodes.length != 1 || selection == null) {
+    return KeyEventResult.ignored;
+  }
+  final textNode = textNodes.first;
+  if (textNode.toRawString() == '--') {
+    TransactionBuilder(editorState)
+      ..deleteText(textNode, 0, 2)
+      ..insertNode(
+        textNode.path,
+        Node(
+          type: 'horizontal_rule',
+          children: LinkedList(),
+          attributes: {},
+        ),
+      )
+      ..afterSelection =
+          Selection.single(path: textNode.path.next, startOffset: 0)
+      ..commit();
+    return KeyEventResult.handled;
+  }
+  return KeyEventResult.ignored;
+};
+
+SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
+  name: () => 'Horizontal rule',
+  icon: const Icon(Icons.horizontal_rule),
+  keywords: ['horizontal rule'],
+  handler: (editorState, _, __) {
+    final selection =
+        editorState.service.selectionService.currentSelection.value;
+    final textNodes = editorState.service.selectionService.currentSelectedNodes
+        .whereType<TextNode>();
+    if (selection == null || textNodes.isEmpty) {
+      return;
+    }
+    final textNode = textNodes.first;
+    if (textNode.toRawString().isEmpty) {
+      TransactionBuilder(editorState)
+        ..insertNode(
+          textNode.path,
+          Node(
+            type: 'horizontal_rule',
+            children: LinkedList(),
+            attributes: {},
+          ),
+        )
+        ..afterSelection =
+            Selection.single(path: textNode.path.next, startOffset: 0)
+        ..commit();
+    } else {
+      TransactionBuilder(editorState)
+        ..insertNode(
+          selection.end.path.next,
+          TextNode(
+            type: 'text',
+            children: LinkedList(),
+            attributes: {
+              'subtype': 'horizontal_rule',
+            },
+            delta: Delta()..insert('---'),
+          ),
+        )
+        ..afterSelection = selection
+        ..commit();
+    }
+  },
+);
+
+class HorizontalRuleWidgetBuilder extends NodeWidgetBuilder<Node> {
+  @override
+  Widget build(NodeWidgetContext<Node> context) {
+    return _HorizontalRuleWidget(
+      key: context.node.key,
+      node: context.node,
+      editorState: context.editorState,
+    );
+  }
+
+  @override
+  NodeValidator<Node> get nodeValidator => (node) {
+        return true;
+      };
+}
+
+class _HorizontalRuleWidget extends StatefulWidget {
+  const _HorizontalRuleWidget({
+    Key? key,
+    required this.node,
+    required this.editorState,
+  }) : super(key: key);
+
+  final Node node;
+  final EditorState editorState;
+
+  @override
+  State<_HorizontalRuleWidget> createState() => __HorizontalRuleWidgetState();
+}
+
+class __HorizontalRuleWidgetState extends State<_HorizontalRuleWidget>
+    with SelectableMixin {
+  RenderBox get _renderBox => context.findRenderObject() as RenderBox;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      padding: const EdgeInsets.symmetric(vertical: 10),
+      child: Container(
+        height: 1,
+        color: Colors.grey,
+      ),
+    );
+  }
+
+  @override
+  Position start() => Position(path: widget.node.path, offset: 0);
+
+  @override
+  Position end() => Position(path: widget.node.path, offset: 1);
+
+  @override
+  Position getPositionInOffset(Offset start) => end();
+
+  @override
+  bool get shouldCursorBlink => false;
+
+  @override
+  CursorStyle get cursorStyle => CursorStyle.borderLine;
+
+  @override
+  Rect? getCursorRectInPosition(Position position) {
+    final size = _renderBox.size;
+    return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height);
+  }
+
+  @override
+  List<Rect> getRectsInSelection(Selection selection) =>
+      [Offset.zero & _renderBox.size];
+
+  @override
+  Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
+        path: widget.node.path,
+        startOffset: 0,
+        endOffset: 1,
+      );
+
+  @override
+  Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset);
+}

+ 25 - 3
frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/cursor_widget.dart

@@ -1,5 +1,6 @@
 import 'dart:async';
 
+import 'package:appflowy_editor/src/render/selection/selectable.dart';
 import 'package:flutter/material.dart';
 
 class CursorWidget extends StatefulWidget {
@@ -9,9 +10,13 @@ class CursorWidget extends StatefulWidget {
     required this.rect,
     required this.color,
     this.blinkingInterval = 0.5,
+    this.shouldBlink = true,
+    this.cursorStyle = CursorStyle.verticalLine,
   }) : super(key: key);
 
   final double blinkingInterval; // milliseconds
+  final bool shouldBlink;
+  final CursorStyle cursorStyle;
   final Color color;
   final Rect rect;
   final LayerLink layerLink;
@@ -67,11 +72,28 @@ class CursorWidgetState extends State<CursorWidget> {
         // 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,
-          ),
+          child: _buildCursor(context),
         ),
       ),
     );
   }
+
+  Widget _buildCursor(BuildContext context) {
+    var color = widget.color;
+    if (widget.shouldBlink && !showCursor) {
+      color = Colors.transparent;
+    }
+    switch (widget.cursorStyle) {
+      case CursorStyle.verticalLine:
+        return Container(
+          color: color,
+        );
+      case CursorStyle.borderLine:
+        return Container(
+          decoration: BoxDecoration(
+            border: Border.all(color: color, width: 2),
+          ),
+        );
+    }
+  }
 }

+ 9 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/selection/selectable.dart

@@ -2,6 +2,11 @@ import 'package:appflowy_editor/src/document/position.dart';
 import 'package:appflowy_editor/src/document/selection.dart';
 import 'package:flutter/material.dart';
 
+enum CursorStyle {
+  verticalLine,
+  borderLine,
+}
+
 /// [SelectableMixin] is used for the editor to calculate the position
 ///   and size of the selection.
 ///
@@ -53,4 +58,8 @@ mixin SelectableMixin<T extends StatefulWidget> on State<T> {
   Selection? getWorldBoundaryInOffset(Offset start) {
     return null;
   }
+
+  bool get shouldCursorBlink => true;
+
+  CursorStyle get cursorStyle => CursorStyle.verticalLine;
 }

+ 5 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart

@@ -3,7 +3,6 @@ import 'package:appflowy_editor/src/service/internal_key_event_handlers/number_l
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
-import 'package:appflowy_editor/src/extensions/path_extensions.dart';
 
 // Handle delete text.
 ShortcutEventHandler deleteTextHandler = (editorState, event) {
@@ -84,6 +83,11 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
     }
   } else {
     if (textNodes.isEmpty) {
+      if (nonTextNodes.isNotEmpty) {
+        transactionBuilder.afterSelection =
+            Selection.collapsed(selection.start);
+      }
+      transactionBuilder.commit();
       return KeyEventResult.handled;
     }
     final startPosition = selection.start;

+ 2 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart

@@ -457,6 +457,8 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
           rect: cursorRect,
           color: widget.cursorColor,
           layerLink: node.layerLink,
+          shouldBlink: selectable.shouldCursorBlink,
+          cursorStyle: selectable.cursorStyle,
         ),
       );