瀏覽代碼

Merge pull request #1519 from LucasXu0/plugin

Separate AppFlowy Editor Plugins
Lucas.Xu 2 年之前
父節點
當前提交
89f89e8822
共有 21 個文件被更改,包括 798 次插入473 次删除
  1. 21 3
      frontend/app_flowy/lib/plugins/document/document_page.dart
  2. 26 0
      frontend/app_flowy/packages/appflowy_editor/example/lib/pages/simple_editor.dart
  3. 0 166
      frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/horizontal_rule_node_widget.dart
  4. 0 193
      frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/tex_block_node_widget.dart
  5. 2 0
      frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml
  6. 30 0
      frontend/app_flowy/packages/appflowy_editor_plugins/.gitignore
  7. 10 0
      frontend/app_flowy/packages/appflowy_editor_plugins/.metadata
  8. 3 0
      frontend/app_flowy/packages/appflowy_editor_plugins/CHANGELOG.md
  9. 1 0
      frontend/app_flowy/packages/appflowy_editor_plugins/LICENSE
  10. 39 0
      frontend/app_flowy/packages/appflowy_editor_plugins/README.md
  11. 4 0
      frontend/app_flowy/packages/appflowy_editor_plugins/analysis_options.yaml
  12. 12 0
      frontend/app_flowy/packages/appflowy_editor_plugins/lib/appflowy_editor_plugins.dart
  13. 65 110
      frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/code_block/code_block_node_widget.dart
  14. 124 0
      frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/code_block/code_block_shortcut_event.dart
  15. 84 0
      frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/divider/divider_node_widget.dart
  16. 72 0
      frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/divider/divider_shortcut_event.dart
  17. 220 0
      frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/math_ equation/math_equation_node_widget.dart
  18. 60 0
      frontend/app_flowy/packages/appflowy_editor_plugins/pubspec.yaml
  19. 1 0
      frontend/app_flowy/packages/appflowy_editor_plugins/test/appflowy_editor_plugins_test.dart
  20. 22 1
      frontend/app_flowy/pubspec.lock
  21. 2 0
      frontend/app_flowy/pubspec.yaml

+ 21 - 3
frontend/app_flowy/lib/plugins/document/document_page.dart

@@ -1,8 +1,8 @@
 import 'package:app_flowy/plugins/document/editor_styles.dart';
-import 'package:app_flowy/plugins/document/presentation/plugins/horizontal_rule_node_widget.dart';
 import 'package:app_flowy/startup/startup.dart';
 import 'package:app_flowy/plugins/document/presentation/banner.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
 import 'package:flowy_infra_ui/widget/error_page.dart';
 import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
 import 'package:flutter/material.dart';
@@ -101,10 +101,28 @@ class _DocumentPageState extends State<DocumentPage> {
       editorState: editorState,
       autoFocus: editorState.document.isEmpty,
       customBuilders: {
-        'horizontal_rule': HorizontalRuleWidgetBuilder(),
+        // Divider
+        kDividerType: DividerWidgetBuilder(),
+        // Math Equation
+        kMathEquationType: MathEquationNodeWidgetBuidler(),
+        // Code Block
+        kCodeBlockType: CodeBlockNodeWidgetBuilder(),
       },
       shortcutEvents: [
-        insertHorizontalRule,
+        // Divider
+        insertDividerEvent,
+        // Code Block
+        enterInCodeBlock,
+        ignoreKeysInCodeBlock,
+        pasteInCodeBlock,
+      ],
+      selectionMenuItems: [
+        // Divider
+        dividerMenuItem,
+        // Math Equation
+        mathEquationMenuItem,
+        // Code Block
+        codeBlockMenuItem,
       ],
       themeData: theme.copyWith(extensions: [
         ...theme.extensions.values,

+ 26 - 0
frontend/app_flowy/packages/appflowy_editor/example/lib/pages/simple_editor.dart

@@ -1,6 +1,7 @@
 import 'dart:convert';
 
 import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
 import 'package:flutter/material.dart';
 
 class SimpleEditor extends StatelessWidget {
@@ -30,10 +31,35 @@ class SimpleEditor extends StatelessWidget {
             ),
           );
           onEditorStateChange(editorState);
+
           return AppFlowyEditor(
             editorState: editorState,
             themeData: themeData,
             autoFocus: editorState.document.isEmpty,
+            customBuilders: {
+              // Divider
+              kDividerType: DividerWidgetBuilder(),
+              // Math Equation
+              kMathEquationType: MathEquationNodeWidgetBuidler(),
+              // Code Block
+              kCodeBlockType: CodeBlockNodeWidgetBuilder(),
+            },
+            shortcutEvents: [
+              // Divider
+              insertDividerEvent,
+              // Code Block
+              enterInCodeBlock,
+              ignoreKeysInCodeBlock,
+              pasteInCodeBlock,
+            ],
+            selectionMenuItems: [
+              // Divider
+              dividerMenuItem,
+              // Math Equation
+              mathEquationMenuItem,
+              // Code Block
+              codeBlockMenuItem,
+            ],
           );
         } else {
           return const Center(

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

@@ -1,166 +0,0 @@
-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.toPlainText() == '--') {
-    final transaction = editorState.transaction
-      ..deleteText(textNode, 0, 2)
-      ..insertNode(
-        textNode.path,
-        Node(
-          type: 'horizontal_rule',
-          children: LinkedList(),
-          attributes: {},
-        ),
-      )
-      ..afterSelection =
-          Selection.single(path: textNode.path.next, startOffset: 0);
-    editorState.apply(transaction);
-    return KeyEventResult.handled;
-  }
-  return KeyEventResult.ignored;
-};
-
-SelectionMenuItem horizontalRuleMenuItem = SelectionMenuItem(
-  name: () => 'Horizontal rule',
-  icon: (_, __) => const Icon(
-    Icons.horizontal_rule,
-    color: Colors.black,
-    size: 18.0,
-  ),
-  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.toPlainText().isEmpty) {
-      final transaction = editorState.transaction
-        ..insertNode(
-          textNode.path,
-          Node(
-            type: 'horizontal_rule',
-            children: LinkedList(),
-            attributes: {},
-          ),
-        )
-        ..afterSelection =
-            Selection.single(path: textNode.path.next, startOffset: 0);
-      editorState.apply(transaction);
-    } else {
-      final transaction = editorState.transaction
-        ..insertNode(
-          selection.end.path.next,
-          TextNode(
-            children: LinkedList(),
-            attributes: {
-              'subtype': 'horizontal_rule',
-            },
-            delta: Delta()..insert('---'),
-          ),
-        )
-        ..afterSelection = selection;
-      editorState.apply(transaction);
-    }
-  },
-);
-
-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);
-}

+ 0 - 193
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/tex_block_node_widget.dart

@@ -1,193 +0,0 @@
-import 'dart:collection';
-
-import 'package:appflowy_editor/appflowy_editor.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter_math_fork/flutter_math.dart';
-
-SelectionMenuItem teXBlockMenuItem = SelectionMenuItem(
-  name: () => 'Tex',
-  icon: (_, __) => const Icon(
-    Icons.text_fields_rounded,
-    color: Colors.black,
-    size: 18.0,
-  ),
-  keywords: ['tex, latex, katex'],
-  handler: (editorState, _, __) {
-    final selection =
-        editorState.service.selectionService.currentSelection.value;
-    final textNodes = editorState.service.selectionService.currentSelectedNodes
-        .whereType<TextNode>();
-    if (selection == null || !selection.isCollapsed || textNodes.isEmpty) {
-      return;
-    }
-    final Path texNodePath;
-    if (textNodes.first.toPlainText().isEmpty) {
-      texNodePath = selection.end.path;
-      final transaction = editorState.transaction
-        ..insertNode(
-          selection.end.path,
-          Node(
-            type: 'tex',
-            children: LinkedList(),
-            attributes: {'tex': ''},
-          ),
-        )
-        ..deleteNode(textNodes.first)
-        ..afterSelection = selection;
-      editorState.apply(transaction);
-    } else {
-      texNodePath = selection.end.path.next;
-      final transaction = editorState.transaction
-        ..insertNode(
-          selection.end.path.next,
-          Node(
-            type: 'tex',
-            children: LinkedList(),
-            attributes: {'tex': ''},
-          ),
-        )
-        ..afterSelection = selection;
-      editorState.apply(transaction);
-    }
-    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
-      final texState =
-          editorState.document.nodeAtPath(texNodePath)?.key?.currentState;
-      if (texState != null && texState is __TeXBlockNodeWidgetState) {
-        texState.showEditingDialog();
-      }
-    });
-  },
-);
-
-class TeXBlockNodeWidgetBuidler extends NodeWidgetBuilder<Node> {
-  @override
-  Widget build(NodeWidgetContext<Node> context) {
-    return _TeXBlockNodeWidget(
-      key: context.node.key,
-      node: context.node,
-      editorState: context.editorState,
-    );
-  }
-
-  @override
-  NodeValidator<Node> get nodeValidator => (node) {
-        return node.attributes['tex'] is String;
-      };
-}
-
-class _TeXBlockNodeWidget extends StatefulWidget {
-  const _TeXBlockNodeWidget({
-    Key? key,
-    required this.node,
-    required this.editorState,
-  }) : super(key: key);
-
-  final Node node;
-  final EditorState editorState;
-
-  @override
-  State<_TeXBlockNodeWidget> createState() => __TeXBlockNodeWidgetState();
-}
-
-class __TeXBlockNodeWidgetState extends State<_TeXBlockNodeWidget> {
-  String get _tex => widget.node.attributes['tex'] as String;
-  bool _isHover = false;
-
-  @override
-  Widget build(BuildContext context) {
-    return InkWell(
-      onHover: (value) {
-        setState(() {
-          _isHover = value;
-        });
-      },
-      onTap: () {
-        showEditingDialog();
-      },
-      child: Stack(
-        children: [
-          _buildTex(context),
-          if (_isHover) _buildDeleteButton(context),
-        ],
-      ),
-    );
-  }
-
-  Widget _buildTex(BuildContext context) {
-    return Container(
-      width: MediaQuery.of(context).size.width,
-      padding: const EdgeInsets.symmetric(vertical: 20),
-      decoration: BoxDecoration(
-        borderRadius: const BorderRadius.all(Radius.circular(8.0)),
-        color: _isHover ? Colors.grey[200] : Colors.transparent,
-      ),
-      child: Center(
-        child: Math.tex(
-          _tex,
-          textStyle: const TextStyle(fontSize: 20),
-          mathStyle: MathStyle.display,
-        ),
-      ),
-    );
-  }
-
-  Widget _buildDeleteButton(BuildContext context) {
-    return Positioned(
-      top: -5,
-      right: -5,
-      child: IconButton(
-        icon: Icon(
-          Icons.delete_outline,
-          color: Colors.blue[400],
-          size: 16,
-        ),
-        onPressed: () {
-          final transaction = widget.editorState.transaction
-            ..deleteNode(widget.node);
-          widget.editorState.apply(transaction);
-        },
-      ),
-    );
-  }
-
-  void showEditingDialog() {
-    showDialog(
-      context: context,
-      builder: (context) {
-        final controller = TextEditingController(text: _tex);
-        return AlertDialog(
-          title: const Text('Edit Katex'),
-          content: TextField(
-            controller: controller,
-            maxLines: null,
-            decoration: const InputDecoration(
-              border: OutlineInputBorder(),
-            ),
-          ),
-          actions: [
-            TextButton(
-              onPressed: () {
-                Navigator.of(context).pop();
-              },
-              child: const Text('Cancel'),
-            ),
-            TextButton(
-              onPressed: () {
-                Navigator.of(context).pop();
-                if (controller.text != _tex) {
-                  final transaction = widget.editorState.transaction
-                    ..updateNode(
-                      widget.node,
-                      {'tex': controller.text},
-                    );
-                  widget.editorState.apply(transaction);
-                }
-              },
-              child: const Text('OK'),
-            ),
-          ],
-        );
-      },
-    );
-  }
-}

+ 2 - 0
frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml

@@ -45,6 +45,8 @@ dependencies:
   universal_html: ^2.0.8
   highlight: ^0.7.0
   flutter_math_fork: ^0.6.3+1
+  appflowy_editor_plugins:
+    path: ../../../packages/appflowy_editor_plugins
 
 dev_dependencies:
   flutter_test:

+ 30 - 0
frontend/app_flowy/packages/appflowy_editor_plugins/.gitignore

@@ -0,0 +1,30 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.buildlog/
+.history
+.svn/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
+/pubspec.lock
+**/doc/api/
+.dart_tool/
+.packages
+build/

+ 10 - 0
frontend/app_flowy/packages/appflowy_editor_plugins/.metadata

@@ -0,0 +1,10 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+  revision: f1875d570e39de09040c8f79aa13cc56baab8db1
+  channel: unknown
+
+project_type: package

+ 3 - 0
frontend/app_flowy/packages/appflowy_editor_plugins/CHANGELOG.md

@@ -0,0 +1,3 @@
+## 0.0.1
+
+* TODO: Describe initial release.

+ 1 - 0
frontend/app_flowy/packages/appflowy_editor_plugins/LICENSE

@@ -0,0 +1 @@
+TODO: Add your license here.

+ 39 - 0
frontend/app_flowy/packages/appflowy_editor_plugins/README.md

@@ -0,0 +1,39 @@
+<!-- 
+This README describes the package. If you publish this package to pub.dev,
+this README's contents appear on the landing page for your package.
+
+For information about how to write a good package README, see the guide for
+[writing package pages](https://dart.dev/guides/libraries/writing-package-pages). 
+
+For general information about developing packages, see the Dart guide for
+[creating packages](https://dart.dev/guides/libraries/create-library-packages)
+and the Flutter guide for
+[developing packages and plugins](https://flutter.dev/developing-packages). 
+-->
+
+TODO: Put a short description of the package here that helps potential users
+know whether this package might be useful for them.
+
+## Features
+
+TODO: List what your package can do. Maybe include images, gifs, or videos.
+
+## Getting started
+
+TODO: List prerequisites and provide or point to information on how to
+start using the package.
+
+## Usage
+
+TODO: Include short and useful examples for package users. Add longer examples
+to `/example` folder. 
+
+```dart
+const like = 'sample';
+```
+
+## Additional information
+
+TODO: Tell users more about the package: where to find more information, how to 
+contribute to the package, how to file issues, what response they can expect 
+from the package authors, and more.

+ 4 - 0
frontend/app_flowy/packages/appflowy_editor_plugins/analysis_options.yaml

@@ -0,0 +1,4 @@
+include: package:flutter_lints/flutter.yaml
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options

+ 12 - 0
frontend/app_flowy/packages/appflowy_editor_plugins/lib/appflowy_editor_plugins.dart

@@ -0,0 +1,12 @@
+library appflowy_editor_plugins;
+
+// Divider
+export 'src/divider/divider_node_widget.dart';
+export 'src/divider/divider_shortcut_event.dart';
+
+// Math Equation
+export 'src/math_ equation/math_equation_node_widget.dart';
+
+// Code Block
+export 'src/code_block/code_block_node_widget.dart';
+export 'src/code_block/code_block_shortcut_event.dart';

+ 65 - 110
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/code_block_node_widget.dart → frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/code_block/code_block_node_widget.dart

@@ -1,93 +1,12 @@
-import 'dart:collection';
-
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter/material.dart';
 import 'package:highlight/highlight.dart' as highlight;
 import 'package:highlight/languages/all.dart';
 
-ShortcutEvent enterInCodeBlock = ShortcutEvent(
-  key: 'Enter in code block',
-  command: 'enter',
-  handler: _enterInCodeBlockHandler,
-);
-
-ShortcutEvent ignoreKeysInCodeBlock = ShortcutEvent(
-  key: 'White space in code block',
-  command: 'space,slash,shift+underscore',
-  handler: _ignorekHandler,
-);
-
-ShortcutEventHandler _enterInCodeBlockHandler = (editorState, event) {
-  final selection = editorState.service.selectionService.currentSelection.value;
-  final nodes = editorState.service.selectionService.currentSelectedNodes;
-  final codeBlockNode =
-      nodes.whereType<TextNode>().where((node) => node.id == 'text/code_block');
-  if (codeBlockNode.length != 1 || selection == null) {
-    return KeyEventResult.ignored;
-  }
-  if (selection.isCollapsed) {
-    final transaction = editorState.transaction
-      ..insertText(codeBlockNode.first, selection.end.offset, '\n');
-    editorState.apply(transaction);
-    return KeyEventResult.handled;
-  }
-  return KeyEventResult.ignored;
-};
-
-ShortcutEventHandler _ignorekHandler = (editorState, event) {
-  final nodes = editorState.service.selectionService.currentSelectedNodes;
-  final codeBlockNodes =
-      nodes.whereType<TextNode>().where((node) => node.id == 'text/code_block');
-  if (codeBlockNodes.length == 1) {
-    return KeyEventResult.skipRemainingHandlers;
-  }
-  return KeyEventResult.ignored;
-};
-
-SelectionMenuItem codeBlockMenuItem = SelectionMenuItem(
-  name: () => 'Code Block',
-  icon: (_, __) => const Icon(
-    Icons.abc,
-    color: Colors.black,
-    size: 18.0,
-  ),
-  keywords: ['code block'],
-  handler: (editorState, _, __) {
-    final selection =
-        editorState.service.selectionService.currentSelection.value;
-    final textNodes = editorState.service.selectionService.currentSelectedNodes
-        .whereType<TextNode>();
-    if (selection == null || textNodes.isEmpty) {
-      return;
-    }
-    if (textNodes.first.toPlainText().isEmpty) {
-      final transaction = editorState.transaction
-        ..updateNode(textNodes.first, {
-          'subtype': 'code_block',
-          'theme': 'vs',
-          'language': null,
-        })
-        ..afterSelection = selection;
-      editorState.apply(transaction);
-    } else {
-      final transaction = editorState.transaction
-        ..insertNode(
-          selection.end.path.next,
-          TextNode(
-            children: LinkedList(),
-            attributes: {
-              'subtype': 'code_block',
-              'theme': 'vs',
-              'language': null,
-            },
-            delta: Delta()..insert('\n'),
-          ),
-        )
-        ..afterSelection = selection;
-      editorState.apply(transaction);
-    }
-  },
-);
+const String kCodeBlockType = 'text/$kCodeBlockSubType';
+const String kCodeBlockSubType = 'code_block';
+const String kCodeBlockAttrTheme = 'theme';
+const String kCodeBlockAttrLanguage = 'language';
 
 class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
   @override
@@ -101,7 +20,8 @@ class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
 
   @override
   NodeValidator<Node> get nodeValidator => (node) {
-        return node is TextNode && node.attributes['theme'] is String;
+        return node is TextNode &&
+            node.attributes[kCodeBlockAttrTheme] is String;
       };
 }
 
@@ -121,9 +41,11 @@ class _CodeBlockNodeWidge extends StatefulWidget {
 
 class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
     with SelectableMixin, DefaultSelectable {
-  final _richTextKey = GlobalKey(debugLabel: 'code_block_text');
-  final _padding = const EdgeInsets.only(left: 20, top: 20, bottom: 20);
-  String? get _language => widget.textNode.attributes['language'] as String?;
+  final _richTextKey = GlobalKey(debugLabel: kCodeBlockType);
+  final _padding = const EdgeInsets.only(left: 20, top: 30, bottom: 30);
+  bool _isHover = false;
+  String? get _language =>
+      widget.textNode.attributes[kCodeBlockAttrLanguage] as String?;
   String? _detectLanguage;
 
   @override
@@ -138,11 +60,20 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
 
   @override
   Widget build(BuildContext context) {
-    return Stack(
-      children: [
-        _buildCodeBlock(context),
-        _buildSwitchCodeButton(context),
-      ],
+    return InkWell(
+      onHover: (value) {
+        setState(() {
+          _isHover = value;
+        });
+      },
+      onTap: () {},
+      child: Stack(
+        children: [
+          _buildCodeBlock(context),
+          _buildSwitchCodeButton(context),
+          if (_isHover) _buildDeleteButton(context),
+        ],
+      ),
     );
   }
 
@@ -177,25 +108,49 @@ class __CodeBlockNodeWidgeState extends State<_CodeBlockNodeWidge>
   Widget _buildSwitchCodeButton(BuildContext context) {
     return Positioned(
       top: -5,
-      right: 0,
-      child: DropdownButton<String>(
-        value: _detectLanguage,
-        onChanged: (value) {
+      left: 10,
+      child: SizedBox(
+        height: 35,
+        child: DropdownButton<String>(
+          value: _detectLanguage,
+          iconSize: 14.0,
+          onChanged: (value) {
+            final transaction = widget.editorState.transaction
+              ..updateNode(widget.textNode, {
+                kCodeBlockAttrLanguage: value,
+              });
+            widget.editorState.apply(transaction);
+          },
+          items:
+              allLanguages.keys.map<DropdownMenuItem<String>>((String value) {
+            return DropdownMenuItem<String>(
+              value: value,
+              child: Text(
+                value,
+                style: const TextStyle(fontSize: 12.0),
+              ),
+            );
+          }).toList(growable: false),
+        ),
+      ),
+    );
+  }
+
+  Widget _buildDeleteButton(BuildContext context) {
+    return Positioned(
+      top: -5,
+      right: -5,
+      child: IconButton(
+        icon: Icon(
+          Icons.delete_forever_outlined,
+          color: widget.editorState.editorStyle.selectionMenuItemIconColor,
+          size: 16,
+        ),
+        onPressed: () {
           final transaction = widget.editorState.transaction
-            ..updateNode(widget.textNode, {
-              'language': value,
-            });
+            ..deleteNode(widget.textNode);
           widget.editorState.apply(transaction);
         },
-        items: allLanguages.keys.map<DropdownMenuItem<String>>((String value) {
-          return DropdownMenuItem<String>(
-            value: value,
-            child: Text(
-              value,
-              style: const TextStyle(fontSize: 12.0),
-            ),
-          );
-        }).toList(growable: false),
       ),
     );
   }

+ 124 - 0
frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/code_block/code_block_shortcut_event.dart

@@ -0,0 +1,124 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor_plugins/src/code_block/code_block_node_widget.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+ShortcutEvent enterInCodeBlock = ShortcutEvent(
+  key: 'Press Enter In Code Block',
+  command: 'enter',
+  handler: _enterInCodeBlockHandler,
+);
+
+ShortcutEvent ignoreKeysInCodeBlock = ShortcutEvent(
+  key: 'White space in code block',
+  command: 'space, slash, shift+underscore',
+  handler: _ignorekHandler,
+);
+
+ShortcutEvent pasteInCodeBlock = ShortcutEvent(
+  key: 'Paste in code block',
+  command: 'meta+v',
+  windowsCommand: 'ctrl+v',
+  linuxCommand: 'ctrl+v',
+  handler: _pasteHandler,
+);
+
+ShortcutEventHandler _enterInCodeBlockHandler = (editorState, event) {
+  final selection = editorState.service.selectionService.currentSelection.value;
+  final nodes = editorState.service.selectionService.currentSelectedNodes;
+  final codeBlockNode =
+      nodes.whereType<TextNode>().where((node) => node.id == kCodeBlockType);
+  if (codeBlockNode.length != 1 ||
+      selection == null ||
+      !selection.isCollapsed) {
+    return KeyEventResult.ignored;
+  }
+
+  final transaction = editorState.transaction
+    ..insertText(
+      codeBlockNode.first,
+      selection.end.offset,
+      '\n',
+    );
+  editorState.apply(transaction);
+  return KeyEventResult.handled;
+};
+
+ShortcutEventHandler _ignorekHandler = (editorState, event) {
+  final nodes = editorState.service.selectionService.currentSelectedNodes;
+  final codeBlockNodes =
+      nodes.whereType<TextNode>().where((node) => node.id == kCodeBlockType);
+  if (codeBlockNodes.length == 1) {
+    return KeyEventResult.skipRemainingHandlers;
+  }
+  return KeyEventResult.ignored;
+};
+
+ShortcutEventHandler _pasteHandler = (editorState, event) {
+  final selection = editorState.service.selectionService.currentSelection.value;
+  final nodes = editorState.service.selectionService.currentSelectedNodes;
+  final codeBlockNodes =
+      nodes.whereType<TextNode>().where((node) => node.id == kCodeBlockType);
+  if (selection != null &&
+      selection.isCollapsed &&
+      codeBlockNodes.length == 1) {
+    Clipboard.getData(Clipboard.kTextPlain).then((value) {
+      final text = value?.text;
+      if (text == null) return;
+      final transaction = editorState.transaction;
+      transaction.insertText(
+        codeBlockNodes.first,
+        selection.startIndex,
+        text,
+      );
+      editorState.apply(transaction);
+    });
+    return KeyEventResult.handled;
+  }
+  return KeyEventResult.ignored;
+};
+
+SelectionMenuItem codeBlockMenuItem = SelectionMenuItem(
+  name: () => 'Code Block',
+  icon: (editorState, onSelected) => Icon(
+    Icons.abc,
+    color: onSelected
+        ? editorState.editorStyle.selectionMenuItemSelectedIconColor
+        : editorState.editorStyle.selectionMenuItemIconColor,
+    size: 18.0,
+  ),
+  keywords: ['code block', 'code snippet'],
+  handler: (editorState, _, __) {
+    final selection =
+        editorState.service.selectionService.currentSelection.value;
+    final textNodes = editorState.service.selectionService.currentSelectedNodes
+        .whereType<TextNode>();
+    if (selection == null || textNodes.isEmpty) {
+      return;
+    }
+    final transaction = editorState.transaction;
+    if (textNodes.first.toPlainText().isEmpty) {
+      transaction.updateNode(textNodes.first, {
+        BuiltInAttributeKey.subtype: kCodeBlockSubType,
+        kCodeBlockAttrTheme: 'vs',
+        kCodeBlockAttrLanguage: null,
+      });
+      transaction.afterSelection = selection;
+      editorState.apply(transaction);
+    } else {
+      transaction.insertNode(
+        selection.end.path,
+        TextNode(
+          attributes: {
+            BuiltInAttributeKey.subtype: kCodeBlockSubType,
+            kCodeBlockAttrTheme: 'vs',
+            kCodeBlockAttrLanguage: null,
+          },
+          delta: Delta()..insert('\n'),
+        ),
+      );
+      transaction.afterSelection = selection;
+    }
+    editorState.apply(transaction);
+  },
+);

+ 84 - 0
frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/divider/divider_node_widget.dart

@@ -0,0 +1,84 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter/material.dart';
+
+const String kDividerType = 'divider';
+
+class DividerWidgetBuilder extends NodeWidgetBuilder<Node> {
+  @override
+  Widget build(NodeWidgetContext<Node> context) {
+    return _DividerWidget(
+      key: context.node.key,
+      node: context.node,
+      editorState: context.editorState,
+    );
+  }
+
+  @override
+  NodeValidator<Node> get nodeValidator => (node) {
+        return true;
+      };
+}
+
+class _DividerWidget extends StatefulWidget {
+  const _DividerWidget({
+    Key? key,
+    required this.node,
+    required this.editorState,
+  }) : super(key: key);
+
+  final Node node;
+  final EditorState editorState;
+
+  @override
+  State<_DividerWidget> createState() => _DividerWidgetState();
+}
+
+class _DividerWidgetState extends State<_DividerWidget> 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);
+}

+ 72 - 0
frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/divider/divider_shortcut_event.dart

@@ -0,0 +1,72 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor_plugins/src/divider/divider_node_widget.dart';
+import 'package:flutter/material.dart';
+
+// insert divider into a document by typing three minuses.
+// ---
+ShortcutEvent insertDividerEvent = ShortcutEvent(
+  key: 'Divider',
+  command: 'Minus',
+  handler: _insertDividerHandler,
+);
+
+ShortcutEventHandler _insertDividerHandler = (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.toPlainText() != '--') {
+    return KeyEventResult.ignored;
+  }
+  final transaction = editorState.transaction
+    ..deleteText(textNode, 0, 2) // remove the existing minuses.
+    ..insertNode(textNode.path, Node(type: kDividerType)) // insert the divder
+    ..afterSelection = Selection.single(
+      // update selection to the next text node.
+      path: textNode.path.next,
+      startOffset: 0,
+    );
+  editorState.apply(transaction);
+  return KeyEventResult.handled;
+};
+
+SelectionMenuItem dividerMenuItem = SelectionMenuItem(
+  name: () => 'Divider',
+  icon: (editorState, onSelected) => Icon(
+    Icons.horizontal_rule,
+    color: onSelected
+        ? editorState.editorStyle.selectionMenuItemSelectedIconColor
+        : editorState.editorStyle.selectionMenuItemIconColor,
+    size: 18.0,
+  ),
+  keywords: ['horizontal rule', 'divider'],
+  handler: (editorState, _, __) {
+    final selection =
+        editorState.service.selectionService.currentSelection.value;
+    final textNodes = editorState.service.selectionService.currentSelectedNodes
+        .whereType<TextNode>();
+    if (textNodes.length != 1 || selection == null) {
+      return;
+    }
+    final textNode = textNodes.first;
+    // insert the divider at current path if the text node is empty.
+    if (textNode.toPlainText().isEmpty) {
+      final transaction = editorState.transaction
+        ..insertNode(textNode.path, Node(type: kDividerType))
+        ..afterSelection = Selection.single(
+          path: textNode.path.next,
+          startOffset: 0,
+        );
+      editorState.apply(transaction);
+    } else {
+      // insert the divider at the path next to current path if the text node is not empty.
+      final transaction = editorState.transaction
+        ..insertNode(selection.end.path.next, Node(type: kDividerType))
+        ..afterSelection = selection;
+      editorState.apply(transaction);
+    }
+  },
+);

+ 220 - 0
frontend/app_flowy/packages/appflowy_editor_plugins/lib/src/math_ equation/math_equation_node_widget.dart

@@ -0,0 +1,220 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_math_fork/flutter_math.dart';
+
+const String kMathEquationType = 'math_equation';
+const String kMathEquationAttr = 'math_equation';
+
+// TODO: l10n
+SelectionMenuItem mathEquationMenuItem = SelectionMenuItem(
+  name: () => 'Math Equation',
+  icon: (editorState, onSelected) => Icon(
+    Icons.text_fields_rounded,
+    color: onSelected
+        ? editorState.editorStyle.selectionMenuItemSelectedIconColor
+        : editorState.editorStyle.selectionMenuItemIconColor,
+    size: 18.0,
+  ),
+  keywords: ['tex, latex, katex', 'math equation'],
+  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;
+    final Path mathEquationNodePath;
+    if (textNode.toPlainText().isEmpty) {
+      mathEquationNodePath = selection.end.path;
+    } else {
+      mathEquationNodePath = selection.end.path.next;
+    }
+    // insert the math equation node
+    final transaction = editorState.transaction
+      ..insertNode(
+        mathEquationNodePath,
+        Node(type: kMathEquationType, attributes: {kMathEquationAttr: ''}),
+      )
+      ..afterSelection = selection;
+    editorState.apply(transaction);
+
+    // tricy to show the editing dialog.
+    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+      final mathEquationState = editorState.document
+          .nodeAtPath(mathEquationNodePath)
+          ?.key
+          ?.currentState;
+      if (mathEquationState != null &&
+          mathEquationState is _MathEquationNodeWidgetState) {
+        mathEquationState.showEditingDialog();
+      }
+    });
+  },
+);
+
+class MathEquationNodeWidgetBuidler extends NodeWidgetBuilder<Node> {
+  @override
+  Widget build(NodeWidgetContext<Node> context) {
+    return _MathEquationNodeWidget(
+      key: context.node.key,
+      node: context.node,
+      editorState: context.editorState,
+    );
+  }
+
+  @override
+  NodeValidator<Node> get nodeValidator =>
+      (node) => node.attributes[kMathEquationAttr] is String;
+}
+
+class _MathEquationNodeWidget extends StatefulWidget {
+  const _MathEquationNodeWidget({
+    Key? key,
+    required this.node,
+    required this.editorState,
+  }) : super(key: key);
+
+  final Node node;
+  final EditorState editorState;
+
+  @override
+  State<_MathEquationNodeWidget> createState() =>
+      _MathEquationNodeWidgetState();
+}
+
+class _MathEquationNodeWidgetState extends State<_MathEquationNodeWidget> {
+  String get _mathEquation =>
+      widget.node.attributes[kMathEquationAttr] as String;
+  bool _isHover = false;
+
+  @override
+  Widget build(BuildContext context) {
+    return InkWell(
+      onHover: (value) {
+        setState(() {
+          _isHover = value;
+        });
+      },
+      onTap: () {
+        showEditingDialog();
+      },
+      child: Stack(
+        children: [
+          _buildMathEquation(context),
+          if (_isHover) _buildDeleteButton(context),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildMathEquation(BuildContext context) {
+    return Container(
+      width: MediaQuery.of(context).size.width,
+      constraints: const BoxConstraints(minHeight: 50),
+      padding: const EdgeInsets.symmetric(vertical: 20),
+      decoration: BoxDecoration(
+        borderRadius: const BorderRadius.all(Radius.circular(8.0)),
+        color: _isHover || _mathEquation.isEmpty
+            ? Colors.grey[200]
+            : Colors.transparent,
+      ),
+      child: Center(
+        child: _mathEquation.isEmpty
+            ? Text(
+                'Add a Math Equation',
+                style: widget.editorState.editorStyle.placeholderTextStyle,
+              )
+            : Math.tex(
+                _mathEquation,
+                textStyle: const TextStyle(fontSize: 20),
+                mathStyle: MathStyle.display,
+              ),
+      ),
+    );
+  }
+
+  Widget _buildDeleteButton(BuildContext context) {
+    return Positioned(
+      top: -5,
+      right: -5,
+      child: IconButton(
+        icon: Icon(
+          Icons.delete_forever_outlined,
+          color: widget.editorState.editorStyle.selectionMenuItemIconColor,
+          size: 16,
+        ),
+        onPressed: () {
+          final transaction = widget.editorState.transaction
+            ..deleteNode(widget.node);
+          widget.editorState.apply(transaction);
+        },
+      ),
+    );
+  }
+
+  void showEditingDialog() {
+    showDialog(
+      context: context,
+      builder: (context) {
+        final controller = TextEditingController(text: _mathEquation);
+        return AlertDialog(
+          title: const Text('Edit Math Equation'),
+          content: RawKeyboardListener(
+            focusNode: FocusNode(),
+            onKey: (key) {
+              if (key is! RawKeyDownEvent) return;
+              if (key.logicalKey == LogicalKeyboardKey.enter &&
+                  !key.isShiftPressed) {
+                _updateMathEquation(controller.text, context);
+              } else if (key.logicalKey == LogicalKeyboardKey.escape) {
+                _dismiss(context);
+              }
+            },
+            child: TextField(
+              autofocus: true,
+              controller: controller,
+              maxLines: null,
+              decoration: const InputDecoration(
+                border: OutlineInputBorder(),
+                hintText: 'E = MC^2',
+              ),
+            ),
+          ),
+          actions: [
+            TextButton(
+              onPressed: () => _dismiss(context),
+              child: const Text('Cancel'),
+            ),
+            TextButton(
+              onPressed: () => _updateMathEquation(controller.text, context),
+              child: const Text('Done'),
+            ),
+          ],
+        );
+      },
+    );
+  }
+
+  void _updateMathEquation(String mathEquation, BuildContext context) {
+    if (mathEquation == _mathEquation) {
+      _dismiss(context);
+      return;
+    }
+    final transaction = widget.editorState.transaction;
+    transaction.updateNode(
+      widget.node,
+      {
+        kMathEquationAttr: mathEquation,
+      },
+    );
+    widget.editorState.apply(transaction);
+    _dismiss(context);
+  }
+
+  void _dismiss(BuildContext context) {
+    Navigator.of(context).pop();
+  }
+}

+ 60 - 0
frontend/app_flowy/packages/appflowy_editor_plugins/pubspec.yaml

@@ -0,0 +1,60 @@
+name: appflowy_editor_plugins
+description: A new Flutter package project.
+version: 0.0.1
+homepage: https://github.com/AppFlowy-IO/AppFlowy
+
+publish_to: none
+
+environment:
+  sdk: ">=2.17.6 <3.0.0"
+  flutter: ">=1.17.0"
+
+dependencies:
+  flutter:
+    sdk: flutter
+  appflowy_editor: 
+    path: ../appflowy_editor
+  flutter_math_fork: ^0.6.3+1
+  highlight: ^0.7.0
+
+dev_dependencies:
+  flutter_test:
+    sdk: flutter
+  flutter_lints: ^2.0.0
+
+# For information on the generic Dart part of this file, see the
+# following page: https://dart.dev/tools/pub/pubspec
+
+# The following section is specific to Flutter packages.
+flutter:
+
+  # To add assets to your package, add an assets section, like this:
+  # assets:
+  #   - images/a_dot_burr.jpeg
+  #   - images/a_dot_ham.jpeg
+  #
+  # For details regarding assets in packages, see
+  # https://flutter.dev/assets-and-images/#from-packages
+  #
+  # An image asset can refer to one or more resolution-specific "variants", see
+  # https://flutter.dev/assets-and-images/#resolution-aware
+
+  # To add custom fonts to your package, add a fonts section here,
+  # in this "flutter" section. Each entry in this list should have a
+  # "family" key with the font family name, and a "fonts" key with a
+  # list giving the asset and other descriptors for the font. For
+  # example:
+  # fonts:
+  #   - family: Schyler
+  #     fonts:
+  #       - asset: fonts/Schyler-Regular.ttf
+  #       - asset: fonts/Schyler-Italic.ttf
+  #         style: italic
+  #   - family: Trajan Pro
+  #     fonts:
+  #       - asset: fonts/TrajanPro.ttf
+  #       - asset: fonts/TrajanPro_Bold.ttf
+  #         weight: 700
+  #
+  # For details regarding fonts in packages, see
+  # https://flutter.dev/custom-fonts/#from-packages

+ 1 - 0
frontend/app_flowy/packages/appflowy_editor_plugins/test/appflowy_editor_plugins_test.dart

@@ -0,0 +1 @@
+

+ 22 - 1
frontend/app_flowy/pubspec.lock

@@ -36,6 +36,13 @@ packages:
       relative: true
     source: path
     version: "0.0.7"
+  appflowy_editor_plugins:
+    dependency: "direct main"
+    description:
+      path: "packages/appflowy_editor_plugins"
+      relative: true
+    source: path
+    version: "0.0.1"
   appflowy_popover:
     dependency: "direct main"
     description:
@@ -471,6 +478,13 @@ packages:
     description: flutter
     source: sdk
     version: "0.0.0"
+  flutter_math_fork:
+    dependency: transitive
+    description:
+      name: flutter_math_fork
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.6.3+1"
   flutter_plugin_android_lifecycle:
     dependency: transitive
     description:
@@ -556,6 +570,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.1.0"
+  highlight:
+    dependency: transitive
+    description:
+      name: highlight
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.7.0"
   hotkey_manager:
     dependency: "direct main"
     description:
@@ -1402,5 +1423,5 @@ packages:
     source: hosted
     version: "3.1.1"
 sdks:
-  dart: ">=2.17.0 <3.0.0"
+  dart: ">=2.17.6 <3.0.0"
   flutter: ">=3.0.0"

+ 2 - 0
frontend/app_flowy/pubspec.yaml

@@ -91,6 +91,8 @@ dependencies:
   google_fonts: ^3.0.1
   file_picker: <=5.0.0
   percent_indicator: ^4.0.1
+  appflowy_editor_plugins:
+    path: packages/appflowy_editor_plugins
 
 dev_dependencies:
   flutter_lints: ^2.0.1