feat: support paste html

This commit is contained in:
Lucas.Xu 2023-05-31 16:05:49 +08:00
commit 839bb94cba
11 changed files with 439 additions and 6 deletions

View file

@ -0,0 +1,49 @@
<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>

View file

@ -78,7 +78,6 @@
"insert": "Customizable"
}]
}
},
{
"type": "todo_list",

View file

@ -97,6 +97,18 @@ class _HomePageState extends State<HomePage> {
final jsonString = rootBundle.loadString('assets/example.json');
_loadEditor(context, jsonString);
}),
_buildListTile(context, 'With Example.html', () async {
final htmlString =
await rootBundle.loadString('assets/example.html');
final html = htmlToDocument(htmlString);
// final html = HTMLToNodesConverter(htmlString).toDocument();
final jsonString = Future<String>.value(
jsonEncode(
html.toJson(),
).toString(),
);
_loadEditor(context, jsonString);
}),
_buildListTile(context, 'With Empty Document', () {
final jsonString = Future<String>.value(
jsonEncode(

View file

@ -67,6 +67,7 @@ flutter:
# To add assets to your application, add an assets section, like this:
assets:
- assets/example.json
- assets/example.html
- assets/images/icon.png
# An image asset can refer to one or more resolution-specific "variants", see

View file

@ -61,3 +61,6 @@ export 'src/service/standard_block_components.dart';
export 'src/core/document/deprecated/node.dart';
export 'src/core/document/deprecated/document.dart';
export 'src/plugins/html/html_document_decoder.dart';
export 'src/plugins/html/html_document.dart';

View file

@ -41,10 +41,14 @@ CommandShortcutEventHandler _pasteCommandHandler = (editorState) {
// TODO: paste the rich text.
() async {
final data = await AppFlowyClipboard.getData();
if (data.html != null) {
// ...
}
if (data.text != null) {
final text = data.text;
final html = data.html;
if (html != null) {
final nodes = htmlToDocument(html).root.children;
final transaction = editorState.transaction
..insertNodes(selection!.end.path, nodes);
await editorState.apply(transaction);
} else if (text != null) {
handlePastePlainText(editorState, data.text!);
}
}();

View file

@ -8,6 +8,33 @@ extension ColorExtension on String {
buffer.write(hexString.replaceFirst('#', ''));
return Color(int.tryParse(buffer.toString(), radix: 16) ?? 0xFFFFFFFF);
}
Color? tryToColor() {
final reg = RegExp(r'rgba\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)');
final match = reg.firstMatch(this);
if (match == null) {
return null;
}
if (match.groupCount < 4) {
return null;
}
final redStr = match.group(1);
final greenStr = match.group(2);
final blueStr = match.group(3);
final alphaStr = match.group(4);
final red = redStr != null ? int.tryParse(redStr) : null;
final green = greenStr != null ? int.tryParse(greenStr) : null;
final blue = blueStr != null ? int.tryParse(blueStr) : null;
final alpha = alphaStr != null ? int.tryParse(alphaStr) : null;
if (red == null || green == null || blue == null || alpha == null) {
return null;
}
return Color.fromARGB(alpha, red, green, blue);
}
}
extension HexExtension on Color {

View file

@ -159,7 +159,7 @@ class HTMLToNodesConverter {
return node;
}
Node _handleDivider() => Node(type: 'divider');
Node _handleDivider() => paragraphNode(text: '---');
Map<String, String> _cssStringToMap(String? cssString) {
final result = <String, String>{};

View file

@ -0,0 +1,27 @@
library delta_markdown;
import 'dart:convert';
import 'package:appflowy_editor/src/core/document/document.dart';
import 'package:appflowy_editor/src/plugins/html/html_document_decoder.dart';
import 'package:appflowy_editor/src/plugins/html/html_document_encoder.dart';
/// Converts a html to [Document].
Document htmlToDocument(String html) {
return const AppFlowyEditorHTMLCodec().decode(html);
}
/// Converts a [Document] to html.
String documentToHTML(Document document) {
return const AppFlowyEditorHTMLCodec().encode(document);
}
class AppFlowyEditorHTMLCodec extends Codec<Document, String> {
const AppFlowyEditorHTMLCodec();
@override
Converter<String, Document> get decoder => DocumentHTMLDecoder();
@override
Converter<Document, String> get encoder => DocumentHTMLEncoder();
}

View file

@ -0,0 +1,298 @@
import 'dart:collection';
import 'dart:convert';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:html/parser.dart' show parse;
import 'package:html/dom.dart' as dom;
class DocumentHTMLDecoder extends Converter<String, Document> {
DocumentHTMLDecoder();
@override
Document convert(String input) {
final document = parse(input);
final body = document.body;
if (body == null) {
return Document.blank(withInitialText: false);
}
final nodes = _parseElement(body.nodes);
return Document.blank(withInitialText: false)
..insert(
[0],
nodes,
);
}
Iterable<Node> _parseElement(Iterable<dom.Node> domNodes) {
final delta = Delta();
final List<Node> nodes = [];
for (final domNode in domNodes) {
if (domNode is dom.Element) {
final localName = domNode.localName;
if (HTMLTags.formattingElements.contains(localName)) {
_parseFormattingElement(delta, domNode);
} else if (HTMLTags.specialElements.contains(localName)) {
nodes.addAll(_parseSpecialElements(domNode));
}
} else if (domNode is dom.Text) {
delta.insert(domNode.text);
} else {
assert(false, 'Unknown node type: $domNode');
}
}
if (delta.isNotEmpty) {
nodes.add(paragraphNode(delta: delta));
}
return nodes;
}
Iterable<Node> _parseSpecialElements(dom.Element element) {
final localName = element.localName;
switch (localName) {
case HTMLTags.h1:
return [_parseHeadingElement(element, level: 1)];
case HTMLTags.h2:
return [_parseHeadingElement(element, level: 2)];
case HTMLTags.h3:
return [_parseHeadingElement(element, level: 3)];
case HTMLTags.unorderedList:
return _parseUnOrderListElement(element);
case HTMLTags.orderedList:
return _parseOrderListElement(element);
case HTMLTags.list:
return _parseListElement(element);
case HTMLTags.paragraph:
return [_parseParagraphElement(element)];
case HTMLTags.blockQuote:
return [_parseBlockQuoteElement(element)];
case HTMLTags.image:
break;
default:
return [paragraphNode(text: element.text)];
}
return [];
}
void _parseFormattingElement(Delta delta, dom.Element element) {
final localName = element.localName;
Attributes? attributes;
switch (localName) {
case HTMLTags.bold || HTMLTags.strong:
attributes = {'bold': true};
break;
case HTMLTags.italic || HTMLTags.em:
attributes = {'italic': true};
break;
case HTMLTags.underline:
attributes = {'underline': true};
break;
case HTMLTags.del:
attributes = {'strikethrough': true};
break;
case HTMLTags.code:
attributes = {'code': true};
case HTMLTags.span:
attributes = _getDeltaAttributesFromHTMLAttributes(
element.attributes,
);
break;
case HTMLTags.anchor:
final href = element.attributes['href'];
if (href != null) {
attributes = {
'href': href,
};
}
break;
default:
assert(false, 'Unknown formatting element: $element');
break;
}
delta.insert(element.text, attributes: attributes);
}
Node _parseHeadingElement(
dom.Element element, {
required int level,
}) =>
headingNode(
level: level,
delta: Delta()..insert(element.text),
);
Node _parseBlockQuoteElement(dom.Element element) => quoteNode(
delta: Delta()..insert(element.text),
);
Iterable<Node> _parseUnOrderListElement(dom.Element element) {
final children = element.nodes.toList().whereType<dom.Element>();
return children.map(
(e) => bulletedListNode(
delta: Delta()
..insert(
e.text,
),
),
);
}
Iterable<Node> _parseOrderListElement(dom.Element element) {
final children = element.nodes.toList().whereType<dom.Element>();
return children.map(
(e) => numberedListNode(
delta: Delta()
..insert(
e.text,
),
),
);
}
Iterable<Node> _parseListElement(dom.Element element) {
final children = element.nodes.toList().whereType<dom.Element>();
return children
.map((e) => _parseSpecialElements(e))
.expand((element) => element);
}
Node _parseParagraphElement(dom.Element element) {
// TODO: parse image and checkbox.
final delta = Delta();
final children = element.nodes.toList();
for (final child in children) {
if (child is dom.Element) {
_parseFormattingElement(delta, child);
} else {
delta.insert(child.text ?? '');
}
}
return paragraphNode(delta: delta);
}
Attributes? _getDeltaAttributesFromHTMLAttributes(
LinkedHashMap<Object, String> htmlAttributes,
) {
final Attributes attributes = {};
final style = htmlAttributes['style'];
final css = _getCssFromString(style);
// font weight
final fontWeight = css['font-weight'];
if (fontWeight != null) {
if (fontWeight == 'bold') {
attributes['bold'] = true;
} else {
final weight = int.tryParse(fontWeight);
if (weight != null && weight >= 500) {
attributes['bold'] = true;
}
}
}
// decoration
final textDecoration = css['text-decoration'];
if (textDecoration != null) {
final decorations = textDecoration.split(' ');
for (final decoration in decorations) {
switch (decoration) {
case 'underline':
attributes['underline'] = true;
break;
case 'line-through':
attributes['strike'] = true;
break;
default:
break;
}
}
}
// background color
final backgroundColor = css['background-color'];
if (backgroundColor != null) {
final highlightColor = backgroundColor.tryToColor()?.toHex();
if (highlightColor != null) {
attributes['highlightColor'] = highlightColor;
}
}
// italic
final fontStyle = css['font-style'];
if (fontStyle == 'italic') {
attributes['italic'] = true;
}
return attributes.isEmpty ? null : attributes;
}
Map<String, String> _getCssFromString(String? cssString) {
final Map<String, String> result = {};
if (cssString == null) {
return result;
}
final entries = cssString.split(';');
for (final entry in entries) {
final tuples = entry.split(':');
if (tuples.length < 2) {
continue;
}
result[tuples[0].trim()] = tuples[1].trim();
}
return result;
}
}
class HTMLTags {
static const h1 = 'h1';
static const h2 = 'h2';
static const h3 = 'h3';
static const orderedList = 'ol';
static const unorderedList = 'ul';
static const list = 'li';
static const paragraph = 'p';
static const image = 'img';
static const anchor = 'a';
static const italic = 'i';
static const em = 'em';
static const bold = 'b';
static const underline = 'u';
static const del = 'del';
static const strong = 'strong';
static const span = 'span';
static const code = 'code';
static const blockQuote = 'blockquote';
static const div = 'div';
static const divider = 'hr';
static List<String> formattingElements = [
HTMLTags.anchor,
HTMLTags.italic,
HTMLTags.em,
HTMLTags.bold,
HTMLTags.underline,
HTMLTags.del,
HTMLTags.strong,
HTMLTags.span,
HTMLTags.code,
];
static List<String> specialElements = [
HTMLTags.h1,
HTMLTags.h2,
HTMLTags.h3,
HTMLTags.unorderedList,
HTMLTags.orderedList,
HTMLTags.list,
HTMLTags.paragraph,
HTMLTags.blockQuote,
];
static bool isTopLevel(String tag) {
return tag == h1 ||
tag == h2 ||
tag == h3 ||
tag == paragraph ||
tag == div ||
tag == blockQuote;
}
}

View file

@ -0,0 +1,13 @@
import 'dart:convert';
import 'package:appflowy_editor/src/core/document/document.dart';
class DocumentHTMLEncoder extends Converter<Document, String> {
DocumentHTMLEncoder();
@override
String convert(Document input) {
final buffer = StringBuffer();
return buffer.toString();
}
}