瀏覽代碼

feat: implement simple code block

Lucas.Xu 2 年之前
父節點
當前提交
ff9cf905fa

+ 5 - 0
frontend/app_flowy/packages/appflowy_editor/example/assets/example.json

@@ -25,6 +25,11 @@
           }
         ]
       },
+      {
+        "type": "text",
+        "attributes": { "subtype": "code_block", "theme": "vs", "language": "dart" },
+        "delta": [{ "insert": "final x = 0;\nprint($x);" }]
+      },
       { "type": "text", "delta": [] },
       {
         "type": "text",

+ 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 {

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

@@ -0,0 +1,275 @@
+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?;
+
+  @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,
+    );
+    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: _language,
+        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),
+};

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

@@ -43,6 +43,8 @@ dependencies:
     sdk: flutter
   file_picker: ^5.0.1
   universal_html: ^2.0.8
+  highlight: ^0.7.0
+  flutter_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';

+ 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(