feat: support parsing the nested list when exporting the document to markdown format (#540)

* feat: support exporting nested list

* feat: support parsing the nested list

* fix: tests
This commit is contained in:
Lucas.Xu 2023-10-14 23:47:57 +08:00 • committed by GitHub
commit 9ae85ea162
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 149 additions and 90 deletions

View file

@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:appflowy_editor/src/core/document/document.dart';
import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/node_parser.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
class DocumentMarkdownEncoder extends Converter<Document, String> {
@ -19,9 +18,23 @@ class DocumentMarkdownEncoder extends Converter<Document, String> {
(element) => element.id == node.type,
);
if (parser != null) {
buffer.write(parser.transform(node));
buffer.write(parser.transform(node, this));
}
}
return buffer.toString();
}
String convertNodes(
List<Node> nodes, {
bool withIndent = false,
}) {
final result = convert(Document(root: pageNode(children: nodes)));
if (result.isNotEmpty && withIndent) {
return result
.split('\n')
.map((e) => e.isNotEmpty ? '\t$e' : e)
.join('\n');
}
return result;
}
}

View file

@ -4,20 +4,17 @@ class BulletedListNodeParser extends NodeParser {
const BulletedListNodeParser();
@override
String get id => 'bulleted_list';
String get id => BulletedListBlockKeys.type;
@override
String transform(Node node) {
assert(node.type == 'bulleted_list');
final delta = node.delta;
if (delta == null) {
throw Exception('Delta is null');
String transform(Node node, DocumentMarkdownEncoder? encoder) {
final delta = node.delta ?? Delta()
..insert('');
final children = encoder?.convertNodes(node.children, withIndent: true);
String markdown = '* ${DeltaMarkdownEncoder().convert(delta)}\n';
if (children != null && children.isNotEmpty) {
markdown += children;
}
final markdown = DeltaMarkdownEncoder().convert(delta);
final result = '* $markdown';
final suffix = node.next == null ? '' : '\n';
return '$result$suffix';
return markdown;
}
}

View file

@ -7,7 +7,7 @@ class CodeBlockNodeParser extends NodeParser {
String get id => 'code';
@override
String transform(Node node) {
String transform(Node node, DocumentMarkdownEncoder? encoder) {
assert(node.type == 'code');
final delta = node.delta;

View file

@ -4,16 +4,12 @@ class HeadingNodeParser extends NodeParser {
const HeadingNodeParser();
@override
String get id => 'heading';
String get id => HeadingBlockKeys.type;
@override
String transform(Node node) {
assert(node.type == 'heading');
final delta = node.delta;
if (delta == null) {
throw Exception('Delta is null');
}
String transform(Node node, DocumentMarkdownEncoder? encoder) {
final delta = node.delta ?? Delta()
..insert('');
final markdown = DeltaMarkdownEncoder().convert(delta);
final attributes = node.attributes;
final level = attributes[HeadingBlockKeys.level] as int? ?? 1;

View file

@ -1,14 +1,13 @@
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/node_parser.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
class ImageNodeParser extends NodeParser {
const ImageNodeParser();
@override
String get id => 'image';
String get id => ImageBlockKeys.type;
@override
String transform(Node node) {
String transform(Node node, DocumentMarkdownEncoder? encoder) {
return '![](${node.attributes['image_src']})';
}
}

View file

@ -1,8 +1,8 @@
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
abstract class NodeParser {
const NodeParser();
String get id;
String transform(Node node);
String transform(Node node, DocumentMarkdownEncoder? encoder);
}

View file

@ -4,20 +4,18 @@ class NumberedListNodeParser extends NodeParser {
const NumberedListNodeParser();
@override
String get id => 'numbered_list';
String get id => NumberedListBlockKeys.type;
@override
String transform(Node node) {
assert(node.type == 'numbered_list');
final delta = node.delta;
if (delta == null) {
throw Exception('Delta is null');
String transform(Node node, DocumentMarkdownEncoder? encoder) {
final delta = node.delta ?? Delta()
..insert('');
final number = node.attributes[NumberedListBlockKeys.number] ?? '1';
final children = encoder?.convertNodes(node.children, withIndent: true);
String markdown = '$number. ${DeltaMarkdownEncoder().convert(delta)}\n';
if (children != null && children.isNotEmpty) {
markdown += children;
}
final markdown = DeltaMarkdownEncoder().convert(delta);
final result = '1. $markdown'; // FIXME: support parse the number
final suffix = node.next == null ? '' : '\n';
return '$result$suffix';
return markdown;
}
}

View file

@ -4,20 +4,17 @@ class QuoteNodeParser extends NodeParser {
const QuoteNodeParser();
@override
String get id => 'quote';
String get id => QuoteBlockKeys.type;
@override
String transform(Node node) {
assert(node.type == 'quote');
final delta = node.delta;
if (delta == null) {
throw Exception('Delta is null');
String transform(Node node, DocumentMarkdownEncoder? encoder) {
final delta = node.delta ?? Delta()
..insert('');
final children = encoder?.convertNodes(node.children, withIndent: true);
String markdown = '> ${DeltaMarkdownEncoder().convert(delta)}\n';
if (children != null && children.isNotEmpty) {
markdown += children;
}
final markdown = DeltaMarkdownEncoder().convert(delta);
final result = '> $markdown';
final suffix = node.next == null ? '' : '\n';
return '$result$suffix';
return markdown;
}
}

View file

@ -8,7 +8,7 @@ class TableNodeParser extends NodeParser {
String get id => 'table';
@override
String transform(Node node) {
String transform(Node node, DocumentMarkdownEncoder? encoder) {
final int rowsLen = node.attributes['rowsLen'],
colsLen = node.attributes['colsLen'];
String result = '';

View file

@ -4,17 +4,24 @@ class TextNodeParser extends NodeParser {
const TextNodeParser();
@override
String get id => 'paragraph';
String get id => ParagraphBlockKeys.type;
@override
String transform(Node node) {
final delta = node.delta;
if (delta == null) {
assert(false, 'Delta is null');
String transform(Node node, DocumentMarkdownEncoder? encoder) {
final delta = node.delta ?? Delta()
..insert('');
final children = encoder?.convertNodes(node.children, withIndent: true);
String markdown = DeltaMarkdownEncoder().convert(delta);
if (markdown.isEmpty && children == null) {
return '';
} else if (node
.findParent((element) => element.type == TableBlockKeys.type) ==
null) {
markdown += '\n';
}
final markdown = DeltaMarkdownEncoder().convert(delta);
final suffix = node.next == null ? '' : '\n';
return '$markdown$suffix';
if (children != null && children.isNotEmpty) {
markdown += children;
}
return markdown;
}
}

View file

@ -4,22 +4,19 @@ class TodoListNodeParser extends NodeParser {
const TodoListNodeParser();
@override
String get id => 'todo_list';
String get id => TodoListBlockKeys.type;
@override
String transform(Node node) {
assert(node.type == 'todo_list');
final delta = node.delta;
if (delta == null) {
throw Exception('Delta is null');
String transform(Node node, DocumentMarkdownEncoder? encoder) {
final delta = node.delta ?? Delta()
..insert('');
final checked =
node.attributes[TodoListBlockKeys.checked] == true ? '- [x]' : '- [ ]';
final children = encoder?.convertNodes(node.children, withIndent: true);
String markdown = '$checked ${DeltaMarkdownEncoder().convert(delta)}\n';
if (children != null && children.isNotEmpty) {
markdown += children;
}
final markdown = DeltaMarkdownEncoder().convert(delta);
final attributes = node.attributes;
final checked = attributes[TodoListBlockKeys.checked] == true;
final result = checked ? '- [x] $markdown' : '- [ ] $markdown';
final suffix = node.next == null ? '' : '\n';
return '$result$suffix';
return markdown;
}
}

View file

@ -18,9 +18,54 @@ void main() {
expect(markdown, markdownDocumentEncoded);
});
test('with nested list', () {
final markdown = documentToMarkdown(
Document.fromJson(
Map<String, Object>.from(json.decode(withNestedListDocument)),
),
);
expect(markdown, withNestedListMarkdown);
});
});
}
const withNestedListDocument =
'''{"document":{"type":"page","children":[{"type":"numbered_list","data":{"delta":[{"insert":"number 1"}],"number":1}},{"type":"numbered_list","data":{"delta":[{"insert":"number 2"}]}},{"type":"numbered_list","data":{"delta":[{"insert":"number 3"}]}},{"type":"paragraph","data":{"delta":[]}},{"type":"bulleted_list","data":{"delta":[{"insert":"bulleted 1"}]}},{"type":"bulleted_list","data":{"delta":[{"insert":"bulleted 2"}]}},{"type":"bulleted_list","data":{"delta":[{"insert":"bulleted 3"}]}},{"type":"paragraph","data":{"delta":[]}},{"type":"numbered_list","children":[{"type":"numbered_list","data":{"delta":[{"insert":"number 1"}]}},{"type":"bulleted_list","data":{"delta":[{"insert":"bulleted 1"}]}},{"type":"todo_list","data":{"checked":false,"delta":[{"insert":"to-do list 1"}]}},{"type":"paragraph","data":{"delta":[]}}],"data":{"delta":[{"insert":"numbered list with children"}],"number":1}},{"type":"bulleted_list","children":[{"type":"numbered_list","data":{"delta":[{"insert":"number 1"}],"number":1}},{"type":"bulleted_list","data":{"delta":[{"insert":"bulleted 1"}]}},{"type":"todo_list","data":{"checked":false,"delta":[{"insert":"to-do list 1"}]}}],"data":{"delta":[{"insert":"bulleted list with children"}]}},{"type":"paragraph","data":{"delta":[]}},{"type":"todo_list","children":[{"type":"numbered_list","data":{"delta":[{"insert":"number 1"}],"number":1}},{"type":"bulleted_list","data":{"delta":[{"insert":"bulleted 1"}]}},{"type":"todo_list","data":{"checked":false,"delta":[{"insert":"to-do list 1"}]}}],"data":{"checked":false,"delta":[{"insert":"to-do list with children"}]}},{"type":"paragraph","data":{"delta":[]}},{"type":"paragraph","children":[{"type":"numbered_list","data":{"delta":[{"insert":"number 1"}]}},{"type":"bulleted_list","data":{"delta":[{"insert":"bulleted 1"}]}},{"type":"todo_list","data":{"checked":false,"delta":[{"insert":"to-do list 1"}]}}],"data":{"delta":[{"insert":"text list with children"}]}},{"type":"paragraph","data":{"delta":[]}},{"type":"paragraph","children":[{"type":"paragraph","children":[{"type":"paragraph","children":[{"type":"paragraph","data":{"delta":[{"insert":"level 4"}]}}],"data":{"delta":[{"insert":"level 3"}]}}],"data":{"delta":[{"insert":"level 2"}]}}],"data":{"delta":[{"insert":"level 1"}]}}]}}''';
const withNestedListMarkdown = '''1. number 1
1. number 2
1. number 3
* bulleted 1
* bulleted 2
* bulleted 3
1. numbered list with children
1. number 1
* bulleted 1
- [ ] to-do list 1
* bulleted list with children
1. number 1
* bulleted 1
- [ ] to-do list 1
- [ ] to-do list with children
1. number 1
* bulleted 1
- [ ] to-do list 1
text list with children
1. number 1
* bulleted 1
- [ ] to-do list 1
level 1
level 2
level 3
level 4
''';
const testDocument = '''{
"document": {
"type": "page",

View file

@ -324,6 +324,7 @@ You can also use ***AppFlowy Editor*** as a component to build your own app.
* 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

@ -11,7 +11,7 @@ void main() async {
},
);
final result = const ImageNodeParser().transform(node);
final result = const ImageNodeParser().transform(node, null);
expect(result, '![](https://appflowy.io)');
});

View file

@ -1,5 +1,5 @@
import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/table_node_parser.dart';
import 'package:appflowy_editor/src/editor/block_component/table_block_component/table_node.dart';
import 'package:appflowy_editor/src/plugins/markdown/encoder/parser/table_node_parser.dart';
import 'package:flutter_test/flutter_test.dart';
void main() async {
@ -10,7 +10,7 @@ void main() async {
['b'],
]).node;
final result = const TableNodeParser().transform(node);
final result = const TableNodeParser().transform(node, null);
expect(result, '|a|b|\n|-|-|');
});
@ -20,7 +20,7 @@ void main() async {
[''],
]).node;
final result = const TableNodeParser().transform(node);
final result = const TableNodeParser().transform(node, null);
expect(result, '| | |\n|-|-|');
});
});

View file

@ -15,7 +15,7 @@ void main() async {
},
);
expect(
const HeadingNodeParser().transform(node),
const HeadingNodeParser().transform(node, null),
'${'#' * i} $text',
);
}
@ -27,7 +27,7 @@ void main() async {
'delta': (Delta()..insert(text)).toJson(),
},
);
expect(const BulletedListNodeParser().transform(node), '* $text');
expect(const BulletedListNodeParser().transform(node, null), '* $text\n');
});
test('numbered list style', () {
@ -36,7 +36,10 @@ void main() async {
'delta': (Delta()..insert(text)).toJson(),
},
);
expect(const NumberedListNodeParser().transform(node), '1. $text');
expect(
const NumberedListNodeParser().transform(node, null),
'1. $text\n',
);
});
test('todo list style', () {
@ -52,10 +55,13 @@ void main() async {
'delta': (Delta()..insert(text)).toJson(),
},
);
expect(const TodoListNodeParser().transform(checkedNode), '- [x] $text');
expect(
const TodoListNodeParser().transform(uncheckedNode),
'- [ ] $text',
const TodoListNodeParser().transform(checkedNode, null),
'- [x] $text\n',
);
expect(
const TodoListNodeParser().transform(uncheckedNode, null),
'- [ ] $text\n',
);
});
@ -65,7 +71,7 @@ void main() async {
'delta': (Delta()..insert(text)).toJson(),
},
);
expect(const QuoteNodeParser().transform(node), '> $text');
expect(const QuoteNodeParser().transform(node, null), '> $text\n');
});
test('code block style', () {
@ -75,7 +81,10 @@ void main() async {
'delta': (Delta()..insert(text)).toJson(),
},
);
expect(const CodeBlockNodeParser().transform(node), '```\n$text\n```');
expect(
const CodeBlockNodeParser().transform(node, null),
'```\n$text\n```',
);
});
test('fallback', () {
@ -85,7 +94,7 @@ void main() async {
'bold': true,
},
);
expect(const TextNodeParser().transform(node), text);
expect(const TextNodeParser().transform(node, null), '$text\n');
});
});
}