diff --git a/frontend/app_flowy/lib/plugins/doc/application/share_bloc.dart b/frontend/app_flowy/lib/plugins/doc/application/share_bloc.dart index 9487350522..eb18257988 100644 --- a/frontend/app_flowy/lib/plugins/doc/application/share_bloc.dart +++ b/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 { diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart index 29cb9f87f6..750ba6c689 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart +++ b/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'; 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 5bf1832f73..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 = { '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 = { '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 { 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(Map? a, Map? b) { + if ((a == null || a.isEmpty) && (b == null || b.isEmpty)) { + return true; + } + return mapEquals(a, b); +} 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 new file mode 100644 index 0000000000..e015546989 --- /dev/null +++ b/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 + 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); + } + } +} 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 { + @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/lib/workspace/application/markdown/document_markdown.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/document_markdown.dart similarity index 55% rename from frontend/app_flowy/lib/workspace/application/markdown/document_markdown.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/document_markdown.dart index 71d0137280..224f9b6ccd 100644 --- a/frontend/app_flowy/lib/workspace/application/markdown/document_markdown.dart +++ b/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 { const AppFlowyEditorMarkdownCodec(); @override - Converter get decoder => throw UnimplementedError(); + Converter get decoder => DocumentMarkdownDecoder(); @override - Converter get encoder { - return AppFlowyEditorMarkdownEncoder(); - } + Converter get encoder => DocumentMarkdownEncoder(); } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/delta_markdown_encoder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/delta_markdown_encoder.dart new file mode 100644 index 0000000000..5c8bd9ebf9 --- /dev/null +++ b/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 { + @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 += ''; + } + 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 += ''; + } + + 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; + } +} diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/parser/markdown_encoder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/document_markdown_encoder.dart similarity index 68% rename from frontend/app_flowy/lib/workspace/application/markdown/src/parser/markdown_encoder.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/document_markdown_encoder.dart index f6c1e6ea02..52c2bd3756 100644 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/parser/markdown_encoder.dart +++ b/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 { - AppFlowyEditorMarkdownEncoder({ +class DocumentMarkdownEncoder extends Converter { + DocumentMarkdownEncoder({ this.parsers = const [ TextNodeParser(), ImageNodeParser(), diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/parser/image_node_parser.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/parser/image_node_parser.dart similarity index 65% rename from frontend/app_flowy/lib/workspace/application/markdown/src/parser/image_node_parser.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/parser/image_node_parser.dart index 575cf13216..5db9f1b558 100644 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/parser/image_node_parser.dart +++ b/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(); diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/parser/node_parser.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/parser/node_parser.dart similarity index 62% rename from frontend/app_flowy/lib/workspace/application/markdown/src/parser/node_parser.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/parser/node_parser.dart index 649ca7eae7..9cbdabfbb9 100644 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/parser/node_parser.dart +++ b/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(); diff --git a/frontend/app_flowy/lib/workspace/application/markdown/src/parser/text_node_parser.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/parser/text_node_parser.dart similarity index 74% rename from frontend/app_flowy/lib/workspace/application/markdown/src/parser/text_node_parser.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/markdown/encoder/parser/text_node_parser.dart index 0dbf6418aa..8857310876 100644 --- a/frontend/app_flowy/lib/workspace/application/markdown/src/parser/text_node_parser.dart +++ b/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'; } diff --git a/frontend/app_flowy/packages/appflowy_editor/pubspec.yaml b/frontend/app_flowy/packages/appflowy_editor/pubspec.yaml index 574757cb79..04a31d29e5 100644 --- a/frontend/app_flowy/packages/appflowy_editor/pubspec.yaml +++ b/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: diff --git a/frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/decoder/delta_markdown_decoder_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/decoder/delta_markdown_decoder_test.dart new file mode 100644 index 0000000000..206aa0ea0b --- /dev/null +++ b/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 = + '***`Welcome`*** ***~~to~~*** ***[AppFlowy](https://appflowy.io)***'; + final delta = Delta(operations: [ + TextInsert('', attributes: { + BuiltInAttributeKey.italic: true, + BuiltInAttributeKey.bold: true, + }), + TextInsert('Welcome', attributes: { + BuiltInAttributeKey.code: true, + BuiltInAttributeKey.italic: true, + BuiltInAttributeKey.bold: true, + }), + TextInsert('', 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); + }); + }); +} 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.from(json.decode(example)); + expect(result.toJson(), data); + }); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/encoder/delta_markdown_encoder_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/encoder/delta_markdown_encoder_test.dart new file mode 100644 index 0000000000..831a449d02 --- /dev/null +++ b/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 AppFlowy'); + }); + + 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, + '***`Welcome`*** ***~~to~~*** ***[AppFlowy](https://appflowy.io)***', + ); + }); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/encoder/document_markdown_encoder_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/encoder/document_markdown_encoder_test.dart new file mode 100644 index 0000000000..0c3fdb0254 --- /dev/null +++ b/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.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 Flutter +- [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!'''); + }); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/encoder/parser/image_node_parser_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/encoder/parser/image_node_parser_test.dart new file mode 100644 index 0000000000..77102c8310 --- /dev/null +++ b/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)'); + }); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/encoder/parser/text_node_parser_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/plugins/markdown/encoder/parser/text_node_parser_test.dart new file mode 100644 index 0000000000..0d7c540a2b --- /dev/null +++ b/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```'); + }); + }); +} diff --git a/frontend/app_flowy/pubspec.lock b/frontend/app_flowy/pubspec.lock index 2711b7651d..9c2201954d 100644 --- a/frontend/app_flowy/pubspec.lock +++ b/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: