diff --git a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json deleted file mode 100644 index 307b4bf92f..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json +++ /dev/null @@ -1,245 +0,0 @@ -{ - "document": { - "type": "editor", - "attributes": {}, - "children": [ - { - "type": "image", - "attributes": { - "image_src": "https://images.squarespace-cdn.com/content/v1/617f6f16b877c06711e87373/c3f23723-37f4-44d7-9c5d-6e2a53064ae7/Asset+10.png?format=1500w" - } - }, - { - "type": "text", - "delta": [ - { - "insert": "👋 Welcome to AppFlowy!", - "attributes": { - "href": "https://www.appflowy.io/", - "heading": "h1" - } - } - ], - "attributes": { - "heading": "h1" - } - }, - { - "type": "text", - "delta": [ - { "insert": "Here are the basics", "attributes": { "heading": "h2" } } - ], - "attributes": { - "heading": "h2" - } - }, - { - "type": "text", - "delta": [{ "insert": "Click anywhere and just start typing." }], - "attributes": { - "list": "todo", - "todo": false - } - }, - { - "type": "text", - "delta": [{ "insert": "Click anywhere and just start typing." }], - "attributes": { - "list": "bullet" - } - }, - { - "type": "text", - "delta": [{ "insert": "Click anywhere and just start typing." }], - "attributes": { - "list": "bullet" - } - }, - { - "type": "text", - "delta": [ - { - "insert": "Highlight", - "attributes": { "highlight": "0xFFFFFF00" } - }, - { "insert": " Click anywhere and just start typing" }, - { "insert": " any text, and use the menu at the bottom to " }, - { "insert": "style", "attributes": { "italic": true } }, - { "insert": " your ", "attributes": { "bold": true } }, - { "insert": "writing", "attributes": { "underline": true } }, - { - "insert": " however you like.", - "attributes": { "strikethrough": true } - } - ], - "attributes": { - "checkbox": false - } - }, - { - "type": "text", - "delta": [ - { "insert": "Have a question? ", "attributes": { "heading": "h2" } } - ], - "attributes": { - "heading": "h2" - } - }, - { - "type": "text", - "delta": [ - { - "insert": "1. Click the '?' at the bottom right for help and support." - } - ], - "attributes": { - "quotes": true - } - }, - { - "type": "text", - "delta": [ - { - "insert": "2. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "3. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "4. Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "5. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "6. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "7. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "8. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "9. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "10. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "11. Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - }, - { - "type": "text", - "delta": [ - { - "insert": "Click the '?' at the bottom right for help and support." - } - ], - "attributes": {} - } - ] - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml b/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml index 65f15eae46..a9b374af5c 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml @@ -3,7 +3,7 @@ description: A new Flutter project. # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -30,7 +30,6 @@ dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 @@ -58,7 +57,6 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: - # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. @@ -66,7 +64,6 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - - document.json - example.json - big_document.json # - images/a_dot_ham.jpeg diff --git a/frontend/app_flowy/packages/flowy_editor/lib/src/extensions/color_extension.dart b/frontend/app_flowy/packages/flowy_editor/lib/src/extensions/color_extension.dart new file mode 100644 index 0000000000..7228127104 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/src/extensions/color_extension.dart @@ -0,0 +1,36 @@ +import 'package:flutter/painting.dart'; + +extension ColorExtension on Color { + /// Try to parse the `rgba(red, greed, blue, alpha)` + /// from the string. + static Color? tryFromRgbaString(String colorString) { + 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); + } + + String toRgbaString() { + return 'rgba($red, $green, $blue, $alpha)'; + } +} 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 4a15b54859..0aa8d4fd13 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 @@ -1,33 +1,42 @@ import 'dart:collection'; +import 'dart:ui'; 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:flowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:flowy_editor/src/extensions/color_extension.dart'; import 'package:flutter/material.dart'; import 'package:html/parser.dart' show parse; 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"; -const String tagImage = "img"; -const String tagAnchor = "a"; -const String tagItalic = "i"; -const String tagBold = "b"; -const String tagUnderline = "u"; -const String tagDel = "del"; -const String tagStrong = "strong"; -const String tagSpan = "span"; -const String tagCode = "code"; +class HTMLTag { + 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 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"; -extension on Color { - String toRgbaString() { - return 'rgba($red, $green, $blue, $alpha)'; + static bool isTopLevel(String tag) { + return tag == h1 || + tag == h2 || + tag == h3 || + tag == paragraph || + tag == div || + tag == blockQuote; } } @@ -54,15 +63,15 @@ class HTMLToNodesConverter { final result = []; for (final child in childNodes) { if (child is html.Element) { - if (child.localName == tagAnchor || - child.localName == tagSpan || - child.localName == tagCode || - child.localName == tagStrong || - child.localName == tagUnderline || - child.localName == tagItalic || - child.localName == tagDel) { + if (child.localName == HTMLTag.anchor || + child.localName == HTMLTag.span || + child.localName == HTMLTag.code || + child.localName == HTMLTag.strong || + child.localName == HTMLTag.underline || + child.localName == HTMLTag.italic || + child.localName == HTMLTag.del) { _handleRichTextElement(delta, child); - } else if (child.localName == tagBold) { + } else if (child.localName == HTMLTag.bold) { // Google docs wraps the the content inside the `` tag. // It's strange if (!_inParagraph) { @@ -70,6 +79,8 @@ class HTMLToNodesConverter { } else { result.add(_handleRichText(child)); } + } else if (child.localName == HTMLTag.blockQuote) { + result.addAll(_handleBlockQuote(child)); } else { result.addAll(_handleElement(child)); } @@ -83,6 +94,18 @@ class HTMLToNodesConverter { return result; } + List _handleBlockQuote(html.Element element) { + final result = []; + + for (final child in element.nodes.toList()) { + if (child is html.Element) { + result.addAll(_handleElement(child, {"subtype": StyleKey.quote})); + } + } + + return result; + } + List _handleBTag(html.Element element) { final childNodes = element.nodes; return _handleContainer(childNodes); @@ -90,19 +113,19 @@ class HTMLToNodesConverter { List _handleElement(html.Element element, [Map? attributes]) { - if (element.localName == tagH1) { - return [_handleHeadingElement(element, tagH1)]; - } else if (element.localName == tagH2) { - return [_handleHeadingElement(element, tagH2)]; - } else if (element.localName == tagH3) { - return [_handleHeadingElement(element, tagH3)]; - } else if (element.localName == tagUnorderedList) { + if (element.localName == HTMLTag.h1) { + return [_handleHeadingElement(element, HTMLTag.h1)]; + } else if (element.localName == HTMLTag.h2) { + return [_handleHeadingElement(element, HTMLTag.h2)]; + } else if (element.localName == HTMLTag.h3) { + return [_handleHeadingElement(element, HTMLTag.h3)]; + } else if (element.localName == HTMLTag.unorderedList) { return _handleUnorderedList(element); - } else if (element.localName == tagOrderedList) { + } else if (element.localName == HTMLTag.orderedList) { return _handleOrderedList(element); - } else if (element.localName == tagList) { + } else if (element.localName == HTMLTag.list) { return _handleListElement(element); - } else if (element.localName == tagParagraph) { + } else if (element.localName == HTMLTag.paragraph) { return [_handleParagraph(element, attributes)]; } else { final delta = Delta(); @@ -164,7 +187,9 @@ class HTMLToNodesConverter { } final backgroundColorStr = cssMap["background-color"]; - final backgroundColor = _tryParseCssColorString(backgroundColorStr); + final backgroundColor = backgroundColorStr == null + ? null + : ColorExtension.tryFromRgbaString(backgroundColorStr); if (backgroundColor != null) { attrs[StyleKey.backgroundColor] = '0x${backgroundColor.value.toRadixString(16)}'; @@ -188,56 +213,25 @@ class HTMLToNodesConverter { } } - /// 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) { + if (element.localName == HTMLTag.span) { delta.insert(element.text, _getDeltaAttributesFromHtmlAttributes(element.attributes)); - } else if (element.localName == tagAnchor) { + } else if (element.localName == HTMLTag.anchor) { final hyperLink = element.attributes["href"]; Map? attributes; if (hyperLink != null) { attributes = {"href": hyperLink}; } delta.insert(element.text, attributes); - } else if (element.localName == tagStrong || element.localName == tagBold) { + } else if (element.localName == HTMLTag.strong || + element.localName == HTMLTag.bold) { delta.insert(element.text, {StyleKey.bold: true}); - } else if (element.localName == tagUnderline) { + } else if (element.localName == HTMLTag.underline) { delta.insert(element.text, {StyleKey.underline: true}); - } else if (element.localName == tagItalic) { + } else if (element.localName == HTMLTag.italic) { delta.insert(element.text, {StyleKey.italic: true}); - } else if (element.localName == tagDel) { + } else if (element.localName == HTMLTag.del) { delta.insert(element.text, {StyleKey.strikethrough: true}); } else { delta.insert(element.text); @@ -250,7 +244,7 @@ class HTMLToNodesConverter { /// A container contains a will be regarded as a image block Node _handleRichText(html.Element element, [Map? attributes]) { - final image = element.querySelector(tagImage); + final image = element.querySelector(HTMLTag.image); if (image != null) { final imageNode = _handleImage(image); return imageNode; @@ -404,10 +398,10 @@ class NodesToHTMLConverter { } _addElement(TextNode textNode, html.Element element) { - if (element.localName == tagList) { + if (element.localName == HTMLTag.list) { final isNumbered = textNode.attributes["subtype"] == StyleKey.numberList; - _stashListContainer ??= - html.Element.tag(isNumbered ? tagOrderedList : tagUnorderedList); + _stashListContainer ??= html.Element.tag( + isNumbered ? HTMLTag.orderedList : HTMLTag.unorderedList); _stashListContainer?.append(element); } else { if (_stashListContainer != null) { @@ -427,8 +421,10 @@ class NodesToHTMLConverter { html.Element _textNodeToHtml(TextNode textNode, {int? end}) { String? subType = textNode.attributes["subtype"]; + String? heading = textNode.attributes["heading"]; return _deltaToHtml(textNode.delta, subType: subType, + heading: heading, end: end, checked: textNode.attributes["checkbox"] == true); } @@ -501,22 +497,32 @@ class NodesToHTMLConverter { /// Text /// ``` html.Element _deltaToHtml(Delta delta, - {String? subType, int? end, bool? checked}) { + {String? subType, String? heading, int? end, bool? checked}) { if (end != null) { delta = delta.slice(0, end); } final childNodes = []; - String tagName = tagParagraph; + String tagName = HTMLTag.paragraph; if (subType == StyleKey.bulletedList || subType == StyleKey.numberList) { - tagName = tagList; + tagName = HTMLTag.list; } else if (subType == StyleKey.checkbox) { final node = html.Element.html(''); if (checked != null && checked) { node.attributes["checked"] = "true"; } childNodes.add(node); + } else if (subType == StyleKey.heading) { + if (heading == StyleKey.h1) { + tagName = HTMLTag.h1; + } else if (heading == StyleKey.h2) { + tagName = HTMLTag.h2; + } else if (heading == StyleKey.h3) { + tagName = HTMLTag.h3; + } + } else if (subType == StyleKey.quote) { + tagName = HTMLTag.blockQuote; } for (final op in delta) { @@ -524,26 +530,26 @@ class NodesToHTMLConverter { final attributes = op.attributes; if (attributes != null) { if (attributes.length == 1 && attributes[StyleKey.bold] == true) { - final strong = html.Element.tag(tagStrong); + final strong = html.Element.tag(HTMLTag.strong); strong.append(html.Text(op.content)); childNodes.add(strong); } else if (attributes.length == 1 && attributes[StyleKey.underline] == true) { - final strong = html.Element.tag(tagUnderline); + final strong = html.Element.tag(HTMLTag.underline); strong.append(html.Text(op.content)); childNodes.add(strong); } else if (attributes.length == 1 && attributes[StyleKey.italic] == true) { - final strong = html.Element.tag(tagItalic); + final strong = html.Element.tag(HTMLTag.italic); strong.append(html.Text(op.content)); childNodes.add(strong); } else if (attributes.length == 1 && attributes[StyleKey.strikethrough] == true) { - final strong = html.Element.tag(tagDel); + final strong = html.Element.tag(HTMLTag.del); strong.append(html.Text(op.content)); childNodes.add(strong); } else { - final span = html.Element.tag(tagSpan); + final span = html.Element.tag(HTMLTag.span); final cssString = _attributesToCssStyle(attributes); if (cssString.isNotEmpty) { span.attributes["style"] = cssString; @@ -557,12 +563,20 @@ class NodesToHTMLConverter { } } - if (tagName != tagParagraph) { - final p = html.Element.tag(tagParagraph); + if (tagName == HTMLTag.blockQuote) { + final p = html.Element.tag(HTMLTag.paragraph); for (final node in childNodes) { p.append(node); } - final result = html.Element.tag(tagList); + final blockQuote = html.Element.tag(tagName); + blockQuote.append(p); + return blockQuote; + } else if (!HTMLTag.isTopLevel(tagName)) { + final p = html.Element.tag(HTMLTag.paragraph); + for (final node in childNodes) { + p.append(node); + } + final result = html.Element.tag(HTMLTag.list); result.append(p); return result; } else {