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:
parent
e8ee051719
commit
5bf6c7acd0
36 changed files with 1413 additions and 1099 deletions
|
|
@ -12,6 +12,7 @@ import 'package:example/pages/editor.dart';
|
|||
import 'package:example/pages/editor_list.dart';
|
||||
import 'package:example/pages/fixed_toolbar_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:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
@ -165,6 +166,14 @@ class _HomePageState extends State<HomePage> {
|
|||
|
||||
// Theme Demo
|
||||
_buildSeparator(context, 'Showcases'),
|
||||
_buildListTile(context, 'Markdown Editor', () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const MarkdownEditor(),
|
||||
),
|
||||
);
|
||||
}),
|
||||
_buildListTile(context, 'Auto complete Editor', () {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
|
|
|||
51
example/lib/pages/markdown/markdown_code_block_parser.dart
Normal file
51
example/lib/pages/markdown/markdown_code_block_parser.dart
Normal 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),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
100
example/lib/pages/markdown_editor.dart
Normal file
100
example/lib/pages/markdown_editor.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ dependencies:
|
|||
sdk: flutter
|
||||
|
||||
appflowy_editor:
|
||||
path: ../
|
||||
|
||||
provider: ^6.0.3
|
||||
url_launcher: ^6.1.5
|
||||
path_provider: ^2.0.15
|
||||
|
|
@ -43,6 +43,12 @@ dependencies:
|
|||
highlight: ^0.7.0
|
||||
http: ^1.1.0
|
||||
show_fps: ^1.0.6
|
||||
appflowy_editor_plugins: ^0.0.6
|
||||
markdown:
|
||||
|
||||
dependency_overrides:
|
||||
appflowy_editor:
|
||||
path: ../
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,17 @@ class DeltaMarkdownDecoder extends Converter<String, 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
|
||||
void visitElementAfter(md.Element element) {
|
||||
_removeAttributeKey(element);
|
||||
|
|
@ -64,7 +75,11 @@ class DeltaMarkdownDecoder extends Converter<String, Delta>
|
|||
_attributes[BuiltInAttributeKey.underline] = true;
|
||||
} else {
|
||||
element.attributes.forEach((key, value) {
|
||||
_attributes[key] = jsonDecode(value);
|
||||
try {
|
||||
_attributes[key] = jsonDecode(value);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,307 +1,66 @@
|
|||
import 'dart:convert';
|
||||
|
||||
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/table_markdown_decoder.dart';
|
||||
import 'package:appflowy_editor/src/plugins/markdown/decoder/custom_syntaxes/underline_syntax.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
|
||||
class DocumentMarkdownDecoder extends Converter<String, Document> {
|
||||
DocumentMarkdownDecoder({
|
||||
this.customNodeParsers = const [],
|
||||
this.customInlineSyntaxes = const [],
|
||||
this.markdownElementParsers = const [],
|
||||
this.inlineSyntaxes = const [],
|
||||
});
|
||||
|
||||
final List<CustomNodeParser> customNodeParsers;
|
||||
final List<md.InlineSyntax> customInlineSyntaxes;
|
||||
final imageRegex = RegExp(r'^!\[[^\]]*\]\((.*?)\)');
|
||||
final assetRegex = RegExp(r'^\[[^\]]*\]\((.*?)\)');
|
||||
final htmlRegex = RegExp('^(http|https)://');
|
||||
final numberedListRegex = RegExp(r'^\d+\. ');
|
||||
final List<CustomMarkdownParser> markdownElementParsers;
|
||||
final List<md.InlineSyntax> inlineSyntaxes;
|
||||
|
||||
@override
|
||||
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();
|
||||
// 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) {
|
||||
i = tempLinePointer;
|
||||
document.insert(
|
||||
[i],
|
||||
[convertLineToNode(lines[i], customInlineSyntaxes)],
|
||||
);
|
||||
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;
|
||||
final nodes = mdNodes
|
||||
.map((mdNode) => _parseNode(mdNode))
|
||||
.toList()
|
||||
.whereNotNull()
|
||||
.toList()
|
||||
.flattened;
|
||||
|
||||
document.insert([i], [node]);
|
||||
i++;
|
||||
} 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++;
|
||||
}
|
||||
if (nodes.isNotEmpty) {
|
||||
document.insert([0], nodes);
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
bool isNestedList(String line1, String line2) {
|
||||
if ((line1.trimLeft().startsWith('- ') ||
|
||||
line1.trimLeft().startsWith('* ') ||
|
||||
numberedListRegex.hasMatch(line1.trimLeft())) &&
|
||||
(line2.trimLeft().startsWith('- ') ||
|
||||
line2.trimLeft().startsWith('* ') ||
|
||||
numberedListRegex.hasMatch(line2.trimLeft()))) {
|
||||
return getIndentLevel(line2) > getIndentLevel(line1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// handle node itself and its children
|
||||
List<Node> _parseNode(md.Node mdNode) {
|
||||
List<Node> nodes = [];
|
||||
|
||||
int getIndentLevel(String line) {
|
||||
int indent = 0;
|
||||
while (indent < line.length && line[indent] == ' ') {
|
||||
indent++;
|
||||
}
|
||||
return (indent / 2).floor();
|
||||
}
|
||||
for (final parser in markdownElementParsers) {
|
||||
nodes = parser.transform(
|
||||
mdNode,
|
||||
markdownElementParsers,
|
||||
MarkdownListType.unknown,
|
||||
);
|
||||
|
||||
Node convertLineToNode(
|
||||
String line,
|
||||
List<md.InlineSyntax> customInlineSyntaxes,
|
||||
) {
|
||||
final decoder = DeltaMarkdownDecoder(
|
||||
customInlineSyntaxes: customInlineSyntaxes,
|
||||
);
|
||||
for (final parser in customNodeParsers) {
|
||||
final node = parser.transform(line);
|
||||
if (node != null) {
|
||||
return node;
|
||||
if (nodes.isNotEmpty) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Heading Style
|
||||
if (line.startsWith('### ')) {
|
||||
return headingNode(
|
||||
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 (nodes.isEmpty) {
|
||||
Log.editor.debug(
|
||||
'empty result from node: $mdNode, text: ${mdNode.textContent}',
|
||||
);
|
||||
}
|
||||
|
||||
if (line.isNotEmpty) {
|
||||
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,
|
||||
},
|
||||
);
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import '../../../../../appflowy_editor.dart';
|
||||
|
||||
abstract class CustomNodeParser {
|
||||
Node? transform(String input);
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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']!),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue