Merge pull request #1424 from LucasXu0/markdown

Implement appflowy editor document to markdown
This commit is contained in:
Lucas.Xu 2022-11-09 09:50:15 +08:00 committed by GitHub
commit cdf6f1b38a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 871 additions and 39 deletions

View File

@ -1,14 +1,14 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:app_flowy/plugins/doc/application/share_service.dart'; 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-document/entities.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-folder/view.pb.dart';
import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-error/errors.pb.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:dartz/dartz.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'; part 'share_bloc.freezed.dart';
class DocShareBloc extends Bloc<DocShareEvent, DocShareState> { class DocShareBloc extends Bloc<DocShareEvent, DocShareState> {

View File

@ -33,3 +33,10 @@ export 'src/render/selection_menu/selection_menu_widget.dart';
export 'src/l10n/l10n.dart'; export 'src/l10n/l10n.dart';
export 'src/render/style/plugin_styles.dart'; export 'src/render/style/plugin_styles.dart';
export 'src/render/style/editor_style.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';

View File

@ -50,7 +50,7 @@ class TextInsert extends TextOperation {
final result = <String, dynamic>{ final result = <String, dynamic>{
'insert': text, 'insert': text,
}; };
if (_attributes != null) { if (_attributes != null && _attributes!.isNotEmpty) {
result['attributes'] = attributes; result['attributes'] = attributes;
} }
return result; return result;
@ -62,7 +62,7 @@ class TextInsert extends TextOperation {
return other is TextInsert && return other is TextInsert &&
other.text == text && other.text == text &&
mapEquals(_attributes, other._attributes); _mapEquals(_attributes, other._attributes);
} }
@override @override
@ -87,7 +87,7 @@ class TextRetain extends TextOperation {
final result = <String, dynamic>{ final result = <String, dynamic>{
'retain': length, 'retain': length,
}; };
if (_attributes != null) { if (_attributes != null && _attributes!.isNotEmpty) {
result['attributes'] = attributes; result['attributes'] = attributes;
} }
return result; return result;
@ -99,7 +99,7 @@ class TextRetain extends TextOperation {
return other is TextRetain && return other is TextRetain &&
other.length == length && other.length == length &&
mapEquals(_attributes, other._attributes); _mapEquals(_attributes, other._attributes);
} }
@override @override
@ -181,7 +181,7 @@ class Delta extends Iterable<TextOperation> {
lastOp.length += textOperation.length; lastOp.length += textOperation.length;
return; return;
} }
if (mapEquals(lastOp.attributes, textOperation.attributes)) { if (_mapEquals(lastOp.attributes, textOperation.attributes)) {
if (lastOp is TextInsert && textOperation is TextInsert) { if (lastOp is TextInsert && textOperation is TextInsert) {
lastOp.text += textOperation.text; lastOp.text += textOperation.text;
return; 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);
}

View File

@ -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);
}
}
}

View File

@ -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());
}
}

View File

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

View File

@ -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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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)***',
);
});
});
}

View File

@ -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!''');
});
});
}

View File

@ -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)');
});
});
}

View File

@ -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```');
});
});
}

View File

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