Bladeren bron

Merge pull request #1424 from LucasXu0/markdown

Implement appflowy editor document to markdown
Lucas.Xu 2 jaren geleden
bovenliggende
commit
cdf6f1b38a
19 gewijzigde bestanden met toevoegingen van 871 en 39 verwijderingen
  1. 2 2
      frontend/app_flowy/lib/plugins/doc/application/share_bloc.dart
  2. 7 0
      frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart
  3. 12 5
      frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/text_delta.dart
  4. 66 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/decoder/delta_markdown_decoder.dart
  5. 91 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/decoder/document_markdown_decoder.dart
  6. 5 6
      frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/document_markdown.dart
  7. 88 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/delta_markdown_encoder.dart
  8. 6 6
      frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/document_markdown_encoder.dart
  9. 2 2
      frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/parser/image_node_parser.dart
  10. 1 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/parser/node_parser.dart
  11. 13 17
      frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/parser/text_node_parser.dart
  12. 1 0
      frontend/app_flowy/packages/appflowy_editor/pubspec.yaml
  13. 96 0
      frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/decoder/delta_markdown_decoder_test.dart
  14. 126 0
      frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/decoder/document_markdown_decoder_test.dart
  15. 100 0
      frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/encoder/delta_markdown_encoder_test.dart
  16. 136 0
      frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/encoder/document_markdown_encoder_test.dart
  17. 17 0
      frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/encoder/parser/image_node_parser_test.dart
  18. 95 0
      frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/encoder/parser/text_node_parser_test.dart
  19. 7 0
      frontend/app_flowy/pubspec.lock

+ 2 - 2
frontend/app_flowy/lib/plugins/doc/application/share_bloc.dart

@@ -1,14 +1,14 @@
 import 'dart:convert';
 import 'dart:io';
 import 'package:app_flowy/plugins/doc/application/share_service.dart';
-import 'package:app_flowy/workspace/application/markdown/document_markdown.dart';
 import 'package:flowy_sdk/protobuf/flowy-document/entities.pb.dart';
 import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
 import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
 import 'package:freezed_annotation/freezed_annotation.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 import 'package:dartz/dartz.dart';
-import 'package:appflowy_editor/appflowy_editor.dart' show Document;
+import 'package:appflowy_editor/appflowy_editor.dart'
+    show Document, documentToMarkdown;
 part 'share_bloc.freezed.dart';
 
 class DocShareBloc extends Bloc<DocShareEvent, DocShareState> {

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

@@ -33,3 +33,10 @@ export 'src/render/selection_menu/selection_menu_widget.dart';
 export 'src/l10n/l10n.dart';
 export 'src/render/style/plugin_styles.dart';
 export 'src/render/style/editor_style.dart';
+export 'src/plugins/markdown/encoder/delta_markdown_encoder.dart';
+export 'src/plugins/markdown/encoder/document_markdown_encoder.dart';
+export 'src/plugins/markdown/encoder/parser/node_parser.dart';
+export 'src/plugins/markdown/encoder/parser/text_node_parser.dart';
+export 'src/plugins/markdown/encoder/parser/image_node_parser.dart';
+export 'src/plugins/markdown/decoder/delta_markdown_decoder.dart';
+export 'src/plugins/markdown/document_markdown.dart';

+ 12 - 5
frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/text_delta.dart

@@ -50,7 +50,7 @@ class TextInsert extends TextOperation {
     final result = <String, dynamic>{
       'insert': text,
     };
-    if (_attributes != null) {
+    if (_attributes != null && _attributes!.isNotEmpty) {
       result['attributes'] = attributes;
     }
     return result;
@@ -62,7 +62,7 @@ class TextInsert extends TextOperation {
 
     return other is TextInsert &&
         other.text == text &&
-        mapEquals(_attributes, other._attributes);
+        _mapEquals(_attributes, other._attributes);
   }
 
   @override
@@ -87,7 +87,7 @@ class TextRetain extends TextOperation {
     final result = <String, dynamic>{
       'retain': length,
     };
-    if (_attributes != null) {
+    if (_attributes != null && _attributes!.isNotEmpty) {
       result['attributes'] = attributes;
     }
     return result;
@@ -99,7 +99,7 @@ class TextRetain extends TextOperation {
 
     return other is TextRetain &&
         other.length == length &&
-        mapEquals(_attributes, other._attributes);
+        _mapEquals(_attributes, other._attributes);
   }
 
   @override
@@ -181,7 +181,7 @@ class Delta extends Iterable<TextOperation> {
         lastOp.length += textOperation.length;
         return;
       }
-      if (mapEquals(lastOp.attributes, textOperation.attributes)) {
+      if (_mapEquals(lastOp.attributes, textOperation.attributes)) {
         if (lastOp is TextInsert && textOperation is TextInsert) {
           lastOp.text += textOperation.text;
           return;
@@ -539,3 +539,10 @@ class _OpIterator {
     }
   }
 }
+
+bool _mapEquals<T, U>(Map<T, U>? a, Map<T, U>? b) {
+  if ((a == null || a.isEmpty) && (b == null || b.isEmpty)) {
+    return true;
+  }
+  return mapEquals(a, b);
+}

+ 66 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/decoder/delta_markdown_decoder.dart

@@ -0,0 +1,66 @@
+import 'dart:convert';
+
+import 'package:appflowy_editor/src/core/document/attributes.dart';
+import 'package:appflowy_editor/src/core/document/text_delta.dart';
+import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
+import 'package:markdown/markdown.dart' as md;
+
+class DeltaMarkdownDecoder extends Converter<String, Delta>
+    with md.NodeVisitor {
+  final _delta = Delta();
+  final Attributes _attributes = {};
+
+  @override
+  Delta convert(String input) {
+    final document =
+        md.Document(extensionSet: md.ExtensionSet.gitHubWeb).parseInline(input);
+    for (final node in document) {
+      node.accept(this);
+    }
+    return _delta;
+  }
+
+  @override
+  void visitElementAfter(md.Element element) {
+    _removeAttributeKey(element);
+  }
+
+  @override
+  bool visitElementBefore(md.Element element) {
+    _addAttributeKey(element);
+    return true;
+  }
+
+  @override
+  void visitText(md.Text text) {
+    _delta.add(TextInsert(text.text, attributes: {..._attributes}));
+  }
+
+  void _addAttributeKey(md.Element element) {
+    if (element.tag == 'strong') {
+      _attributes[BuiltInAttributeKey.bold] = true;
+    } else if (element.tag == 'em') {
+      _attributes[BuiltInAttributeKey.italic] = true;
+    } else if (element.tag == 'code') {
+      _attributes[BuiltInAttributeKey.code] = true;
+    } else if (element.tag == 'del') {
+      _attributes[BuiltInAttributeKey.strikethrough] = true;
+    } else if (element.tag == 'a') {
+      _attributes[BuiltInAttributeKey.href] = element.attributes['href'];
+    }
+  }
+
+  void _removeAttributeKey(md.Element element) {
+    if (element.tag == 'strong') {
+      _attributes.remove(BuiltInAttributeKey.bold);
+    } else if (element.tag == 'em') {
+      _attributes.remove(BuiltInAttributeKey.italic);
+    } else if (element.tag == 'code') {
+      _attributes.remove(BuiltInAttributeKey.code);
+    } else if (element.tag == 'del') {
+      _attributes.remove(BuiltInAttributeKey.strikethrough);
+    } else if (element.tag == 'a') {
+      _attributes.remove(BuiltInAttributeKey.href);
+    }
+  }
+}

+ 91 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/decoder/document_markdown_decoder.dart

@@ -0,0 +1,91 @@
+import 'dart:convert';
+
+import 'package:appflowy_editor/appflowy_editor.dart';
+
+class DocumentMarkdownDecoder extends Converter<String, Document> {
+  @override
+  Document convert(String input) {
+    final lines = input.split('\n');
+    final document = Document.empty();
+
+    var i = 0;
+    for (final line in lines) {
+      document.insert([i++], [_convertLineToNode(line)]);
+    }
+
+    return document;
+  }
+
+  Node _convertLineToNode(String text) {
+    final decoder = DeltaMarkdownDecoder();
+    // Heading Style
+    if (text.startsWith('### ')) {
+      return TextNode(
+        delta: decoder.convert(text.substring(4)),
+        attributes: {
+          BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
+          BuiltInAttributeKey.heading: BuiltInAttributeKey.h3,
+        },
+      );
+    } else if (text.startsWith('## ')) {
+      return TextNode(
+        delta: decoder.convert(text.substring(3)),
+        attributes: {
+          BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
+          BuiltInAttributeKey.heading: BuiltInAttributeKey.h2,
+        },
+      );
+    } else if (text.startsWith('# ')) {
+      return TextNode(
+        delta: decoder.convert(text.substring(2)),
+        attributes: {
+          BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
+          BuiltInAttributeKey.heading: BuiltInAttributeKey.h1,
+        },
+      );
+    } else if (text.startsWith('- [ ] ')) {
+      return TextNode(
+        delta: decoder.convert(text.substring(6)),
+        attributes: {
+          BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox,
+          BuiltInAttributeKey.checkbox: false,
+        },
+      );
+    } else if (text.startsWith('- [x] ')) {
+      return TextNode(
+        delta: decoder.convert(text.substring(6)),
+        attributes: {
+          BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox,
+          BuiltInAttributeKey.checkbox: true,
+        },
+      );
+    } else if (text.startsWith('> ')) {
+      return TextNode(
+        delta: decoder.convert(text.substring(2)),
+        attributes: {
+          BuiltInAttributeKey.subtype: BuiltInAttributeKey.quote,
+        },
+      );
+    } else if (text.startsWith('- ') || text.startsWith('* ')) {
+      return TextNode(
+        delta: decoder.convert(text.substring(2)),
+        attributes: {
+          BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
+        },
+      );
+    } else if (text.startsWith('> ')) {
+      return TextNode(
+        delta: decoder.convert(text.substring(2)),
+        attributes: {
+          BuiltInAttributeKey.subtype: BuiltInAttributeKey.quote,
+        },
+      );
+    }
+
+    if (text.isNotEmpty) {
+      return TextNode(delta: decoder.convert(text));
+    }
+
+    return TextNode(delta: Delta());
+  }
+}

+ 5 - 6
frontend/app_flowy/lib/workspace/application/markdown/document_markdown.dart → frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/document_markdown.dart

@@ -2,8 +2,9 @@ library delta_markdown;
 
 import 'dart:convert';
 
-import 'package:appflowy_editor/appflowy_editor.dart' show Document;
-import 'package:app_flowy/workspace/application/markdown/src/parser/markdown_encoder.dart';
+import 'package:appflowy_editor/src/core/document/document.dart';
+import 'package:appflowy_editor/src/plugins/markdown/decoder/document_markdown_decoder.dart';
+import 'package:appflowy_editor/src/plugins/markdown/encoder/document_markdown_encoder.dart';
 
 /// Codec used to convert between Markdown and AppFlowy Editor Document.
 const AppFlowyEditorMarkdownCodec _kCodec = AppFlowyEditorMarkdownCodec();
@@ -20,10 +21,8 @@ class AppFlowyEditorMarkdownCodec extends Codec<Document, String> {
   const AppFlowyEditorMarkdownCodec();
 
   @override
-  Converter<String, Document> get decoder => throw UnimplementedError();
+  Converter<String, Document> get decoder => DocumentMarkdownDecoder();
 
   @override
-  Converter<Document, String> get encoder {
-    return AppFlowyEditorMarkdownEncoder();
-  }
+  Converter<Document, String> get encoder => DocumentMarkdownEncoder();
 }

+ 88 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/delta_markdown_encoder.dart

@@ -0,0 +1,88 @@
+import 'dart:convert';
+
+import 'package:appflowy_editor/appflowy_editor.dart';
+
+/// A [Delta] encoder that encodes a [Delta] to Markdown.
+///
+/// Only support inline styles, like bold, italic, underline, strike, code.
+class DeltaMarkdownEncoder extends Converter<Delta, String> {
+  @override
+  String convert(Delta input) {
+    final buffer = StringBuffer();
+    final iterator = input.iterator;
+    while (iterator.moveNext()) {
+      final op = iterator.current;
+      if (op is TextInsert) {
+        final attributes = op.attributes;
+        if (attributes != null) {
+          buffer.write(_prefixSyntax(attributes));
+          buffer.write(op.text);
+          buffer.write(_suffixSyntax(attributes));
+        } else {
+          buffer.write(op.text);
+        }
+      }
+    }
+    return buffer.toString();
+  }
+
+  String _prefixSyntax(Attributes attributes) {
+    var syntax = '';
+
+    if (attributes[BuiltInAttributeKey.bold] == true &&
+        attributes[BuiltInAttributeKey.italic] == true) {
+      syntax += '***';
+    } else if (attributes[BuiltInAttributeKey.bold] == true) {
+      syntax += '**';
+    } else if (attributes[BuiltInAttributeKey.italic] == true) {
+      syntax += '_';
+    }
+
+    if (attributes[BuiltInAttributeKey.strikethrough] == true) {
+      syntax += '~~';
+    }
+    if (attributes[BuiltInAttributeKey.underline] == true) {
+      syntax += '<u>';
+    }
+    if (attributes[BuiltInAttributeKey.code] == true) {
+      syntax += '`';
+    }
+
+    if (attributes[BuiltInAttributeKey.href] != null) {
+      syntax += '[';
+    }
+
+    return syntax;
+  }
+
+  String _suffixSyntax(Attributes attributes) {
+    var syntax = '';
+
+    if (attributes[BuiltInAttributeKey.href] != null) {
+      syntax += '](${attributes[BuiltInAttributeKey.href]})';
+    }
+
+    if (attributes[BuiltInAttributeKey.code] == true) {
+      syntax += '`';
+    }
+
+    if (attributes[BuiltInAttributeKey.underline] == true) {
+      syntax += '</u>';
+    }
+
+    if (attributes[BuiltInAttributeKey.strikethrough] == true) {
+      syntax += '~~';
+    }
+
+    if (attributes[BuiltInAttributeKey.bold] == true &&
+        attributes[BuiltInAttributeKey.italic] == true) {
+      syntax += '***';
+    } else if (attributes[BuiltInAttributeKey.bold] == true) {
+      syntax += '**';
+    } else if (attributes[BuiltInAttributeKey.italic] == true) {
+      syntax += '_';
+    }
+
+    return syntax;
+  }
+}

+ 6 - 6
frontend/app_flowy/lib/workspace/application/markdown/src/parser/markdown_encoder.dart → frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/document_markdown_encoder.dart

@@ -1,12 +1,12 @@
 import 'dart:convert';
 
-import 'package:app_flowy/workspace/application/markdown/src/parser/image_node_parser.dart';
-import 'package:app_flowy/workspace/application/markdown/src/parser/node_parser.dart';
-import 'package:app_flowy/workspace/application/markdown/src/parser/text_node_parser.dart';
-import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/core/document/document.dart';
+import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/image_node_parser.dart';
+import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/node_parser.dart';
+import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/text_node_parser.dart';
 
-class AppFlowyEditorMarkdownEncoder extends Converter<Document, String> {
-  AppFlowyEditorMarkdownEncoder({
+class DocumentMarkdownEncoder extends Converter<Document, String> {
+  DocumentMarkdownEncoder({
     this.parsers = const [
       TextNodeParser(),
       ImageNodeParser(),

+ 2 - 2
frontend/app_flowy/lib/workspace/application/markdown/src/parser/image_node_parser.dart → frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/parser/image_node_parser.dart

@@ -1,5 +1,5 @@
-import 'package:app_flowy/workspace/application/markdown/src/parser/node_parser.dart';
-import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/node_parser.dart';
 
 class ImageNodeParser extends NodeParser {
   const ImageNodeParser();

+ 1 - 1
frontend/app_flowy/lib/workspace/application/markdown/src/parser/node_parser.dart → frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/parser/node_parser.dart

@@ -1,4 +1,4 @@
-import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
 
 abstract class NodeParser {
   const NodeParser();

+ 13 - 17
frontend/app_flowy/lib/workspace/application/markdown/src/parser/text_node_parser.dart → frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/parser/text_node_parser.dart

@@ -1,8 +1,7 @@
-import 'dart:convert';
-
-import 'package:app_flowy/workspace/application/markdown/delta_markdown.dart';
-import 'package:app_flowy/workspace/application/markdown/src/parser/node_parser.dart';
-import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/core/document/node.dart';
+import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
+import 'package:appflowy_editor/src/plugins/markdown/encoder/delta_markdown_encoder.dart';
+import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/node_parser.dart';
 
 class TextNodeParser extends NodeParser {
   const TextNodeParser();
@@ -14,20 +13,15 @@ class TextNodeParser extends NodeParser {
   String transform(Node node) {
     assert(node is TextNode);
     final textNode = node as TextNode;
-    final delta = jsonEncode(
-      textNode.delta
-        ..add(TextInsert('\n'))
-        ..toJson(),
-    );
-    final markdown = deltaToMarkdown(delta);
+    final markdown = DeltaMarkdownEncoder().convert(textNode.delta);
     final attributes = textNode.attributes;
     var result = markdown;
-    var suffix = '';
+    var suffix = '\n';
     if (attributes.isNotEmpty &&
         attributes.containsKey(BuiltInAttributeKey.subtype)) {
       final subtype = attributes[BuiltInAttributeKey.subtype];
-      if (node.next?.subtype != subtype) {
-        suffix = '\n';
+      if (node.next == null) {
+        suffix = '';
       }
       if (subtype == 'heading') {
         final heading = attributes[BuiltInAttributeKey.heading];
@@ -46,12 +40,10 @@ class TextNodeParser extends NodeParser {
         }
       } else if (subtype == 'quote') {
         result = '> $markdown';
-      } else if (subtype == 'code') {
-        result = '`$markdown`';
       } else if (subtype == 'code-block') {
         result = '```\n$markdown\n```';
       } else if (subtype == 'bulleted-list') {
-        result = '- $markdown';
+        result = '* $markdown';
       } else if (subtype == 'number-list') {
         final number = attributes['number'];
         result = '$number. $markdown';
@@ -62,6 +54,10 @@ class TextNodeParser extends NodeParser {
           result = '- [ ] $markdown';
         }
       }
+    } else {
+      if (node.next == null) {
+        suffix = '';
+      }
     }
     return '$result$suffix';
   }

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

@@ -27,6 +27,7 @@ dependencies:
   intl:
   flutter_localizations:
     sdk: flutter
+  markdown: ^6.0.1
 
 dev_dependencies:
   flutter_test:

+ 96 - 0
frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/decoder/delta_markdown_decoder_test.dart

@@ -0,0 +1,96 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  group('delta_markdown_decoder.dart', () {
+    test('bold', () {
+      final delta = Delta(operations: [
+        TextInsert('Welcome to '),
+        TextInsert('AppFlowy', attributes: {
+          BuiltInAttributeKey.bold: true,
+        }),
+      ]);
+      final result = DeltaMarkdownDecoder().convert('Welcome to **AppFlowy**');
+      expect(result, delta);
+    });
+
+    test('italic', () {
+      final delta = Delta(operations: [
+        TextInsert('Welcome to '),
+        TextInsert('AppFlowy', attributes: {
+          BuiltInAttributeKey.italic: true,
+        }),
+      ]);
+      final result = DeltaMarkdownDecoder().convert('Welcome to _AppFlowy_');
+      expect(result, delta);
+    });
+
+    test('strikethrough', () {
+      final delta = Delta(operations: [
+        TextInsert('Welcome to '),
+        TextInsert('AppFlowy', attributes: {
+          BuiltInAttributeKey.strikethrough: true,
+        }),
+      ]);
+      final result = DeltaMarkdownDecoder().convert('Welcome to ~~AppFlowy~~');
+      expect(result, delta);
+    });
+
+    test('href', () {
+      final delta = Delta(operations: [
+        TextInsert('Welcome to '),
+        TextInsert('AppFlowy', attributes: {
+          BuiltInAttributeKey.href: 'https://appflowy.io',
+        }),
+      ]);
+      final result = DeltaMarkdownDecoder()
+          .convert('Welcome to [AppFlowy](https://appflowy.io)');
+      expect(result, delta);
+    });
+
+    test('code', () {
+      final delta = Delta(operations: [
+        TextInsert('Welcome to '),
+        TextInsert('AppFlowy', attributes: {
+          BuiltInAttributeKey.code: true,
+        }),
+      ]);
+      final result = DeltaMarkdownDecoder().convert('Welcome to `AppFlowy`');
+      expect(result, delta);
+    });
+
+    test('bold', () {
+      const markdown =
+          '***<u>`Welcome`</u>*** ***~~to~~*** ***[AppFlowy](https://appflowy.io)***';
+      final delta = Delta(operations: [
+        TextInsert('<u>', attributes: {
+          BuiltInAttributeKey.italic: true,
+          BuiltInAttributeKey.bold: true,
+        }),
+        TextInsert('Welcome', attributes: {
+          BuiltInAttributeKey.code: true,
+          BuiltInAttributeKey.italic: true,
+          BuiltInAttributeKey.bold: true,
+        }),
+        TextInsert('</u>', attributes: {
+          BuiltInAttributeKey.italic: true,
+          BuiltInAttributeKey.bold: true,
+        }),
+        TextInsert(' '),
+        TextInsert('to', attributes: {
+          BuiltInAttributeKey.italic: true,
+          BuiltInAttributeKey.bold: true,
+          BuiltInAttributeKey.strikethrough: true,
+        }),
+        TextInsert(' '),
+        TextInsert('AppFlowy', attributes: {
+          BuiltInAttributeKey.href: 'https://appflowy.io',
+          BuiltInAttributeKey.bold: true,
+          BuiltInAttributeKey.italic: true,
+        }),
+      ]);
+      final result = DeltaMarkdownDecoder().convert(markdown);
+      expect(result, delta);
+    });
+  });
+}

+ 126 - 0
frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/decoder/document_markdown_decoder_test.dart

@@ -0,0 +1,126 @@
+import 'dart:convert';
+
+import 'package:appflowy_editor/src/plugins/markdown/decoder/document_markdown_decoder.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  group('document_markdown_decoder.dart', () {
+    const example = '''
+{
+  "document": {
+              "type": "editor",
+              "children": [
+                {
+                  "type": "text",
+                  "attributes": {"subtype": "heading", "heading": "h2"},
+                  "delta": [
+                    {"insert": "👋 "},
+                    {"insert": "Welcome to", "attributes": {"bold": true}},
+                    {"insert": " "},
+                    {
+                      "insert": "AppFlowy Editor",
+                      "attributes": {"italic": true, "bold": true, "href": "appflowy.io"}
+                    }
+                  ]
+                },
+                {"type": "text", "delta": []},
+                {
+                  "type": "text",
+                  "delta": [
+                    {"insert": "AppFlowy Editor is a "},
+                    {"insert": "highly customizable", "attributes": {"bold": true}},
+                    {"insert": " "},
+                    {"insert": "rich-text editor", "attributes": {"italic": true}}
+                  ]
+                },
+                {
+                  "type": "text",
+                  "attributes": {"subtype": "checkbox", "checkbox": true},
+                  "delta": [{"insert": "Customizable"}]
+                },
+                {
+                  "type": "text",
+                  "attributes": {"subtype": "checkbox", "checkbox": true},
+                  "delta": [{"insert": "Test-covered"}]
+                },
+                {
+                  "type": "text",
+                  "attributes": {"subtype": "checkbox", "checkbox": false},
+                  "delta": [{"insert": "more to come!"}]
+                },
+                {"type": "text", "delta": []},
+                {
+                  "type": "text",
+                  "attributes": {"subtype": "quote"},
+                  "delta": [{"insert": "Here is an example you can give a try"}]
+                },
+                {"type": "text", "delta": []},
+                {
+                  "type": "text",
+                  "delta": [
+                    {"insert": "You can also use "},
+                    {
+                      "insert": "AppFlowy Editor",
+                      "attributes": {"italic": true, "bold": true}
+                    },
+                    {"insert": " as a component to build your own app."}
+                  ]
+                },
+                {"type": "text", "delta": []},
+                {
+                  "type": "text",
+                  "attributes": {"subtype": "bulleted-list"},
+                  "delta": [{"insert": "Use / to insert blocks"}]
+                },
+                {
+                  "type": "text",
+                  "attributes": {"subtype": "bulleted-list"},
+                  "delta": [
+                    {
+                      "insert": "Select text to trigger to the toolbar to format your notes."
+                    }
+                  ]
+                },
+                {"type": "text", "delta": []},
+                {
+                  "type": "text",
+                  "delta": [
+                    {
+                      "insert": "If you have questions or feedback, please submit an issue on Github or join the community along with 1000+ builders!"
+                    }
+                  ]
+                },
+                {"type": "text", "delta": []},
+                {"type": "text", "delta": [{"insert": ""}]}
+              ]
+            }
+}
+''';
+    setUpAll(() {
+      TestWidgetsFlutterBinding.ensureInitialized();
+    });
+
+    test('parser document', () async {
+      const markdown = '''
+## 👋 **Welcome to** ***[AppFlowy Editor](appflowy.io)***
+
+AppFlowy Editor is a **highly customizable** _rich-text editor_
+- [x] Customizable
+- [x] Test-covered
+- [ ] more to come!
+
+> Here is an example you can give a try
+
+You can also use ***AppFlowy Editor*** as a component to build your own app.
+
+* Use / to insert blocks
+* Select text to trigger to the toolbar to format your notes.
+
+If you have questions or feedback, please submit an issue on Github or join the community along with 1000+ builders!
+''';
+      final result = DocumentMarkdownDecoder().convert(markdown);
+      final data = Map<String, Object>.from(json.decode(example));
+      expect(result.toJson(), data);
+    });
+  });
+}

+ 100 - 0
frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/encoder/delta_markdown_encoder_test.dart

@@ -0,0 +1,100 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  group('delta_markdown_encoder.dart', () {
+    test('bold', () {
+      final delta = Delta(operations: [
+        TextInsert('Welcome to '),
+        TextInsert('AppFlowy', attributes: {
+          BuiltInAttributeKey.bold: true,
+        }),
+      ]);
+      final result = DeltaMarkdownEncoder().convert(delta);
+      expect(result, 'Welcome to **AppFlowy**');
+    });
+
+    test('italic', () {
+      final delta = Delta(operations: [
+        TextInsert('Welcome to '),
+        TextInsert('AppFlowy', attributes: {
+          BuiltInAttributeKey.italic: true,
+        }),
+      ]);
+      final result = DeltaMarkdownEncoder().convert(delta);
+      expect(result, 'Welcome to _AppFlowy_');
+    });
+
+    test('underline', () {
+      final delta = Delta(operations: [
+        TextInsert('Welcome to '),
+        TextInsert('AppFlowy', attributes: {
+          BuiltInAttributeKey.underline: true,
+        }),
+      ]);
+      final result = DeltaMarkdownEncoder().convert(delta);
+      expect(result, 'Welcome to <u>AppFlowy</u>');
+    });
+
+    test('strikethrough', () {
+      final delta = Delta(operations: [
+        TextInsert('Welcome to '),
+        TextInsert('AppFlowy', attributes: {
+          BuiltInAttributeKey.strikethrough: true,
+        }),
+      ]);
+      final result = DeltaMarkdownEncoder().convert(delta);
+      expect(result, 'Welcome to ~~AppFlowy~~');
+    });
+
+    test('href', () {
+      final delta = Delta(operations: [
+        TextInsert('Welcome to '),
+        TextInsert('AppFlowy', attributes: {
+          BuiltInAttributeKey.href: 'https://appflowy.io',
+        }),
+      ]);
+      final result = DeltaMarkdownEncoder().convert(delta);
+      expect(result, 'Welcome to [AppFlowy](https://appflowy.io)');
+    });
+
+    test('code', () {
+      final delta = Delta(operations: [
+        TextInsert('Welcome to '),
+        TextInsert('AppFlowy', attributes: {
+          BuiltInAttributeKey.code: true,
+        }),
+      ]);
+      final result = DeltaMarkdownEncoder().convert(delta);
+      expect(result, 'Welcome to `AppFlowy`');
+    });
+
+    test('composition', () {
+      final delta = Delta(operations: [
+        TextInsert('Welcome', attributes: {
+          BuiltInAttributeKey.code: true,
+          BuiltInAttributeKey.italic: true,
+          BuiltInAttributeKey.bold: true,
+          BuiltInAttributeKey.underline: true,
+        }),
+        TextInsert(' '),
+        TextInsert('to', attributes: {
+          BuiltInAttributeKey.italic: true,
+          BuiltInAttributeKey.bold: true,
+          BuiltInAttributeKey.strikethrough: true,
+        }),
+        TextInsert(' '),
+        TextInsert('AppFlowy', attributes: {
+          BuiltInAttributeKey.href: 'https://appflowy.io',
+          BuiltInAttributeKey.bold: true,
+          BuiltInAttributeKey.italic: true,
+        }),
+      ]);
+      final result = DeltaMarkdownEncoder().convert(delta);
+      expect(
+        result,
+        '***<u>`Welcome`</u>*** ***~~to~~*** ***[AppFlowy](https://appflowy.io)***',
+      );
+    });
+  });
+}

+ 136 - 0
frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/encoder/document_markdown_encoder_test.dart

@@ -0,0 +1,136 @@
+import 'dart:convert';
+
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  group('document_markdown_encoder.dart', () {
+    const example = '''
+{
+  "document": {
+    "type": "editor",
+    "children": [
+      {
+        "type": "text",
+        "attributes": {
+          "subtype": "heading",
+          "heading": "h2"
+        },
+        "delta": [
+          { "insert": "👋 " },
+          { "insert": "Welcome to", "attributes": { "bold": true } },
+          { "insert": " " },
+          {
+            "insert": "AppFlowy Editor",
+            "attributes": {
+              "href": "appflowy.io",
+              "italic": true,
+              "bold": true
+            }
+          }
+        ]
+      },
+      { "type": "text", "delta": [] },
+      {
+        "type": "text",
+        "delta": [
+          { "insert": "AppFlowy Editor is a " },
+          { "insert": "highly customizable", "attributes": { "bold": true } },
+          { "insert": " " },
+          { "insert": "rich-text editor", "attributes": { "italic": true } },
+          { "insert": " for " },
+          { "insert": "Flutter", "attributes": { "underline": true } }
+        ]
+      },
+      {
+        "type": "text",
+        "attributes": { "checkbox": true, "subtype": "checkbox" },
+        "delta": [{ "insert": "Customizable" }]
+      },
+      {
+        "type": "text",
+        "attributes": { "checkbox": true, "subtype": "checkbox" },
+        "delta": [{ "insert": "Test-covered" }]
+      },
+      {
+        "type": "text",
+        "attributes": { "checkbox": false, "subtype": "checkbox" },
+        "delta": [{ "insert": "more to come!" }]
+      },
+      { "type": "text", "delta": [] },
+      {
+        "type": "text",
+        "attributes": { "subtype": "quote" },
+        "delta": [{ "insert": "Here is an example you can give a try" }]
+      },
+      { "type": "text", "delta": [] },
+      {
+        "type": "text",
+        "delta": [
+          { "insert": "You can also use " },
+          {
+            "insert": "AppFlowy Editor",
+            "attributes": {
+              "italic": true,
+              "bold": true,
+              "backgroundColor": "0x6000BCF0"
+            }
+          },
+          { "insert": " as a component to build your own app." }
+        ]
+      },
+      { "type": "text", "delta": [] },
+      {
+        "type": "text",
+        "attributes": { "subtype": "bulleted-list" },
+        "delta": [{ "insert": "Use / to insert blocks" }]
+      },
+      {
+        "type": "text",
+        "attributes": { "subtype": "bulleted-list" },
+        "delta": [
+          {
+            "insert": "Select text to trigger to the toolbar to format your notes."
+          }
+        ]
+      },
+      { "type": "text", "delta": [] },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "If you have questions or feedback, please submit an issue on Github or join the community along with 1000+ builders!"
+          }
+        ]
+      }
+    ]
+  }
+}
+''';
+    setUpAll(() {
+      TestWidgetsFlutterBinding.ensureInitialized();
+    });
+
+    test('parser document', () async {
+      final data = Map<String, Object>.from(json.decode(example));
+      final document = Document.fromJson(data);
+      final result = DocumentMarkdownEncoder().convert(document);
+      expect(result, '''
+## 👋 **Welcome to** ***[AppFlowy Editor](appflowy.io)***
+
+AppFlowy Editor is a **highly customizable** _rich-text editor_ for <u>Flutter</u>
+- [x] Customizable
+- [x] Test-covered
+- [ ] more to come!
+
+> Here is an example you can give a try
+
+You can also use ***AppFlowy Editor*** as a component to build your own app.
+
+* Use / to insert blocks
+* Select text to trigger to the toolbar to format your notes.
+
+If you have questions or feedback, please submit an issue on Github or join the community along with 1000+ builders!''');
+    });
+  });
+}

+ 17 - 0
frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/encoder/parser/image_node_parser_test.dart

@@ -0,0 +1,17 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  group('image_node_parser.dart', () {
+    test('parser image node', () {
+      final node = Node(
+        type: 'image',
+        attributes: {
+          'image_src': 'https://appflowy.io',
+        },
+      );
+      final result = const ImageNodeParser().transform(node);
+      expect(result, '![](https://appflowy.io)');
+    });
+  });
+}

+ 95 - 0
frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/encoder/parser/text_node_parser_test.dart

@@ -0,0 +1,95 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  group('text_node_parser.dart', () {
+    const text = 'Welcome to AppFlowy';
+
+    test('heading style', () {
+      final h1 = TextNode(
+        delta: Delta(operations: [TextInsert(text)]),
+        attributes: {
+          BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
+          BuiltInAttributeKey.heading: BuiltInAttributeKey.h1,
+        },
+      );
+      final h2 = TextNode(
+        delta: Delta(operations: [TextInsert(text)]),
+        attributes: {
+          BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
+          BuiltInAttributeKey.heading: BuiltInAttributeKey.h2,
+        },
+      );
+      final h3 = TextNode(
+        delta: Delta(operations: [TextInsert(text)]),
+        attributes: {
+          BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
+          BuiltInAttributeKey.heading: BuiltInAttributeKey.h3,
+        },
+      );
+      expect(const TextNodeParser().transform(h1), '# $text');
+      expect(const TextNodeParser().transform(h2), '## $text');
+      expect(const TextNodeParser().transform(h3), '### $text');
+    });
+
+    test('bulleted list style', () {
+      final node = TextNode(
+        delta: Delta(operations: [TextInsert(text)]),
+        attributes: {
+          BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
+        },
+      );
+      expect(const TextNodeParser().transform(node), '* $text');
+    });
+
+    test('number list style', () {
+      final node = TextNode(
+        delta: Delta(operations: [TextInsert(text)]),
+        attributes: {
+          BuiltInAttributeKey.subtype: BuiltInAttributeKey.numberList,
+          BuiltInAttributeKey.number: 1,
+        },
+      );
+      expect(const TextNodeParser().transform(node), '1. $text');
+    });
+
+    test('checkbox style', () {
+      final checkbox = TextNode(
+        delta: Delta(operations: [TextInsert(text)]),
+        attributes: {
+          BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox,
+          BuiltInAttributeKey.checkbox: true,
+        },
+      );
+      final unCheckbox = TextNode(
+        delta: Delta(operations: [TextInsert(text)]),
+        attributes: {
+          BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox,
+          BuiltInAttributeKey.checkbox: false,
+        },
+      );
+      expect(const TextNodeParser().transform(checkbox), '- [x] $text');
+      expect(const TextNodeParser().transform(unCheckbox), '- [ ] $text');
+    });
+
+    test('quote style', () {
+      final node = TextNode(
+        delta: Delta(operations: [TextInsert(text)]),
+        attributes: {
+          BuiltInAttributeKey.subtype: BuiltInAttributeKey.quote,
+        },
+      );
+      expect(const TextNodeParser().transform(node), '> $text');
+    });
+
+    test('code block style', () {
+      final node = TextNode(
+        delta: Delta(operations: [TextInsert(text)]),
+        attributes: {
+          BuiltInAttributeKey.subtype: 'code-block',
+        },
+      );
+      expect(const TextNodeParser().transform(node), '```\n$text\n```');
+    });
+  });
+}

+ 7 - 0
frontend/app_flowy/pubspec.lock

@@ -773,6 +773,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "1.0.2"
+  markdown:
+    dependency: transitive
+    description:
+      name: markdown
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "6.0.1"
   matcher:
     dependency: transitive
     description: