From 4622a412b7ab224f979b9862b195261a372db15d Mon Sep 17 00:00:00 2001
From: "Lucas.Xu" <lucas.xu@appflowy.io>
Date: Tue, 8 Nov 2022 20:10:09 +0800
Subject: [PATCH] feat: markdown to document

---
 .../lib/src/core/document/text_delta.dart     |   4 +-
 .../decoder/delta_markdown_decoder.dart       |   4 +-
 .../decoder/document_markdown_decoder.dart    |  91 +++++++++++++
 .../plugins/markdown/document_markdown.dart   |  29 ++++
 .../document_markdown_decoder_test.dart       | 126 ++++++++++++++++++
 5 files changed, 251 insertions(+), 3 deletions(-)
 create mode 100644 frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/decoder/document_markdown_decoder.dart
 create mode 100644 frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/document_markdown.dart
 create mode 100644 frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/decoder/document_markdown_decoder_test.dart

diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/text_delta.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/text_delta.dart
index 9969681e73..9aa66d962a 100644
--- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/text_delta.dart
+++ b/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;
@@ -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;
diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/decoder/delta_markdown_decoder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/decoder/delta_markdown_decoder.dart
index ccd49f22fd..e015546989 100644
--- a/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/decoder/delta_markdown_decoder.dart
+++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/decoder/delta_markdown_decoder.dart
@@ -1,6 +1,8 @@
 import 'dart:convert';
 
-import 'package:appflowy_editor/appflowy_editor.dart';
+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>
diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/decoder/document_markdown_decoder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/decoder/document_markdown_decoder.dart
new file mode 100644
index 0000000000..257cfabb28
--- /dev/null
+++ b/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());
+  }
+}
diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/document_markdown.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/document_markdown.dart
new file mode 100644
index 0000000000..48714a6a85
--- /dev/null
+++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/document_markdown.dart
@@ -0,0 +1,29 @@
+library delta_markdown;
+
+import 'dart:convert';
+
+import 'package:appflowy_editor/src/core/document/document.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();
+
+Document markdownToDocument(String markdown) {
+  return _kCodec.decode(markdown);
+}
+
+String documentToMarkdown(Document document) {
+  return _kCodec.encode(document);
+}
+
+class AppFlowyEditorMarkdownCodec extends Codec<Document, String> {
+  const AppFlowyEditorMarkdownCodec();
+
+  @override
+  Converter<String, Document> get decoder => throw UnimplementedError();
+
+  @override
+  Converter<Document, String> get encoder {
+    return DocumentMarkdownEncoder();
+  }
+}
diff --git a/frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/decoder/document_markdown_decoder_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/decoder/document_markdown_decoder_test.dart
new file mode 100644
index 0000000000..a95e7b877e
--- /dev/null
+++ b/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);
+    });
+  });
+}