diff --git a/frontend/app_flowy/packages/flowy_editor/example/assets/example.json b/frontend/app_flowy/packages/flowy_editor/example/assets/example.json index fe74b22dad..e6357d93f9 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/example.json +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/example.json @@ -73,7 +73,7 @@ }, { "insert": " / ", - "attributes": { "highlightColor": "0xFFFFFF00" } + "attributes": { "backgroundColor": "0xFFFFFF00" } }, { "insert": "to see all the types of content you can add - entity, headers, videos, sub pages, etc." diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/infra/html_converter.dart index d05f768895..eb4eb0f653 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/infra/html_converter.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/infra/html_converter.dart @@ -3,7 +3,7 @@ import 'dart:collection'; import 'package:flowy_editor/src/document/attributes.dart'; import 'package:flowy_editor/src/document/node.dart'; import 'package:flowy_editor/src/document/text_delta.dart'; -import 'package:flutter/foundation.dart'; +import 'package:flowy_editor/src/render/rich_text/rich_text_style.dart'; import 'package:flutter/material.dart'; import 'package:html/parser.dart' show parse; import 'package:html/dom.dart' as html; @@ -11,6 +11,7 @@ import 'package:html/dom.dart' as html; const String tagH1 = "h1"; const String tagH2 = "h2"; const String tagH3 = "h3"; +const String tagOrderedList = "ol"; const String tagUnorderedList = "ul"; const String tagList = "li"; const String tagParagraph = "p"; @@ -21,23 +22,33 @@ const String tagStrong = "strong"; const String tagSpan = "span"; const String tagCode = "code"; -class HTMLConverter { +extension on Color { + String toRgbaString() { + return 'rgba($red, $green, $blue, $alpha)'; + } +} + +/// Converting the HTML to nodes +class HTMLToNodesConverter { final html.Document _document; + + /// This flag is used for parsing HTML pasting from Google Docs + /// Google docs wraps the the content inside the `` tag. It's strange. + /// + /// If a `` element is parsing in the

, we regard it as as text spans. + /// Otherwise, it's parsed as a container. bool _inParagraph = false; - HTMLConverter(String htmlString) : _document = parse(htmlString); + HTMLToNodesConverter(String htmlString) : _document = parse(htmlString); List toNodes() { - final result = []; - final childNodes = _document.body?.nodes.toList() ?? []; - _handleContainer(result, childNodes); - - return result; + return _handleContainer(childNodes); } - _handleContainer(List nodes, List childNodes) { + List _handleContainer(List childNodes) { final delta = Delta(); + final result = []; for (final child in childNodes) { if (child is html.Element) { if (child.localName == tagAnchor || @@ -46,83 +57,139 @@ class HTMLConverter { child.localName == tagStrong) { _handleRichTextElement(delta, child); } else if (child.localName == tagBold) { - // Google docs wraps the the content inside the tag. + // Google docs wraps the the content inside the `` tag. // It's strange if (!_inParagraph) { - _handleBTag(nodes, child); + result.addAll(_handleBTag(child)); } else { - _handleRichText(nodes, child); + result.add(_handleRichText(child)); } } else { - _handleElement(nodes, child); + result.addAll(_handleElement(child)); } } else { delta.insert(child.text ?? ""); } } if (delta.operations.isNotEmpty) { - nodes.add(TextNode(type: "text", delta: delta)); + result.add(TextNode(type: "text", delta: delta)); } + return result; } - _handleBTag(List nodes, html.Element element) { + List _handleBTag(html.Element element) { final childNodes = element.nodes; - _handleContainer(nodes, childNodes); + return _handleContainer(childNodes); } - _handleElement(List nodes, html.Element element, + List _handleElement(html.Element element, [Map? attributes]) { if (element.localName == tagH1) { - _handleHeadingElement(nodes, element, tagH1); + return [_handleHeadingElement(element, tagH1)]; } else if (element.localName == tagH2) { - _handleHeadingElement(nodes, element, tagH2); + return [_handleHeadingElement(element, tagH2)]; } else if (element.localName == tagH3) { - _handleHeadingElement(nodes, element, tagH3); + return [_handleHeadingElement(element, tagH3)]; } else if (element.localName == tagUnorderedList) { - _handleUnorderedList(nodes, element); + return _handleUnorderedList(element); + } else if (element.localName == tagOrderedList) { + return _handleOrderedList(element); } else if (element.localName == tagList) { - _handleListElement(nodes, element); + return _handleListElement(element); } else if (element.localName == tagParagraph) { - _handleParagraph(nodes, element, attributes); + return [_handleParagraph(element, attributes)]; } else { final delta = Delta(); delta.insert(element.text); if (delta.operations.isNotEmpty) { - nodes.add(TextNode(type: "text", delta: delta)); + return [TextNode(type: "text", delta: delta)]; } } + return []; } - _handleParagraph(List nodes, html.Element element, + Node _handleParagraph(html.Element element, [Map? attributes]) { _inParagraph = true; - _handleRichText(nodes, element, attributes); + final node = _handleRichText(element, attributes); _inParagraph = false; + return node; + } + + Map _cssStringToMap(String? cssString) { + final 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]] = tuples[1]; + } + + return result; } Attributes? _getDeltaAttributesFromHtmlAttributes( LinkedHashMap htmlAttributes) { final attrs = {}; final styleString = htmlAttributes["style"]; - if (styleString != null) { - final entries = styleString.split(";"); - for (final entry in entries) { - final tuples = entry.split(":"); - if (tuples.length < 2) { - continue; - } - if (tuples[0] == "font-weight") { - int? weight = int.tryParse(tuples[1]); - if (weight != null && weight > 500) { - attrs["bold"] = true; - } - } + final cssMap = _cssStringToMap(styleString); + + final fontWeightStr = cssMap["font-weight"]; + if (fontWeightStr != null) { + int? weight = int.tryParse(fontWeightStr); + if (weight != null && weight > 500) { + attrs["bold"] = true; } } + final backgroundColorStr = cssMap["background-color"]; + final backgroundColor = _tryParseCssColorString(backgroundColorStr); + if (backgroundColor != null) { + attrs[StyleKey.backgroundColor] = + '0x${backgroundColor.value.toRadixString(16)}'; + } + return attrs.isEmpty ? null : attrs; } + /// Try to parse the `rgba(red, greed, blue, alpha)` + /// from the string. + Color? _tryParseCssColorString(String? colorString) { + if (colorString == null) { + return null; + } + final reg = RegExp(r'rgba\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)'); + final match = reg.firstMatch(colorString); + 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); + } + _handleRichTextElement(Delta delta, html.Element element) { if (element.localName == tagSpan) { delta.insert(element.text, @@ -141,15 +208,27 @@ class HTMLConverter { } } - _handleRichText(List nodes, html.Element element, + /// A container contains a will + /// be regarded as a checkbox block. + /// + /// A container contains a will be regarded as a image block + Node _handleRichText(html.Element element, [Map? attributes]) { final image = element.querySelector(tagImage); if (image != null) { - _handleImage(nodes, image); - return; + final imageNode = _handleImage(image); + return imageNode; + } + final testInput = element.querySelector("input"); + bool checked = false; + final isCheckbox = + testInput != null && testInput.attributes["type"] == "checkbox"; + if (isCheckbox) { + checked = testInput.attributes.containsKey("checked") && + testInput.attributes["checked"] != "false"; } - var delta = Delta(); + final delta = Delta(); for (final child in element.nodes.toList()) { if (child is html.Element) { @@ -159,100 +238,258 @@ class HTMLConverter { } } - if (delta.operations.isNotEmpty) { - nodes.add(TextNode(type: "text", delta: delta, attributes: attributes)); + final textNode = + TextNode(type: "text", delta: delta, attributes: attributes); + if (isCheckbox) { + textNode.attributes["subtype"] = StyleKey.checkbox; + textNode.attributes["checkbox"] = checked; } + return textNode; } - _handleImage(List nodes, html.Element element) { + Node _handleImage(html.Element element) { final src = element.attributes["src"]; final attributes = {}; if (src != null) { attributes["image_src"] = src; } - debugPrint("insert image: $src"); - nodes.add( - Node(type: "image", attributes: attributes, children: LinkedList())); + return Node(type: "image", attributes: attributes, children: LinkedList()); } - _handleUnorderedList(List nodes, html.Element element) { + List _handleUnorderedList(html.Element element) { + final result = []; element.children.forEach((child) { - _handleListElement(nodes, child); + result.addAll( + _handleListElement(child, {"subtype": StyleKey.bulletedList})); }); + return result; } - _handleHeadingElement( - List nodes, + List _handleOrderedList(html.Element element) { + final result = []; + for (var i = 0; i < element.children.length; i++) { + final child = element.children[i]; + result.addAll(_handleListElement( + child, {"subtype": StyleKey.numberList, "number": i + 1})); + } + return result; + } + + Node _handleHeadingElement( html.Element element, String headingStyle, ) { final delta = Delta(); delta.insert(element.text); - if (delta.operations.isNotEmpty) { - nodes.add(TextNode( - type: "text", - attributes: {"subtype": "heading", "heading": headingStyle}, - delta: delta)); - } + return TextNode( + type: "text", + attributes: {"subtype": "heading", "heading": headingStyle}, + delta: delta); } - _handleListElement(List nodes, html.Element element) { + List _handleListElement(html.Element element, + [Map? attributes]) { + final result = []; final childNodes = element.nodes.toList(); for (final child in childNodes) { if (child is html.Element) { - _handleElement(nodes, child, {"subtype": "bulleted-list"}); + result.addAll(_handleElement(child, attributes)); } } + return result; } } -html.Element deltaToHtml(Delta delta, [String? subType]) { - final childNodes = []; - String tagName = tagParagraph; +/// [NodesToHTMLConverter] is used to convert the nodes to HTML. +/// Can be used to copy & paste, exporting the document. +class NodesToHTMLConverter { + final List nodes; + final int? startOffset; + final int? endOffset; + final List _result = []; - if (subType == "bulleted-list") { - tagName = tagList; - } + /// According to the W3C specs. The bullet list should be wrapped as + /// + ///

+ /// + /// This container is used to save the list elements temporarily. + html.Element? _stashListContainer; - for (final op in delta.operations) { - if (op is TextInsert) { - final attributes = op.attributes; - if (attributes != null && attributes["bold"] == true) { - final strong = html.Element.tag("strong"); - strong.append(html.Text(op.content)); - childNodes.add(strong); - } else { - childNodes.add(html.Text(op.content)); + NodesToHTMLConverter( + {required this.nodes, this.startOffset, this.endOffset}) { + if (nodes.isEmpty) { + return; + } else if (nodes.length == 1) { + final first = nodes.first; + if (first is TextNode) { + nodes[0] = first.copyWith( + delta: first.delta.slice(startOffset ?? 0, endOffset)); + } + } else { + final first = nodes.first; + final last = nodes.last; + if (first is TextNode) { + nodes[0] = first.copyWith(delta: first.delta.slice(startOffset ?? 0)); + } + if (last is TextNode) { + nodes[nodes.length - 1] = + last.copyWith(delta: last.delta.slice(0, endOffset)); } } } - if (tagName != tagParagraph) { - final p = html.Element.tag(tagParagraph); - for (final node in childNodes) { - p.append(node); + List toHTMLNodes() { + for (final node in nodes) { + if (node.type == "text") { + final textNode = node as TextNode; + if (node == nodes.first) { + _addTextNode(textNode); + } else if (node == nodes.last) { + _addTextNode(textNode, end: endOffset); + } else { + _addTextNode(textNode); + } + } + // TODO: handle image and other blocks } - final result = html.Element.tag("li"); - result.append(p); - return result; - } else { - final p = html.Element.tag(tagName); - for (final node in childNodes) { - p.append(node); + if (_stashListContainer != null) { + _result.add(_stashListContainer!); + _stashListContainer = null; + } + return _result; + } + + _addTextNode(TextNode textNode, {int? end}) { + _addElement(textNode, _textNodeToHtml(textNode, end: end)); + } + + _addElement(TextNode textNode, html.Element element) { + if (element.localName == tagList) { + final isNumbered = textNode.attributes["subtype"] == StyleKey.numberList; + _stashListContainer ??= + html.Element.tag(isNumbered ? tagOrderedList : tagUnorderedList); + _stashListContainer?.append(element); + } else { + if (_stashListContainer != null) { + _result.add(_stashListContainer!); + _stashListContainer = null; + } + _result.add(element); + } + } + + String toHTMLString() { + final elements = toHTMLNodes(); + final copyString = elements.fold( + "", ((previousValue, element) => previousValue + stringify(element))); + return copyString; + } + + html.Element _textNodeToHtml(TextNode textNode, {int? end}) { + String? subType = textNode.attributes["subtype"]; + return _deltaToHtml(textNode.delta, + subType: subType, + end: end, + checked: textNode.attributes["checkbox"] == true); + } + + String _attributesToCssStyle(Map attributes) { + final cssMap = {}; + if (attributes[StyleKey.backgroundColor] != null) { + final color = Color( + int.parse(attributes[StyleKey.backgroundColor]), + ); + cssMap["background-color"] = color.toRgbaString(); + } + if (attributes[StyleKey.color] != null) { + final color = Color( + int.parse(attributes[StyleKey.color]), + ); + cssMap["color"] = color.toRgbaString(); + } + if (attributes[StyleKey.bold] == true) { + cssMap["font-weight"] = "bold"; + } + return _cssMapToCssStyle(cssMap); + } + + String _cssMapToCssStyle(Map cssMap) { + return cssMap.entries.fold("", (previousValue, element) { + final kv = '${element.key}: ${element.value}'; + if (previousValue.isEmpty) { + return kv; + } + return '$previousValue; $kv'; + }); + } + + html.Element _deltaToHtml(Delta delta, + {String? subType, int? end, bool? checked}) { + if (end != null) { + delta = delta.slice(0, end); + } + + final childNodes = []; + String tagName = tagParagraph; + + if (subType == StyleKey.bulletedList || subType == StyleKey.numberList) { + tagName = tagList; + } else if (subType == StyleKey.checkbox) { + final node = html.Element.html(''); + if (checked != null && checked) { + node.attributes["checked"] = "true"; + } + childNodes.add(node); + } + + for (final op in delta.operations) { + if (op is TextInsert) { + final attributes = op.attributes; + if (attributes != null) { + if (attributes.length == 1 && attributes[StyleKey.bold] == true) { + final strong = html.Element.tag(tagStrong); + strong.append(html.Text(op.content)); + childNodes.add(strong); + } else { + final span = html.Element.tag(tagSpan); + final cssString = _attributesToCssStyle(attributes); + if (cssString.isNotEmpty) { + span.attributes["style"] = cssString; + } + span.append(html.Text(op.content)); + childNodes.add(span); + } + } else { + childNodes.add(html.Text(op.content)); + } + } + } + + if (tagName != tagParagraph) { + final p = html.Element.tag(tagParagraph); + for (final node in childNodes) { + p.append(node); + } + final result = html.Element.tag(tagList); + result.append(p); + return result; + } else { + final p = html.Element.tag(tagName); + for (final node in childNodes) { + p.append(node); + } + return p; } - return p; } } String stringify(html.Node node) { if (node is html.Element) { - String result = '<${node.localName}>'; - - for (final node in node.nodes) { - result += stringify(node); - } - - return result += ''; + return node.outerHtml; } if (node is html.Text) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/rich_text_style.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/rich_text_style.dart index c924709948..7b5eef70ea 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/rich_text_style.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/rich_text_style.dart @@ -22,7 +22,7 @@ class StyleKey { static String underline = 'underline'; static String strikethrough = 'strikethrough'; static String color = 'color'; - static String highlightColor = 'highlightColor'; + static String backgroundColor = 'backgroundColor'; static String font = 'font'; static String href = 'href'; @@ -151,11 +151,11 @@ extension DeltaAttributesExtensions on Attributes { return null; } - Color? get highlightColor { - if (containsKey(StyleKey.highlightColor) && - this[StyleKey.highlightColor] is String) { + Color? get backgroundColor { + if (containsKey(StyleKey.backgroundColor) && + this[StyleKey.backgroundColor] is String) { return Color( - int.parse(this[StyleKey.highlightColor]), + int.parse(this[StyleKey.backgroundColor]), ); } return null; @@ -266,8 +266,8 @@ class RichTextStyle { } Color? get _backgroundColor { - if (attributes.highlightColor != null) { - return attributes.highlightColor!; + if (attributes.backgroundColor != null) { + return attributes.backgroundColor!; } else if (attributes.code) { return Colors.grey.withOpacity(0.4); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart index 57cc47d605..948e5233fc 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart @@ -1,4 +1,3 @@ -import 'package:html/dom.dart' as html; import 'package:flowy_editor/flowy_editor.dart'; import 'package:flowy_editor/src/service/keyboard_service.dart'; import 'package:flowy_editor/src/infra/html_converter.dart'; @@ -7,50 +6,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:rich_clipboard/rich_clipboard.dart'; -class _HTMLNormalizer { - final List nodes; - html.Element? _pendingList; - - _HTMLNormalizer(this.nodes); - - List normalize() { - final result = []; - - for (final item in nodes) { - if (item is Text) { - result.add(item); - continue; - } - - if (item is html.Element) { - if (item.localName == "li") { - if (_pendingList != null) { - _pendingList!.append(item); - } else { - final ulItem = html.Element.tag("ul"); - ulItem.append(item); - - _pendingList = ulItem; - } - } else { - _pushList(result); - result.add(item); - } - } - } - - return result; - } - - _pushList(List result) { - if (_pendingList == null) { - return; - } - result.add(_pendingList!); - _pendingList = null; - } -} - _handleCopy(EditorState editorState) async { final selection = editorState.cursorSelection; if (selection == null || selection.isCollapsed) { @@ -60,10 +15,11 @@ _handleCopy(EditorState editorState) async { final nodeAtPath = editorState.document.nodeAtPath(selection.end.path)!; if (nodeAtPath.type == "text") { final textNode = nodeAtPath as TextNode; - final delta = - textNode.delta.slice(selection.start.offset, selection.end.offset); - - final htmlString = stringify(deltaToHtml(delta)); + final htmlString = NodesToHTMLConverter( + nodes: [textNode], + startOffset: selection.start.offset, + endOffset: selection.end.offset) + .toHTMLString(); debugPrint('copy html: $htmlString'); RichClipboard.setData(RichClipboardData(html: htmlString)); } else { @@ -74,32 +30,14 @@ _handleCopy(EditorState editorState) async { final beginNode = editorState.document.nodeAtPath(selection.start.path)!; final endNode = editorState.document.nodeAtPath(selection.end.path)!; - final traverser = NodeIterator(editorState.document, beginNode, endNode); - final nodes = []; - while (traverser.moveNext()) { - final node = traverser.current; - if (node.type == "text") { - final textNode = node as TextNode; - String? subType = textNode.attributes["subtype"]; - if (node == beginNode) { - final htmlElement = - deltaToHtml(textNode.delta.slice(selection.start.offset), subType); - nodes.add(htmlElement); - } else if (node == endNode) { - final htmlElement = - deltaToHtml(textNode.delta.slice(0, selection.end.offset), subType); - nodes.add(htmlElement); - } else { - final htmlElement = deltaToHtml(textNode.delta, subType); - nodes.add(htmlElement); - } - } - // TODO: handle image and other blocks - } + final nodes = NodeIterator(editorState.document, beginNode, endNode).toList(); - final copyString = _HTMLNormalizer(nodes).normalize().fold( - "", ((previousValue, element) => previousValue + stringify(element))); + final copyString = NodesToHTMLConverter( + nodes: nodes, + startOffset: selection.start.offset, + endOffset: selection.end.offset) + .toHTMLString(); debugPrint('copy html: $copyString'); RichClipboard.setData(RichClipboardData(html: copyString)); } @@ -116,8 +54,7 @@ _pasteHTML(EditorState editorState, String html) { } debugPrint('paste html: $html'); - final converter = HTMLConverter(html); - final nodes = converter.toNodes(); + final nodes = HTMLToNodesConverter(html).toNodes(); if (nodes.isEmpty) { return;