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:
parent
d1027c4d83
commit
9ae85ea162
16 changed files with 149 additions and 90 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
||||
''');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ void main() async {
|
|||
},
|
||||
);
|
||||
|
||||
final result = const ImageNodeParser().transform(node);
|
||||
final result = const ImageNodeParser().transform(node, null);
|
||||
expect(result, '');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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|-|-|');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue