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"
|
"insert": "Customizable"
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "todo_list",
|
"type": "todo_list",
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,18 @@ class _HomePageState extends State<HomePage> {
|
||||||
final jsonString = rootBundle.loadString('assets/example.json');
|
final jsonString = rootBundle.loadString('assets/example.json');
|
||||||
_loadEditor(context, jsonString);
|
_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', () {
|
_buildListTile(context, 'With Empty Document', () {
|
||||||
final jsonString = Future<String>.value(
|
final jsonString = Future<String>.value(
|
||||||
jsonEncode(
|
jsonEncode(
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ flutter:
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
assets:
|
assets:
|
||||||
- assets/example.json
|
- assets/example.json
|
||||||
|
- assets/example.html
|
||||||
- assets/images/icon.png
|
- assets/images/icon.png
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# 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/node.dart';
|
||||||
export 'src/core/document/deprecated/document.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.
|
// TODO: paste the rich text.
|
||||||
() async {
|
() async {
|
||||||
final data = await AppFlowyClipboard.getData();
|
final data = await AppFlowyClipboard.getData();
|
||||||
if (data.html != null) {
|
final text = data.text;
|
||||||
// ...
|
final html = data.html;
|
||||||
}
|
if (html != null) {
|
||||||
if (data.text != 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!);
|
handlePastePlainText(editorState, data.text!);
|
||||||
}
|
}
|
||||||
}();
|
}();
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,33 @@ extension ColorExtension on String {
|
||||||
buffer.write(hexString.replaceFirst('#', ''));
|
buffer.write(hexString.replaceFirst('#', ''));
|
||||||
return Color(int.tryParse(buffer.toString(), radix: 16) ?? 0xFFFFFFFF);
|
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 {
|
extension HexExtension on Color {
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ class HTMLToNodesConverter {
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
Node _handleDivider() => Node(type: 'divider');
|
Node _handleDivider() => paragraphNode(text: '---');
|
||||||
|
|
||||||
Map<String, String> _cssStringToMap(String? cssString) {
|
Map<String, String> _cssStringToMap(String? cssString) {
|
||||||
final result = <String, String>{};
|
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