fix: convert html to document (#152)

* fix:html to node converter fixes

* html tests added

* html decoder optimization

* feat: refactor code

* mobile platforms added

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
This commit is contained in:
Ali Hassan 2023-06-01 12:20:42 +05:00 • committed by GitHub
commit a77b25aa50
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 751 additions and 47 deletions

View file

@ -30,9 +30,15 @@ class DocumentHTMLDecoder extends Converter<String, Document> {
if (domNode is dom.Element) { if (domNode is dom.Element) {
final localName = domNode.localName; final localName = domNode.localName;
if (HTMLTags.formattingElements.contains(localName)) { if (HTMLTags.formattingElements.contains(localName)) {
_parseFormattingElement(delta, domNode); final attributes = _parserFormattingElementAttributes(domNode);
delta.insert(domNode.text, attributes: attributes);
} else if (HTMLTags.specialElements.contains(localName)) { } else if (HTMLTags.specialElements.contains(localName)) {
nodes.addAll(_parseSpecialElements(domNode)); nodes.addAll(
_parseSpecialElements(
domNode,
type: ParagraphBlockKeys.type,
),
);
} }
} else if (domNode is dom.Text) { } else if (domNode is dom.Text) {
delta.insert(domNode.text); delta.insert(domNode.text);
@ -46,7 +52,10 @@ class DocumentHTMLDecoder extends Converter<String, Document> {
return nodes; return nodes;
} }
Iterable<Node> _parseSpecialElements(dom.Element element) { Iterable<Node> _parseSpecialElements(
dom.Element element, {
required String type,
}) {
final localName = element.localName; final localName = element.localName;
switch (localName) { switch (localName) {
case HTMLTags.h1: case HTMLTags.h1:
@ -60,22 +69,21 @@ class DocumentHTMLDecoder extends Converter<String, Document> {
case HTMLTags.orderedList: case HTMLTags.orderedList:
return _parseOrderListElement(element); return _parseOrderListElement(element);
case HTMLTags.list: case HTMLTags.list:
return _parseListElement(element); return _parseListElement(element, type: type);
case HTMLTags.paragraph: case HTMLTags.paragraph:
return [_parseParagraphElement(element)]; return [_parseParagraphElement(element)];
case HTMLTags.blockQuote: case HTMLTags.blockQuote:
return [_parseBlockQuoteElement(element)]; return [_parseBlockQuoteElement(element)];
case HTMLTags.image: case HTMLTags.image:
break; return [_parseImageElement(element)];
default: default:
return [paragraphNode(text: element.text)]; return [paragraphNode(text: element.text)];
} }
return [];
} }
void _parseFormattingElement(Delta delta, dom.Element element) { Attributes _parserFormattingElementAttributes(dom.Element element) {
final localName = element.localName; final localName = element.localName;
Attributes? attributes; Attributes attributes = {};
switch (localName) { switch (localName) {
case HTMLTags.bold || HTMLTags.strong: case HTMLTags.bold || HTMLTags.strong:
attributes = {FlowyRichTextKeys.bold: true}; attributes = {FlowyRichTextKeys.bold: true};
@ -92,81 +100,93 @@ class DocumentHTMLDecoder extends Converter<String, Document> {
case HTMLTags.code: case HTMLTags.code:
attributes = {FlowyRichTextKeys.code: true}; attributes = {FlowyRichTextKeys.code: true};
case HTMLTags.span: case HTMLTags.span:
attributes = _getDeltaAttributesFromHTMLAttributes( final deltaAttributes = _getDeltaAttributesFromHTMLAttributes(
element.attributes, element.attributes,
); ) ??
{};
attributes.addAll(deltaAttributes);
break; break;
case HTMLTags.anchor: case HTMLTags.anchor:
final href = element.attributes['href']; final href = element.attributes['href'];
if (href != null) { if (href != null) {
attributes = { attributes = {FlowyRichTextKeys.href: href};
FlowyRichTextKeys.href: href,
};
} }
break; break;
default: default:
assert(false, 'Unknown formatting element: $element'); assert(false, 'Unknown formatting element: $element');
break; break;
} }
delta.insert(element.text, attributes: attributes); for (final child in element.children) {
attributes.addAll(_parserFormattingElementAttributes(child));
}
return attributes;
} }
Node _parseHeadingElement( Node _parseHeadingElement(
dom.Element element, { dom.Element element, {
required int level, required int level,
}) => }) {
headingNode( final delta = _parseDeltaElement(element);
level: level, return headingNode(
delta: Delta()..insert(element.text), level: level,
); delta: delta,
);
}
Node _parseBlockQuoteElement(dom.Element element) => quoteNode( Node _parseBlockQuoteElement(dom.Element element) => quoteNode(
delta: Delta()..insert(element.text), delta: Delta()..insert(element.text),
); );
Iterable<Node> _parseUnOrderListElement(dom.Element element) { Iterable<Node> _parseUnOrderListElement(dom.Element element) {
final children = element.nodes.toList().whereType<dom.Element>(); final result = <Node>[];
return children.map( for (final child in element.children) {
(e) => bulletedListNode( result.addAll(_parseListElement(child, type: NumberedListBlockKeys.type));
delta: Delta() }
..insert( return result;
e.text,
),
),
);
} }
Iterable<Node> _parseOrderListElement(dom.Element element) { Iterable<Node> _parseOrderListElement(dom.Element element) {
final children = element.nodes.toList().whereType<dom.Element>(); final result = <Node>[];
return children.map( for (final child in element.children) {
(e) => numberedListNode( result.addAll(_parseListElement(child, type: NumberedListBlockKeys.type));
delta: Delta() }
..insert( return result;
e.text,
),
),
);
} }
Iterable<Node> _parseListElement(dom.Element element) { Iterable<Node> _parseListElement(
final children = element.nodes.toList().whereType<dom.Element>(); dom.Element element, {
return children required String type,
.map((e) => _parseSpecialElements(e)) }) {
.expand((element) => element); final delta = _parseDeltaElement(element);
return [
Node(type: type, attributes: {ParagraphBlockKeys.delta: delta.toJson()})
];
} }
Node _parseParagraphElement(dom.Element element) { Node _parseParagraphElement(dom.Element element) {
// TODO: parse image and checkbox. final delta = _parseDeltaElement(element);
return paragraphNode(delta: delta);
}
Node _parseImageElement(dom.Element element) {
final src = element.attributes['src'] ?? '';
return imageNode(
url: src,
);
}
Delta _parseDeltaElement(dom.Element element) {
final delta = Delta(); final delta = Delta();
final children = element.nodes.toList(); final children = element.nodes.toList();
for (final child in children) { for (final child in children) {
if (child is dom.Element) { if (child is dom.Element) {
_parseFormattingElement(delta, child); final attributes = _parserFormattingElementAttributes(child);
delta.insert(child.text, attributes: attributes);
} else { } else {
delta.insert(child.text ?? ''); delta.insert(child.text ?? '');
} }
} }
return paragraphNode(delta: delta); return delta;
} }
Attributes? _getDeltaAttributesFromHTMLAttributes( Attributes? _getDeltaAttributesFromHTMLAttributes(

View file

@ -4,10 +4,12 @@ version: 1.0.0-dev.0
homepage: https://github.com/AppFlowy-IO/appflowy-editor homepage: https://github.com/AppFlowy-IO/appflowy-editor
platforms: platforms:
android:
ios:
linux: linux:
macos: macos:
windows:
web: web:
windows:
environment: environment:
sdk: ">=3.0.0 <4.0.0" sdk: ">=3.0.0 <4.0.0"

View file

@ -0,0 +1,344 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
void main() async {
group('document_html_decoder_test.dart', () {
const example = {
'document': {
'type': 'page',
'children': [
{
'type': 'heading',
'data': {
'level': 1,
'delta': [
{'insert': 'AppFlowyEditor'}
]
}
},
{
'type': 'heading',
'data': {
'level': 2,
'delta': [
{'insert': '👋 '},
{
'insert': 'Welcome to',
'attributes': {'bold': true}
},
{'insert': ' '},
{
'insert': 'AppFlowy Editor',
'attributes': {
'bold': true,
'italic': true,
'href': 'appflowy.io'
}
}
]
}
},
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'AppFlowy Editor is a '},
{
'insert': 'highly customizable',
'attributes': {'bold': true}
},
{'insert': ' '},
{
'insert': 'rich-text editor',
'attributes': {'italic': true}
}
]
}
},
{
'type': 'paragraph',
'data': {
'delta': [
{
'insert': 'Here',
'attributes': {'underline': true}
},
{'insert': ' is an example '},
{
'insert': 'your',
'attributes': {'strikethrough': true}
},
{'insert': ' you can give a try'}
]
}
},
{
'type': 'heading',
'data': {
'level': 3,
'delta': [
{'insert': 'Features!'}
]
}
},
{
'type': 'numbered_list',
'data': {
'delta': [
{'insert': '[x] Customizable'}
]
}
},
{
'type': 'numbered_list',
'data': {
'delta': [
{'insert': '[x] Test-covered'}
]
}
},
{
'type': 'numbered_list',
'data': {
'delta': [
{'insert': '[ ] more to come!'}
]
}
},
{
'type': 'numbered_list',
'data': {
'delta': [
{'insert': 'First item'}
]
}
},
{
'type': 'numbered_list',
'data': {
'delta': [
{'insert': 'Second item'}
]
}
},
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'List element'}
]
}
},
{
'type': 'quote',
'data': {
'delta': [
{
'insert': '\n'
' This is a quote!\n'
''
}
]
}
},
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'You can also use '},
{
'insert': 'AppFlowy Editor',
'attributes': {'bold': true, 'italic': true}
},
{'insert': ' as a component to build your own app.'}
]
}
},
{
'type': 'heading',
'data': {
'level': 3,
'delta': [
{'insert': 'Awesome features'}
]
}
},
{
'type': 'paragraph',
'data': {
'delta': [
{
'insert':
'If you have questions or feedback, please submit an issue on Github or join the community along with 1000+ builders!'
}
]
}
},
{
'type': 'paragraph',
'data': {
'delta': [
{
'insert': '\n'
'\n'
' \n'
'\n'
'\n'
'\n'
'\n'
'\n'
''
},
{
'insert': 'Span element',
'attributes': {'bold': true, 'italic': true}
},
{
'insert': '\n'
'\n'
''
},
{
'insert': 'Span element two',
'attributes': {'underline': true}
},
{
'insert': '\n'
'\n'
''
},
{
'insert': 'Span element three',
'attributes': {'bold': true, 'strikethrough': true}
},
{
'insert': '\n'
'\n'
''
},
{
'insert': 'This is an anchor tag!',
'attributes': {'href': 'https://appflowy.io'}
},
{
'insert': '\n'
'\n'
'\n'
'\n'
'\n'
'\n'
'\n'
'\n'
'\n'
'\n'
'\n'
'\n'
'\n'
'\n'
''
},
{
'insert': '\n'
' Code block\n'
'',
'attributes': {'code': true}
},
{
'insert': '\n'
'\n'
''
},
{
'insert': 'Italic one',
'attributes': {'italic': true}
},
{'insert': ' '},
{
'insert': 'Italic two',
'attributes': {'italic': true}
},
{
'insert': '\n'
'\n'
''
},
{
'insert': 'Bold tag',
'attributes': {'bold': true}
},
{
'insert': '\n'
'\n'
'\n'
'\n'
'\n'
''
}
]
}
}
]
}
};
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
test('parser document', () async {
const rawHTML = """<h1>AppFlowyEditor</h1>
<h2>👋 <strong>Welcome to</strong> <strong><em><a href="appflowy.io">AppFlowy Editor</a></em></strong></h2>
<p>AppFlowy Editor is a <strong>highly customizable</strong> <em>rich-text editor</em></p>
<hr />
<p><u>Here</u> is an example <del>your</del> you can give a try</p>
<span style="font-weight: bold;background-color: #cccccc;font-style: italic;">Span element</span>
<span style="font-weight: medium;text-decoration: underline;">Span element two</span>
<span style="font-weight: 900;text-decoration: line-through;">Span element three</span>
<a href="https://appflowy.io">This is an anchor tag!</a>
<img src="https://images.squarespace-cdn.com/content/v1/617f6f16b877c06711e87373/c3f23723-37f4-44d7-9c5d-6e2a53064ae7/Asset+10.png?format=1500w" />
<h3>Features!</h3>
<ul>
<li>[x] Customizable</li>
<li>[x] Test-covered</li>
<li>[ ] more to come!</li>
</ul>
<ol>
<li>First item</li>
<li>Second item</li>
</ol>
<li>List element</li>
<blockquote>
<p>This is a quote!</p>
</blockquote>
<code>
Code block
</code>
<em>Italic one</em> <i>Italic two</i>
<b>Bold tag</b>
<img src="http://appflowy.io" alt="AppFlowy">
<p>You can also use <strong><em>AppFlowy Editor</em></strong> as a component to build your own app.</p>
<h3>Awesome features</h3>
<p>If you have questions or feedback, please submit an issue on Github or join the community along with 1000+ builders!</p>
<hr>""";
final result = DocumentHTMLDecoder().convert(rawHTML);
expect(result.toJson(), example);
});
});
}

View file

@ -0,0 +1,338 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
void main() {
group('html_document_test.dart tests', () {
test('htmlToDocument()', () {
final document = htmlToDocument(rawHTML);
expect(document.toJson(), data);
});
});
}
const data = {
'document': {
'type': 'page',
'children': [
{
'type': 'heading',
'data': {
'level': 1,
'delta': [
{'insert': 'AppFlowyEditor'}
]
}
},
{
'type': 'heading',
'data': {
'level': 2,
'delta': [
{'insert': '👋 '},
{
'insert': 'Welcome to',
'attributes': {'bold': true}
},
{'insert': ' '},
{
'insert': 'AppFlowy Editor',
'attributes': {
'bold': true,
'italic': true,
'href': 'appflowy.io'
}
}
]
}
},
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'AppFlowy Editor is a '},
{
'insert': 'highly customizable',
'attributes': {'bold': true}
},
{'insert': ' '},
{
'insert': 'rich-text editor',
'attributes': {'italic': true}
}
]
}
},
{
'type': 'paragraph',
'data': {
'delta': [
{
'insert': 'Here',
'attributes': {'underline': true}
},
{'insert': ' is an example '},
{
'insert': 'your',
'attributes': {'strikethrough': true}
},
{'insert': ' you can give a try'}
]
}
},
{
'type': 'heading',
'data': {
'level': 3,
'delta': [
{'insert': 'Features!'}
]
}
},
{
'type': 'numbered_list',
'data': {
'delta': [
{'insert': '[x] Customizable'}
]
}
},
{
'type': 'numbered_list',
'data': {
'delta': [
{'insert': '[x] Test-covered'}
]
}
},
{
'type': 'numbered_list',
'data': {
'delta': [
{'insert': '[ ] more to come!'}
]
}
},
{
'type': 'numbered_list',
'data': {
'delta': [
{'insert': 'First item'}
]
}
},
{
'type': 'numbered_list',
'data': {
'delta': [
{'insert': 'Second item'}
]
}
},
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'List element'}
]
}
},
{
'type': 'quote',
'data': {
'delta': [
{
'insert': '\n'
' This is a quote!\n'
''
}
]
}
},
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'You can also use '},
{
'insert': 'AppFlowy Editor',
'attributes': {'bold': true, 'italic': true}
},
{'insert': ' as a component to build your own app.'}
]
}
},
{
'type': 'heading',
'data': {
'level': 3,
'delta': [
{'insert': 'Awesome features'}
]
}
},
{
'type': 'paragraph',
'data': {
'delta': [
{
'insert':
'If you have questions or feedback, please submit an issue on Github or join the community along with 1000+ builders!'
}
]
}
},
{
'type': 'paragraph',
'data': {
'delta': [
{
'insert': '\n'
'\n'
' \n'
'\n'
'\n'
'\n'
'\n'
'\n'
''
},
{
'insert': 'Span element',
'attributes': {'bold': true, 'italic': true}
},
{
'insert': '\n'
'\n'
''
},
{
'insert': 'Span element two',
'attributes': {'underline': true}
},
{
'insert': '\n'
'\n'
''
},
{
'insert': 'Span element three',
'attributes': {'bold': true, 'strikethrough': true}
},
{
'insert': '\n'
'\n'
''
},
{
'insert': 'This is an anchor tag!',
'attributes': {'href': 'https://appflowy.io'}
},
{
'insert': '\n'
'\n'
'\n'
'\n'
'\n'
'\n'
'\n'
'\n'
'\n'
'\n'
'\n'
'\n'
'\n'
'\n'
''
},
{
'insert': '\n'
' Code block\n'
'',
'attributes': {'code': true}
},
{
'insert': '\n'
'\n'
''
},
{
'insert': 'Italic one',
'attributes': {'italic': true}
},
{'insert': ' '},
{
'insert': 'Italic two',
'attributes': {'italic': true}
},
{
'insert': '\n'
'\n'
''
},
{
'insert': 'Bold tag',
'attributes': {'bold': true}
},
{
'insert': '\n'
'\n'
'\n'
'\n'
'\n'
''
}
]
}
}
]
}
};
const rawHTML = """<h1>AppFlowyEditor</h1>
<h2>👋 <strong>Welcome to</strong> <strong><em><a href="appflowy.io">AppFlowy Editor</a></em></strong></h2>
<p>AppFlowy Editor is a <strong>highly customizable</strong> <em>rich-text editor</em></p>
<hr />
<p><u>Here</u> is an example <del>your</del> you can give a try</p>
<span style="font-weight: bold;background-color: #cccccc;font-style: italic;">Span element</span>
<span style="font-weight: medium;text-decoration: underline;">Span element two</span>
<span style="font-weight: 900;text-decoration: line-through;">Span element three</span>
<a href="https://appflowy.io">This is an anchor tag!</a>
<img src="https://images.squarespace-cdn.com/content/v1/617f6f16b877c06711e87373/c3f23723-37f4-44d7-9c5d-6e2a53064ae7/Asset+10.png?format=1500w" />
<h3>Features!</h3>
<ul>
<li>[x] Customizable</li>
<li>[x] Test-covered</li>
<li>[ ] more to come!</li>
</ul>
<ol>
<li>First item</li>
<li>Second item</li>
</ol>
<li>List element</li>
<blockquote>
<p>This is a quote!</p>
</blockquote>
<code>
Code block
</code>
<em>Italic one</em> <i>Italic two</i>
<b>Bold tag</b>
<img src="http://appflowy.io" alt="AppFlowy">
<p>You can also use <strong><em>AppFlowy Editor</em></strong> as a component to build your own app.</p>
<h3>Awesome features</h3>
<p>If you have questions or feedback, please submit an issue on Github or join the community along with 1000+ builders!</p>
<hr>""";