feat: support custom markdown parsers (#835)

* feat: support custom markdown parsers

* fix: markdown test

* feat: add markdown quote parser

* chore: use markdown parser

* chore: parse unordered list, to_do list and quote

* feat: add a markdown editor example

* feat: add bulleted list parser

* feat: support nested list and img

* feat: support code block

* feat: support table parser

* chore: refacotr code

* fix: flutter analyze

* chore: add more tests and remove unused files

* fix: test
This commit is contained in:
Lucas.Xu 2024-07-04 09:53:25 +08:00 • committed by GitHub
commit 5bf6c7acd0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1413 additions and 1099 deletions

View file

@ -12,6 +12,7 @@ import 'package:example/pages/editor.dart';
import 'package:example/pages/editor_list.dart'; import 'package:example/pages/editor_list.dart';
import 'package:example/pages/fixed_toolbar_editor.dart'; import 'package:example/pages/fixed_toolbar_editor.dart';
import 'package:example/pages/focus_example_for_editor.dart'; import 'package:example/pages/focus_example_for_editor.dart';
import 'package:example/pages/markdown_editor.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -165,6 +166,14 @@ class _HomePageState extends State<HomePage> {
// Theme Demo // Theme Demo
_buildSeparator(context, 'Showcases'), _buildSeparator(context, 'Showcases'),
_buildListTile(context, 'Markdown Editor', () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const MarkdownEditor(),
),
);
}),
_buildListTile(context, 'Auto complete Editor', () { _buildListTile(context, 'Auto complete Editor', () {
Navigator.push( Navigator.push(
context, context,

View file

@ -0,0 +1,51 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:markdown/markdown.dart' as md;
class MarkdownCodeBlockParserV2 extends CustomMarkdownParser {
const MarkdownCodeBlockParserV2();
@override
List<Node> transform(
md.Node element,
List<CustomMarkdownParser> parsers,
MarkdownListType listType,
) {
if (element is! md.Element) {
return [];
}
if (element.tag != 'pre') {
return [];
}
final ec = element.children;
if (ec == null || ec.isEmpty) {
return [];
}
final code = ec.first;
if (code is! md.Element || code.tag != 'code') {
return [];
}
String? language;
if (code.attributes.containsKey('class')) {
final classes = code.attributes['class']!.split(' ');
final languageClass = classes.firstWhere(
(c) => c.startsWith('language-'),
orElse: () => '',
);
language = languageClass.substring('language-'.length);
}
final deltaDecoder = DeltaMarkdownDecoder();
return [
codeBlockNode(
language: language,
delta: deltaDecoder.convertNodes(code.children),
),
];
}
}

View file

@ -0,0 +1,100 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:example/pages/markdown/markdown_code_block_parser.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class MarkdownEditor extends StatefulWidget {
const MarkdownEditor({super.key});
@override
State<MarkdownEditor> createState() => _MarkdownEditorState();
}
class _MarkdownEditorState extends State<MarkdownEditor> {
EditorState editorState = EditorState.blank();
final controller = TextEditingController();
final editorStyle = EditorStyle.desktop(
padding: EdgeInsets.zero,
textStyleConfiguration: TextStyleConfiguration(
lineHeight: 1.2,
applyHeightToFirstAscent: true,
applyHeightToLastDescent: true,
text: const TextStyle(
fontSize: 16,
color: Colors.black,
),
code: GoogleFonts.architectsDaughter(),
bold: GoogleFonts.poppins(
fontWeight: FontWeight.w500,
),
),
);
@override
void initState() {
super.initState();
controller.addListener(_onTextChanged);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.black,
title: const Text('Markdown Editor'),
titleTextStyle: const TextStyle(color: Colors.white),
iconTheme: const IconThemeData(
color: Colors.white,
),
),
body: Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Expanded(
child: AppFlowyEditor(
editorState: editorState,
editorStyle: editorStyle,
editable: true,
blockComponentBuilders: {
...standardBlockComponentBuilderMap,
CodeBlockKeys.type: CodeBlockComponentBuilder(),
},
),
),
const VerticalDivider(),
Expanded(
child: TextFormField(
controller: controller,
maxLines: null,
decoration: const InputDecoration(
hintText: 'Type markdown here ...',
border: InputBorder.none,
),
),
),
],
),
);
}
void _onTextChanged() {
final document = markdownToDocument(
controller.text,
markdownParsers: [
const MarkdownCodeBlockParserV2(),
],
);
setState(() {
editorState = EditorState(document: document);
});
}
}

View file

@ -31,7 +31,7 @@ dependencies:
sdk: flutter sdk: flutter
appflowy_editor: appflowy_editor:
path: ../
provider: ^6.0.3 provider: ^6.0.3
url_launcher: ^6.1.5 url_launcher: ^6.1.5
path_provider: ^2.0.15 path_provider: ^2.0.15
@ -43,6 +43,12 @@ dependencies:
highlight: ^0.7.0 highlight: ^0.7.0
http: ^1.1.0 http: ^1.1.0
show_fps: ^1.0.6 show_fps: ^1.0.6
appflowy_editor_plugins: ^0.0.6
markdown:
dependency_overrides:
appflowy_editor:
path: ../
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -33,6 +33,17 @@ class DeltaMarkdownDecoder extends Converter<String, Delta>
return _delta; return _delta;
} }
Delta convertNodes(List<md.Node>? nodes) {
if (nodes == null) {
return Delta();
}
for (final node in nodes) {
node.accept(this);
}
return _delta;
}
@override @override
void visitElementAfter(md.Element element) { void visitElementAfter(md.Element element) {
_removeAttributeKey(element); _removeAttributeKey(element);
@ -64,7 +75,11 @@ class DeltaMarkdownDecoder extends Converter<String, Delta>
_attributes[BuiltInAttributeKey.underline] = true; _attributes[BuiltInAttributeKey.underline] = true;
} else { } else {
element.attributes.forEach((key, value) { element.attributes.forEach((key, value) {
_attributes[key] = jsonDecode(value); try {
_attributes[key] = jsonDecode(value);
} catch (_) {
return;
}
}); });
} }
} }

View file

@ -1,307 +1,66 @@
import 'dart:convert'; import 'dart:convert';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/plugins/markdown/decoder/parser/custom_node_parser.dart'; import 'package:appflowy_editor/src/plugins/markdown/decoder/custom_syntaxes/underline_syntax.dart';
import 'package:appflowy_editor/src/plugins/markdown/decoder/table_markdown_decoder.dart'; import 'package:collection/collection.dart';
import 'package:markdown/markdown.dart' as md; import 'package:markdown/markdown.dart' as md;
class DocumentMarkdownDecoder extends Converter<String, Document> { class DocumentMarkdownDecoder extends Converter<String, Document> {
DocumentMarkdownDecoder({ DocumentMarkdownDecoder({
this.customNodeParsers = const [], this.markdownElementParsers = const [],
this.customInlineSyntaxes = const [], this.inlineSyntaxes = const [],
}); });
final List<CustomNodeParser> customNodeParsers; final List<CustomMarkdownParser> markdownElementParsers;
final List<md.InlineSyntax> customInlineSyntaxes; final List<md.InlineSyntax> inlineSyntaxes;
final imageRegex = RegExp(r'^!\[[^\]]*\]\((.*?)\)');
final assetRegex = RegExp(r'^\[[^\]]*\]\((.*?)\)');
final htmlRegex = RegExp('^(http|https)://');
final numberedListRegex = RegExp(r'^\d+\. ');
@override @override
Document convert(String input) { Document convert(String input) {
final lines = input.split('\n'); final List<md.Node> mdNodes = md.Document(
extensionSet: md.ExtensionSet.gitHubWeb,
inlineSyntaxes: [
...inlineSyntaxes,
UnderlineInlineSyntax(),
],
).parse(input);
final document = Document.blank(); final document = Document.blank();
// In the below while loop we iterate through each line of the input markdown
// if the line starts with ``` it means there are chances it may be a code block
// so it iterates through all the lines(using another while loop) storing them in a
// string variable codeblock until it finds another ``` which marks the end of code
// block it then sends this to convert _convertLineToNode function .
// If we reach the end of lines and we still haven't found ``` then we will treat the
// line starting with ``` as a regular paragraph and continue the main while loop from
// a temporary pointer which stored the line number of the line with starting ```
int i = 0;
while (i < lines.length) {
if (lines[i].startsWith('```') &&
lines[i].endsWith('```') &&
lines[i].length > 3) {
document.insert(
[i],
[convertLineToNode(lines[i], customInlineSyntaxes)],
);
i++;
} else if (lines[i].startsWith('```')) {
String codeBlock = '';
codeBlock += '${lines[i]}\n';
int tempLinePointer = i;
i++;
while (i < lines.length && !lines[i].endsWith('```')) {
codeBlock += '${lines[i]}\n';
i++;
}
if (i == lines.length) { final nodes = mdNodes
i = tempLinePointer; .map((mdNode) => _parseNode(mdNode))
document.insert( .toList()
[i], .whereNotNull()
[convertLineToNode(lines[i], customInlineSyntaxes)], .toList()
); .flattened;
i++;
} else {
codeBlock += lines[i];
document.insert(
[tempLinePointer],
[convertLineToNode(codeBlock, customInlineSyntaxes)],
);
i++;
}
} else if (i + 1 < lines.length &&
TableMarkdownDecoder.isTable(lines[i], lines[i + 1])) {
final node = TableMarkdownDecoder().convert(lines.sublist(i));
i += node.attributes['rowsLen'] as int;
document.insert([i], [node]); if (nodes.isNotEmpty) {
i++; document.insert([0], nodes);
} else if (i + 1 < lines.length && isNestedList(lines[i], lines[i + 1])) {
String text = lines[i];
int tempLinePointer = i;
int currentIndent = getIndentLevel(lines[i]);
i++;
while (i < lines.length &&
(lines[i].trimLeft().startsWith('- ') ||
lines[i].trimLeft().startsWith('* ') ||
numberedListRegex.hasMatch(lines[i].trimLeft())) &&
getIndentLevel(lines[i]) > currentIndent) {
text += '\n${lines[i]}';
i++;
}
List<String> listLines = text.split('\n');
Node? rootList;
Node? currentParent;
int indentLevel = 0;
for (String line in listLines) {
currentIndent = getIndentLevel(line);
Node newNode =
convertLineToNode(line.trimLeft(), customInlineSyntaxes);
if (rootList == null) {
rootList = newNode;
currentParent = newNode;
} else if (currentIndent > indentLevel) {
currentParent!.insert(newNode);
currentParent = newNode;
} else {
while (
currentIndent <= indentLevel && currentParent!.parent != null) {
currentParent = currentParent.parent;
indentLevel--;
}
if (currentParent != rootList) {
currentParent!.parent!.insert(newNode);
} else {
rootList.insert(newNode);
}
currentParent = newNode;
}
indentLevel = currentIndent;
}
if (rootList != null) {
document.insert([tempLinePointer], [rootList]);
}
} else {
document.insert(
[i],
[convertLineToNode(lines[i], customInlineSyntaxes)],
);
i++;
}
} }
return document; return document;
} }
bool isNestedList(String line1, String line2) { // handle node itself and its children
if ((line1.trimLeft().startsWith('- ') || List<Node> _parseNode(md.Node mdNode) {
line1.trimLeft().startsWith('* ') || List<Node> nodes = [];
numberedListRegex.hasMatch(line1.trimLeft())) &&
(line2.trimLeft().startsWith('- ') ||
line2.trimLeft().startsWith('* ') ||
numberedListRegex.hasMatch(line2.trimLeft()))) {
return getIndentLevel(line2) > getIndentLevel(line1);
}
return false;
}
int getIndentLevel(String line) { for (final parser in markdownElementParsers) {
int indent = 0; nodes = parser.transform(
while (indent < line.length && line[indent] == ' ') { mdNode,
indent++; markdownElementParsers,
} MarkdownListType.unknown,
return (indent / 2).floor(); );
}
Node convertLineToNode( if (nodes.isNotEmpty) {
String line, break;
List<md.InlineSyntax> customInlineSyntaxes,
) {
final decoder = DeltaMarkdownDecoder(
customInlineSyntaxes: customInlineSyntaxes,
);
for (final parser in customNodeParsers) {
final node = parser.transform(line);
if (node != null) {
return node;
} }
} }
// Heading Style if (nodes.isEmpty) {
if (line.startsWith('### ')) { Log.editor.debug(
return headingNode( 'empty result from node: $mdNode, text: ${mdNode.textContent}',
level: 3,
attributes: {'delta': decoder.convert(line.substring(4)).toJson()},
);
} else if (line.startsWith('## ')) {
return headingNode(
level: 2,
attributes: {'delta': decoder.convert(line.substring(3)).toJson()},
);
} else if (line.startsWith('# ')) {
return headingNode(
level: 1,
attributes: {'delta': decoder.convert(line.substring(2)).toJson()},
);
} else if (line.startsWith('- [ ] ')) {
return todoListNode(
checked: false,
attributes: {'delta': decoder.convert(line.substring(6)).toJson()},
);
} else if (line.startsWith('- [x] ')) {
return todoListNode(
checked: true,
attributes: {'delta': decoder.convert(line.substring(6)).toJson()},
);
} else if (line.startsWith('> ')) {
return quoteNode(
attributes: {'delta': decoder.convert(line.substring(2)).toJson()},
);
} else if (line.startsWith('- ') || line.startsWith('* ')) {
return bulletedListNode(
attributes: {'delta': decoder.convert(line.substring(2)).toJson()},
);
} else if (line.isNotEmpty && RegExp('^-*').stringMatch(line) == line) {
return Node(type: 'divider');
} else if (line.startsWith('```') && line.endsWith('```')) {
return codeBlockNodeFromMarkdown(line, decoder);
} else if (imageRegex.hasMatch(line.trim())) {
final filePath = extractImagePath(line.trim());
final regex = RegExp(
r'\.(png|jpg|jpeg|gif|bmp|svg|webp|tiff|ico)(\?.*)?$',
caseSensitive: false,
);
if (filePath == null || !regex.hasMatch(filePath)) {
return paragraphNode(text: line.trim());
}
return imageNode(url: filePath);
} else if (assetRegex.hasMatch(line.trim())) {
// this might be a url or a file like pdf, videos, etc
final filepath = extractFilePath(line.trim());
if (filepath != null && !htmlRegex.hasMatch(filepath)) {
return paragraphNode(text: line);
}
} else if (numberedListRegex.hasMatch(line)) {
return numberedListNode(
attributes: {
'delta':
decoder.convert(line.substring(line.indexOf('.') + 2)).toJson(),
},
); );
} }
if (line.isNotEmpty) { return nodes;
return paragraphNode(
attributes: {'delta': decoder.convert(line).toJson()},
);
}
return paragraphNode(
attributes: {'delta': Delta().toJson()},
);
}
String? extractImagePath(String text) {
final match = imageRegex.firstMatch(text);
return match?.group(1);
}
String? extractFilePath(String text) {
final match = assetRegex.firstMatch(text);
return match?.group(1);
}
static Node codeBlockNodeFromMarkdown(
String markdown,
DeltaMarkdownDecoder decoder,
) {
// This if conditions checks for cases like - ````` where we can see
// it starts with ``` as well as end with ``` but is not a valid code block
// so this is treated like a normal paragraph
if (!markdown.contains('\n') &&
markdown.split('`').length - 1 == markdown.length) {
return paragraphNode(
attributes: {'delta': decoder.convert(markdown).toJson()},
);
}
const codeMarker = '```';
int codeStartIndex = markdown.indexOf(codeMarker);
int codeEndIndex = markdown.indexOf(
codeMarker,
codeStartIndex + codeMarker.length,
);
// This if condition is for handling cases like ```\n`
// In this case codeStartIndex = 0 and codeEndIndex = -1
if (codeEndIndex < codeStartIndex) {
return paragraphNode(
attributes: {'delta': decoder.convert(markdown).toJson()},
);
}
String codeBlock = markdown.substring(
codeStartIndex + codeMarker.length,
codeEndIndex,
);
List<String> codeLines = codeBlock.trim().split('\n');
// This if condition will handle cases like ```hello world```
// this would be treated as a code block with hello world as its content
if (codeLines[0].split(' ').length > 1) {
return Node(
type: 'code',
attributes: {
'delta': decoder.convert(codeBlock).toJson(),
'language': null,
},
);
}
final language = codeLines[0].trim();
final codeContent = codeLines.sublist(1).join('\n');
return Node(
type: 'code',
attributes: {
'delta': decoder.convert(codeContent).toJson(),
'language': language.isEmpty ? null : language,
},
);
} }
} }

View file

@ -0,0 +1,12 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:markdown/markdown.dart' as md;
abstract class CustomMarkdownParser {
const CustomMarkdownParser();
List<Node> transform(
md.Node element,
List<CustomMarkdownParser> parsers,
MarkdownListType listType,
);
}

View file

@ -1,5 +0,0 @@
import '../../../../../appflowy_editor.dart';
abstract class CustomNodeParser {
Node? transform(String input);
}

View file

@ -0,0 +1,28 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:markdown/markdown.dart' as md;
class MarkdownBlockQuoteParserV2 extends CustomMarkdownParser {
const MarkdownBlockQuoteParserV2();
@override
List<Node> transform(
md.Node element,
List<CustomMarkdownParser> parsers,
MarkdownListType listType,
) {
if (element is! md.Element) {
return [];
}
if (element.tag != 'blockquote') {
return [];
}
final deltaDecoder = DeltaMarkdownDecoder();
return [
quoteNode(
delta: deltaDecoder.convertNodes(element.children),
),
];
}
}

View file

@ -0,0 +1,25 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:markdown/markdown.dart' as md;
class MarkdownDividerParserV2 extends CustomMarkdownParser {
const MarkdownDividerParserV2();
@override
List<Node> transform(
md.Node element,
List<CustomMarkdownParser> parsers,
MarkdownListType listType,
) {
if (element is! md.Element) {
return [];
}
if (element.tag != 'hr') {
return [];
}
return [
dividerNode(),
];
}
}

View file

@ -0,0 +1,33 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:markdown/markdown.dart' as md;
final _headingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
class MarkdownHeadingParserV2 extends CustomMarkdownParser {
const MarkdownHeadingParserV2();
@override
List<Node> transform(
md.Node element,
List<CustomMarkdownParser> parsers,
MarkdownListType listType,
) {
if (element is! md.Element) {
return [];
}
if (!_headingTags.contains(element.tag)) {
return [];
}
final level = _headingTags.indexOf(element.tag) + 1;
final deltaDecoder = DeltaMarkdownDecoder();
return [
headingNode(
level: level,
delta: deltaDecoder.convertNodes(element.children),
),
];
}
}

View file

@ -0,0 +1,31 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:markdown/markdown.dart' as md;
class MarkdownImageParserV2 extends CustomMarkdownParser {
const MarkdownImageParserV2();
@override
List<Node> transform(
md.Node element,
List<CustomMarkdownParser> parsers,
MarkdownListType listType,
) {
if (element is! md.Element) {
return [];
}
if (element.children?.length != 1 ||
element.children?.first is! md.Element) {
return [];
}
final ec = element.children?.first as md.Element;
if (ec.tag != 'img' || ec.attributes['src'] == null) {
return [];
}
return [
imageNode(url: ec.attributes['src']!),
];
}
}

View file

@ -0,0 +1,56 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:markdown/markdown.dart' as md;
class MarkdownOrderedListItemParserV2 extends CustomMarkdownParser {
const MarkdownOrderedListItemParserV2();
@override
List<Node> transform(
md.Node element,
List<CustomMarkdownParser> parsers,
MarkdownListType listType,
) {
if (element is! md.Element) {
return [];
}
if (element.tag != 'li' ||
element.attributes.isNotEmpty ||
listType != MarkdownListType.ordered) {
return [];
}
final List<md.Node> ec = [];
int sliceIndex = -1;
if (element.children != null) {
for (final child in element.children!.reversed) {
if (child is md.Element) {
ec.add(child);
} else {
break;
}
}
sliceIndex = element.children!.length - ec.length;
}
final deltaDecoder = DeltaMarkdownDecoder();
final deltaNodes = sliceIndex == -1
? element.children
: element.children!.slice(0, sliceIndex);
return [
numberedListNode(
delta: deltaDecoder.convertNodes(
deltaNodes,
),
children: parseElementChildren(
ec.reversed.toList(),
parsers,
listType: MarkdownListType.ordered,
),
),
];
}
}

View file

@ -0,0 +1,28 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:markdown/markdown.dart' as md;
class MarkdownOrderedListParserV2 extends CustomMarkdownParser {
const MarkdownOrderedListParserV2();
@override
List<Node> transform(
md.Node element,
List<CustomMarkdownParser> parsers,
MarkdownListType listType,
) {
if (element is! md.Element) {
return [];
}
if (element.tag != 'ol') {
return [];
}
// flatten the list
return parseElementChildren(
element.children,
parsers,
listType: MarkdownListType.ordered,
);
}
}

View file

@ -0,0 +1,37 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:markdown/markdown.dart' as md;
class MarkdownParagraphParserV2 extends CustomMarkdownParser {
const MarkdownParagraphParserV2();
@override
List<Node> transform(
md.Node element,
List<CustomMarkdownParser> parsers,
MarkdownListType listType,
) {
if (element is! md.Element) {
return [];
}
if (element.tag != 'p') {
return [];
}
// exclude the img tag
final ec = element.children;
if (ec != null && ec.length == 1 && ec.first is md.Element) {
final e = ec.first as md.Element;
if (e.tag == 'img') {
return [];
}
}
final deltaDecoder = DeltaMarkdownDecoder();
return [
paragraphNode(
delta: deltaDecoder.convertNodes(element.children),
),
];
}
}

View file

@ -0,0 +1,37 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:markdown/markdown.dart' as md;
enum MarkdownListType {
ordered,
unordered,
unknown,
}
List<Node> parseElementChildren(
List<md.Node>? elementChildren,
List<CustomMarkdownParser> parsers, {
MarkdownListType listType = MarkdownListType.unknown,
}) {
final List<Node> children = [];
if (elementChildren == null || elementChildren.isEmpty) {
return children;
}
for (final child in elementChildren) {
for (final parser in parsers) {
final nodes = parser.transform(
child,
parsers,
listType,
);
if (nodes.isNotEmpty) {
children.addAll(nodes);
break;
}
}
}
return children;
}

View file

@ -0,0 +1,81 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/block_component/table_block_component/table_node.dart';
import 'package:markdown/markdown.dart' as md;
class MarkdownTableListParserV2 extends CustomMarkdownParser {
const MarkdownTableListParserV2();
@override
List<Node> transform(
md.Node element,
List<CustomMarkdownParser> parsers,
MarkdownListType listType,
) {
if (element is! md.Element) {
return [];
}
if (element.tag != 'table') {
return [];
}
final ec = element.children;
if (ec == null || ec.isEmpty) {
return [];
}
final List<List<Node>> cells = [];
final th = ec
.whereType<md.Element>()
.where((e) => e.tag == 'thead')
.firstOrNull
?.children
?.whereType<md.Element>()
.where((e) => e.tag == 'tr')
.expand((e) => e.children?.whereType<md.Element>().toList() ?? [])
.where((e) => e.tag == 'th')
.toList();
final td = ec
.whereType<md.Element>()
.where((e) => e.tag == 'tbody')
.firstOrNull
?.children
?.whereType<md.Element>()
.where((e) => e.tag == 'tr')
.expand((e) => e.children?.whereType<md.Element>().toList() ?? [])
.where((e) => e.tag == 'td')
.toList();
if (th == null || td == null || th.isEmpty || td.isEmpty) {
return [];
}
for (var i = 0; i < th.length; i++) {
final List<Node> row = [];
row.add(
paragraphNode(
delta: DeltaMarkdownDecoder().convertNodes(th[i].children),
),
);
for (var j = i; j < td.length; j += th.length) {
row.add(
paragraphNode(
delta: DeltaMarkdownDecoder().convertNodes(td[j].children),
),
);
}
cells.add(row);
}
final tableNode = TableNode.fromList(cells);
return [
tableNode.node,
];
}
}

View file

@ -0,0 +1,59 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:markdown/markdown.dart' as md;
class MarkdownTodoListParserV2 extends CustomMarkdownParser {
const MarkdownTodoListParserV2();
@override
List<Node> transform(
md.Node element,
List<CustomMarkdownParser> parsers,
MarkdownListType listType,
) {
if (element is! md.Element) {
return [];
}
if (element.tag != 'li' ||
element.attributes['class'] != 'task-list-item') {
return [];
}
final ec = element.children;
if (ec == null || ec.isEmpty) {
return [];
}
// for the task list item, the first two children are the input and the label
// the rest are its children
final checked = (ec[0] as md.Element).attributes['checked'] == 'true';
md.Element? last;
// if the last child is not a list or paragraph, ignore it
if (ec.last is md.Element) {
final lastElement = ec.last as md.Element;
if (lastElement.tag == 'ul' || lastElement.tag == 'ol') {
last = lastElement;
}
}
final deltaDecoder = DeltaMarkdownDecoder();
return [
todoListNode(
checked: checked,
delta: deltaDecoder.convertNodes(
element.children?.sublist(
1,
ec.length - (last != null ? 1 : 0),
),
),
children: last == null
? null
: parseElementChildren(
[last],
parsers,
listType: MarkdownListType.unknown,
),
),
];
}
}

View file

@ -0,0 +1,56 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:markdown/markdown.dart' as md;
class MarkdownUnorderedListItemParserV2 extends CustomMarkdownParser {
const MarkdownUnorderedListItemParserV2();
@override
List<Node> transform(
md.Node element,
List<CustomMarkdownParser> parsers,
MarkdownListType listType,
) {
if (element is! md.Element) {
return [];
}
if (element.tag != 'li' ||
element.attributes.isNotEmpty ||
listType != MarkdownListType.unordered) {
return [];
}
final List<md.Node> ec = [];
int sliceIndex = -1;
if (element.children != null) {
for (final child in element.children!.reversed) {
if (child is md.Element) {
ec.add(child);
} else {
break;
}
}
sliceIndex = element.children!.length - ec.length;
}
final deltaDecoder = DeltaMarkdownDecoder();
final deltaNodes = sliceIndex == -1
? element.children
: element.children!.slice(0, sliceIndex);
return [
bulletedListNode(
delta: deltaDecoder.convertNodes(
deltaNodes,
),
children: parseElementChildren(
ec.reversed.toList(),
parsers,
listType: MarkdownListType.unknown,
),
),
];
}
}

View file

@ -0,0 +1,28 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:markdown/markdown.dart' as md;
class MarkdownUnorderedListParserV2 extends CustomMarkdownParser {
const MarkdownUnorderedListParserV2();
@override
List<Node> transform(
md.Node element,
List<CustomMarkdownParser> parsers,
MarkdownListType listType,
) {
if (element is! md.Element) {
return [];
}
if (element.tag != 'ul') {
return [];
}
// flatten the list
return parseElementChildren(
element.children,
parsers,
listType: MarkdownListType.unordered,
);
}
}

Some files were not shown because too many files have changed in this diff Show more