Browse Source

Merge pull request #1166 from LucasXu0/code_block

Implement code block feature for appflowy editor
Lucas.Xu 2 years ago
parent
commit
b578067092

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

@@ -1,6 +1,7 @@
 import 'dart:convert';
 import 'dart:io';
 
+import 'package:example/plugin/code_block_node_widget.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
@@ -116,9 +117,17 @@ class _MyHomePageState extends State<MyHomePage> {
               editorState: _editorState!,
               editorStyle: _editorStyle,
               editable: true,
+              customBuilders: {
+                'text/code_block': CodeBlockNodeWidgetBuilder(),
+              },
               shortcutEvents: [
+                enterInCodeBlock,
+                ignoreKeysInCodeBlock,
                 underscoreToItalic,
               ],
+              selectionMenuItems: [
+                codeBlockItem,
+              ],
             ),
           );
         } else {

+ 277 - 0
frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/code_block_node_widget.dart

@@ -0,0 +1,277 @@
+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) {
+    TransactionBuilder(editorState)
+      ..insertText(codeBlockNode.first, selection.end.offset, '\n')
+      ..commit();
+    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 codeBlockItem = SelectionMenuItem(
+  name: 'Code Block',
+  icon: const Icon(Icons.abc),
+  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.toRawString().isEmpty) {
+      TransactionBuilder(editorState)
+        ..updateNode(textNodes.first, {
+          'subtype': 'code_block',
+          'theme': 'vs',
+          'language': null,
+        })
+        ..afterSelection = selection
+        ..commit();
+    } else {
+      TransactionBuilder(editorState)
+        ..insertNode(
+          selection.end.path.next,
+          TextNode(
+            type: 'text',
+            children: LinkedList(),
+            attributes: {
+              'subtype': 'code_block',
+              'theme': 'vs',
+              'language': null,
+            },
+            delta: Delta()..insert('\n'),
+          ),
+        )
+        ..afterSelection = selection
+        ..commit();
+    }
+  },
+);
+
+class CodeBlockNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
+  @override
+  Widget build(NodeWidgetContext<TextNode> context) {
+    return _CodeBlockNodeWidge(
+      key: context.node.key,
+      textNode: context.node,
+      editorState: context.editorState,
+    );
+  }
+
+  @override
+  NodeValidator<Node> get nodeValidator => (node) {
+        return node is TextNode && node.attributes['theme'] is String;
+      };
+}
+
+class _CodeBlockNodeWidge extends StatefulWidget {
+  const _CodeBlockNodeWidge({
+    Key? key,
+    required this.textNode,
+    required this.editorState,
+  }) : super(key: key);
+
+  final TextNode textNode;
+  final EditorState editorState;
+
+  @override
+  State<_CodeBlockNodeWidge> createState() => __CodeBlockNodeWidgeState();
+}
+
+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?;
+  String? _detectLanguage;
+
+  @override
+  SelectableMixin<StatefulWidget> get forward =>
+      _richTextKey.currentState as SelectableMixin;
+
+  @override
+  GlobalKey<State<StatefulWidget>>? get iconKey => null;
+
+  @override
+  Offset get baseOffset => super.baseOffset + _padding.topLeft;
+
+  @override
+  Widget build(BuildContext context) {
+    return Stack(
+      children: [
+        _buildCodeBlock(context),
+        _buildSwitchCodeButton(context),
+      ],
+    );
+  }
+
+  Widget _buildCodeBlock(BuildContext context) {
+    final result = highlight.highlight.parse(
+      widget.textNode.toRawString(),
+      language: _language,
+      autoDetection: _language == null,
+    );
+    _detectLanguage = _language ?? result.language;
+    final code = result.nodes;
+    final codeTextSpan = _convert(code!);
+    return Container(
+      decoration: BoxDecoration(
+        borderRadius: const BorderRadius.all(Radius.circular(8.0)),
+        color: Colors.grey.withOpacity(0.1),
+      ),
+      padding: _padding,
+      width: MediaQuery.of(context).size.width,
+      child: FlowyRichText(
+        key: _richTextKey,
+        textNode: widget.textNode,
+        editorState: widget.editorState,
+        textSpanDecorator: (textSpan) => TextSpan(
+          style: widget.editorState.editorStyle.textStyle.defaultTextStyle,
+          children: codeTextSpan,
+        ),
+      ),
+    );
+  }
+
+  Widget _buildSwitchCodeButton(BuildContext context) {
+    return Positioned(
+      top: -5,
+      right: 0,
+      child: DropdownButton<String>(
+        value: _detectLanguage,
+        onChanged: (value) {
+          TransactionBuilder(widget.editorState)
+            ..updateNode(widget.textNode, {
+              'language': value,
+            })
+            ..commit();
+        },
+        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),
+      ),
+    );
+  }
+
+  // Copy from flutter.highlight package.
+  // https://github.com/git-touch/highlight.dart/blob/master/flutter_highlight/lib/flutter_highlight.dart
+  List<TextSpan> _convert(List<highlight.Node> nodes) {
+    List<TextSpan> spans = [];
+    var currentSpans = spans;
+    List<List<TextSpan>> stack = [];
+
+    _traverse(highlight.Node node) {
+      if (node.value != null) {
+        currentSpans.add(node.className == null
+            ? TextSpan(text: node.value)
+            : TextSpan(
+                text: node.value,
+                style: _builtInCodeBlockTheme[node.className!]));
+      } else if (node.children != null) {
+        List<TextSpan> tmp = [];
+        currentSpans.add(TextSpan(
+            children: tmp, style: _builtInCodeBlockTheme[node.className!]));
+        stack.add(currentSpans);
+        currentSpans = tmp;
+
+        for (var n in node.children!) {
+          _traverse(n);
+          if (n == node.children!.last) {
+            currentSpans = stack.isEmpty ? spans : stack.removeLast();
+          }
+        }
+      }
+    }
+
+    for (var node in nodes) {
+      _traverse(node);
+    }
+
+    return spans;
+  }
+}
+
+const _builtInCodeBlockTheme = {
+  'root':
+      TextStyle(backgroundColor: Color(0xffffffff), color: Color(0xff000000)),
+  'comment': TextStyle(color: Color(0xff007400)),
+  'quote': TextStyle(color: Color(0xff007400)),
+  'tag': TextStyle(color: Color(0xffaa0d91)),
+  'attribute': TextStyle(color: Color(0xffaa0d91)),
+  'keyword': TextStyle(color: Color(0xffaa0d91)),
+  'selector-tag': TextStyle(color: Color(0xffaa0d91)),
+  'literal': TextStyle(color: Color(0xffaa0d91)),
+  'name': TextStyle(color: Color(0xffaa0d91)),
+  'variable': TextStyle(color: Color(0xff3F6E74)),
+  'template-variable': TextStyle(color: Color(0xff3F6E74)),
+  'code': TextStyle(color: Color(0xffc41a16)),
+  'string': TextStyle(color: Color(0xffc41a16)),
+  'meta-string': TextStyle(color: Color(0xffc41a16)),
+  'regexp': TextStyle(color: Color(0xff0E0EFF)),
+  'link': TextStyle(color: Color(0xff0E0EFF)),
+  'title': TextStyle(color: Color(0xff1c00cf)),
+  'symbol': TextStyle(color: Color(0xff1c00cf)),
+  'bullet': TextStyle(color: Color(0xff1c00cf)),
+  'number': TextStyle(color: Color(0xff1c00cf)),
+  'section': TextStyle(color: Color(0xff643820)),
+  'meta': TextStyle(color: Color(0xff643820)),
+  'type': TextStyle(color: Color(0xff5c2699)),
+  'built_in': TextStyle(color: Color(0xff5c2699)),
+  'builtin-name': TextStyle(color: Color(0xff5c2699)),
+  'params': TextStyle(color: Color(0xff5c2699)),
+  'attr': TextStyle(color: Color(0xff836C28)),
+  'subst': TextStyle(color: Color(0xff000000)),
+  'formula': TextStyle(
+      backgroundColor: Color(0xffeeeeee), fontStyle: FontStyle.italic),
+  'addition': TextStyle(backgroundColor: Color(0xffbaeeba)),
+  'deletion': TextStyle(backgroundColor: Color(0xffffc8bd)),
+  'selector-id': TextStyle(color: Color(0xff9b703f)),
+  'selector-class': TextStyle(color: Color(0xff9b703f)),
+  'doctag': TextStyle(fontWeight: FontWeight.bold),
+  'strong': TextStyle(fontWeight: FontWeight.bold),
+  'emphasis': TextStyle(fontStyle: FontStyle.italic),
+};

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

@@ -43,6 +43,7 @@ dependencies:
     sdk: flutter
   file_picker: ^5.0.1
   universal_html: ^2.0.8
+  highlight: ^0.7.0
 
 dev_dependencies:
   flutter_test:

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

@@ -28,4 +28,8 @@ export 'src/service/shortcut_event/keybinding.dart';
 export 'src/service/shortcut_event/shortcut_event.dart';
 export 'src/service/shortcut_event/shortcut_event_handler.dart';
 export 'src/extensions/attributes_extension.dart';
+export 'src/extensions/path_extensions.dart';
+export 'src/render/rich_text/default_selectable.dart';
+export 'src/render/rich_text/flowy_rich_text.dart';
+export 'src/render/selection_menu/selection_menu_widget.dart';
 export 'src/l10n/l10n.dart';

+ 4 - 2
frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart

@@ -93,12 +93,14 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
   }
 
   void updateAttributes(Attributes attributes) {
-    bool shouldNotifyParent = _attributes['subtype'] != attributes['subtype'];
-
+    final oldAttributes = {..._attributes};
     _attributes = composeAttributes(_attributes, attributes) ?? {};
+
     // Notifies the new attributes
     // if attributes contains 'subtype', should notify parent to rebuild node
     // else, just notify current node.
+    bool shouldNotifyParent =
+        _attributes['subtype'] != oldAttributes['subtype'];
     shouldNotifyParent ? parent?.notifyListeners() : notifyListeners();
   }
 

+ 17 - 9
frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart

@@ -8,7 +8,6 @@ import 'package:appflowy_editor/src/service/default_text_operations/format_rich_
 
 import 'package:flutter/material.dart';
 import 'package:rich_clipboard/rich_clipboard.dart';
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
 
 typedef ToolbarItemEventHandler = void Function(
     EditorState editorState, BuildContext context);
@@ -120,7 +119,7 @@ List<ToolbarItem> defaultToolbarItems = [
       name: 'toolbar/bold',
       color: isHighlight ? Colors.lightBlue : null,
     ),
-    validator: _showInTextSelection,
+    validator: _showInBuiltInTextSelection,
     highlightCallback: (editorState) => _allSatisfy(
       editorState,
       BuiltInAttributeKey.bold,
@@ -136,7 +135,7 @@ List<ToolbarItem> defaultToolbarItems = [
       name: 'toolbar/italic',
       color: isHighlight ? Colors.lightBlue : null,
     ),
-    validator: _showInTextSelection,
+    validator: _showInBuiltInTextSelection,
     highlightCallback: (editorState) => _allSatisfy(
       editorState,
       BuiltInAttributeKey.italic,
@@ -152,7 +151,7 @@ List<ToolbarItem> defaultToolbarItems = [
       name: 'toolbar/underline',
       color: isHighlight ? Colors.lightBlue : null,
     ),
-    validator: _showInTextSelection,
+    validator: _showInBuiltInTextSelection,
     highlightCallback: (editorState) => _allSatisfy(
       editorState,
       BuiltInAttributeKey.underline,
@@ -168,7 +167,7 @@ List<ToolbarItem> defaultToolbarItems = [
       name: 'toolbar/strikethrough',
       color: isHighlight ? Colors.lightBlue : null,
     ),
-    validator: _showInTextSelection,
+    validator: _showInBuiltInTextSelection,
     highlightCallback: (editorState) => _allSatisfy(
       editorState,
       BuiltInAttributeKey.strikethrough,
@@ -184,7 +183,7 @@ List<ToolbarItem> defaultToolbarItems = [
       name: 'toolbar/code',
       color: isHighlight ? Colors.lightBlue : null,
     ),
-    validator: _showInTextSelection,
+    validator: _showInBuiltInTextSelection,
     highlightCallback: (editorState) => _allSatisfy(
       editorState,
       BuiltInAttributeKey.code,
@@ -248,7 +247,7 @@ List<ToolbarItem> defaultToolbarItems = [
       name: 'toolbar/highlight',
       color: isHighlight ? Colors.lightBlue : null,
     ),
-    validator: _showInTextSelection,
+    validator: _showInBuiltInTextSelection,
     highlightCallback: (editorState) => _allSatisfy(
       editorState,
       BuiltInAttributeKey.backgroundColor,
@@ -262,13 +261,22 @@ List<ToolbarItem> defaultToolbarItems = [
 ];
 
 ToolbarItemValidator _onlyShowInSingleTextSelection = (editorState) {
+  final result = _showInBuiltInTextSelection(editorState);
+  if (!result) {
+    return false;
+  }
   final nodes = editorState.service.selectionService.currentSelectedNodes;
   return (nodes.length == 1 && nodes.first is TextNode);
 };
 
-ToolbarItemValidator _showInTextSelection = (editorState) {
+ToolbarItemValidator _showInBuiltInTextSelection = (editorState) {
   final nodes = editorState.service.selectionService.currentSelectedNodes
-      .whereType<TextNode>();
+      .whereType<TextNode>()
+      .where(
+        (textNode) =>
+            BuiltInAttributeKey.globalStyleKeys.contains(textNode.subtype) ||
+            textNode.subtype == null,
+      );
   return nodes.isNotEmpty;
 };
 

+ 1 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart

@@ -118,8 +118,8 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
               key: editorState.service.keyboardServiceKey,
               editable: widget.editable,
               shortcutEvents: [
-                ...builtInShortcutEvents,
                 ...widget.shortcutEvents,
+                ...builtInShortcutEvents,
               ],
               editorState: editorState,
               child: FlowyToolbar(

+ 8 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart

@@ -117,12 +117,17 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
         makeFollowingNodesIncremental(editorState, insertPath, afterSelection,
             beginNum: prevNumber);
       } else {
+        bool needCopyAttributes = ![
+          BuiltInAttributeKey.heading,
+          BuiltInAttributeKey.quote,
+        ].contains(subtype);
         TransactionBuilder(editorState)
           ..insertNode(
             textNode.path,
             textNode.copyWith(
               children: LinkedList(),
               delta: Delta(),
+              attributes: needCopyAttributes ? null : {},
             ),
           )
           ..afterSelection = afterSelection
@@ -173,7 +178,9 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
 Attributes _attributesFromPreviousLine(TextNode textNode) {
   final prevAttributes = textNode.attributes;
   final subType = textNode.subtype;
-  if (subType == null || subType == BuiltInAttributeKey.heading) {
+  if (subType == null ||
+      subType == BuiltInAttributeKey.heading ||
+      subType == BuiltInAttributeKey.quote) {
     return {};
   }
 

+ 10 - 3
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart

@@ -13,12 +13,19 @@ ShortcutEventHandler tabHandler = (editorState, event) {
 
   final textNode = textNodes.first;
   final previous = textNode.previous;
-  if (textNode.subtype != BuiltInAttributeKey.bulletedList ||
-      previous == null ||
-      previous.subtype != BuiltInAttributeKey.bulletedList) {
+
+  if (textNode.subtype != BuiltInAttributeKey.bulletedList) {
+    TransactionBuilder(editorState)
+      ..insertText(textNode, selection.end.offset, ' ' * 4)
+      ..commit();
     return KeyEventResult.handled;
   }
 
+  if (previous == null ||
+      previous.subtype != BuiltInAttributeKey.bulletedList) {
+    return KeyEventResult.ignored;
+  }
+
   final path = previous.path + [previous.children.length];
   final afterSelection = Selection(
     start: selection.start.copyWith(path: path),

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

@@ -124,6 +124,8 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
         final result = shortcutEvent.handler(widget.editorState, event);
         if (result == KeyEventResult.handled) {
           return KeyEventResult.handled;
+        } else if (result == KeyEventResult.skipRemainingHandlers) {
+          return KeyEventResult.skipRemainingHandlers;
         }
         continue;
       }

+ 5 - 7
frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart

@@ -38,14 +38,17 @@ class _FlowyToolbarState extends State<FlowyToolbar>
   @override
   void showInOffset(Offset offset, LayerLink layerLink) {
     hide();
-
+    final items = _filterItems(defaultToolbarItems);
+    if (items.isEmpty) {
+      return;
+    }
     _toolbarOverlay = OverlayEntry(
       builder: (context) => ToolbarWidget(
         key: _toolbarWidgetKey,
         editorState: widget.editorState,
         layerLink: layerLink,
         offset: offset,
-        items: _filterItems(defaultToolbarItems),
+        items: items,
       ),
     );
     Overlay.of(context)?.insert(_toolbarOverlay!);
@@ -102,9 +105,4 @@ class _FlowyToolbarState extends State<FlowyToolbar>
     }
     return dividedItems;
   }
-
-  // List<ToolbarItem> _highlightItems(
-  //   List<ToolbarItem> items,
-  //   Selection selection,
-  // ) {}
 }

+ 20 - 7
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart

@@ -2,7 +2,6 @@ import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.dart';
 import '../../infra/test_editor.dart';
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
 
 void main() async {
   setUpAll(() {
@@ -171,13 +170,27 @@ Future<void> _testStyleNeedToBeCopy(WidgetTester tester, String style) async {
     LogicalKeyboardKey.enter,
   );
   expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0));
-  expect(editor.nodeAtPath([4])?.subtype, style);
 
-  await editor.pressLogicKey(
-    LogicalKeyboardKey.enter,
-  );
-  expect(editor.documentSelection, Selection.single(path: [4], startOffset: 0));
-  expect(editor.nodeAtPath([4])?.subtype, null);
+  if ([BuiltInAttributeKey.heading, BuiltInAttributeKey.quote]
+      .contains(style)) {
+    expect(editor.nodeAtPath([4])?.subtype, null);
+
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.enter,
+    );
+    expect(
+        editor.documentSelection, Selection.single(path: [5], startOffset: 0));
+    expect(editor.nodeAtPath([5])?.subtype, null);
+  } else {
+    expect(editor.nodeAtPath([4])?.subtype, style);
+
+    await editor.pressLogicKey(
+      LogicalKeyboardKey.enter,
+    );
+    expect(
+        editor.documentSelection, Selection.single(path: [4], startOffset: 0));
+    expect(editor.nodeAtPath([4])?.subtype, null);
+  }
 }
 
 Future<void> _testMultipleSelection(

+ 12 - 8
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart

@@ -15,23 +15,24 @@ void main() async {
         ..insertTextNode(text)
         ..insertTextNode(text);
       await editor.startTesting();
-      final document = editor.document;
 
       var selection = Selection.single(path: [0], startOffset: 0);
       await editor.updateSelection(selection);
       await editor.pressLogicKey(LogicalKeyboardKey.tab);
 
-      // nothing happens
-      expect(editor.documentSelection, selection);
-      expect(editor.document.toJson(), document.toJson());
+      expect(
+        editor.documentSelection,
+        Selection.single(path: [0], startOffset: 4),
+      );
 
       selection = Selection.single(path: [1], startOffset: 0);
       await editor.updateSelection(selection);
       await editor.pressLogicKey(LogicalKeyboardKey.tab);
 
-      // nothing happens
-      expect(editor.documentSelection, selection);
-      expect(editor.document.toJson(), document.toJson());
+      expect(
+        editor.documentSelection,
+        Selection.single(path: [1], startOffset: 4),
+      );
     });
 
     testWidgets('press tab in bulleted list', (tester) async {
@@ -63,7 +64,10 @@ void main() async {
       await editor.pressLogicKey(LogicalKeyboardKey.tab);
 
       // nothing happens
-      expect(editor.documentSelection, selection);
+      expect(
+        editor.documentSelection,
+        Selection.single(path: [0], startOffset: 0),
+      );
       expect(editor.document.toJson(), document.toJson());
 
       // Before