feat: support paste html
This commit is contained in:
parent
2bc87a99bd
commit
839bb94cba
11 changed files with 439 additions and 6 deletions
49
example/assets/example.html
Normal file
49
example/assets/example.html
Normal 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>
|
||||
|
||||
|
|
@ -78,7 +78,6 @@
|
|||
"insert": "Customizable"
|
||||
}]
|
||||
}
|
||||
|
||||
},
|
||||
{
|
||||
"type": "todo_list",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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!);
|
||||
}
|
||||
}();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>{};
|
||||
|
|
|
|||
27
lib/src/plugins/html/html_document.dart
Normal file
27
lib/src/plugins/html/html_document.dart
Normal 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();
|
||||
}
|
||||
298
lib/src/plugins/html/html_document_decoder.dart
Normal file
298
lib/src/plugins/html/html_document_decoder.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
13
lib/src/plugins/html/html_document_encoder.dart
Normal file
13
lib/src/plugins/html/html_document_encoder.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue