From d40a3c33fd11644199479883c2c84432056f07ff Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 10 Aug 2022 11:28:56 +0800 Subject: [PATCH 1/8] feat: copy paste check box --- .../lib/infra/html_converter.dart | 26 ++++++++++++------- .../copy_paste_handler.dart | 17 +++--------- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart index 708e47cb85..13f30ce4ef 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart @@ -3,6 +3,7 @@ import 'dart:collection'; import 'package:flowy_editor/document/attributes.dart'; import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/text_delta.dart'; +import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:html/parser.dart' show parse; @@ -206,18 +207,29 @@ class HTMLConverter { } } -html.Element deltaToHtml(Delta delta, [String? subType]) { +html.Element textNodeToHtml(TextNode textNode, {int? end}) { + String? subType = textNode.attributes["subtype"]; + return deltaToHtml(textNode.delta, subType: subType, end: end); +} + +html.Element deltaToHtml(Delta delta, {String? subType, int? end}) { + if (end != null) { + delta = delta.slice(0, end); + } + final childNodes = []; String tagName = tagParagraph; - if (subType == "bulleted-list") { + if (subType == StyleKey.bulletedList) { tagName = tagList; + } else if (subType == StyleKey.checkbox) { + childNodes.add(html.Element.html('')); } for (final op in delta.operations) { if (op is TextInsert) { final attributes = op.attributes; - if (attributes != null && attributes["bold"] == true) { + if (attributes != null && attributes[StyleKey.bold] == true) { final strong = html.Element.tag("strong"); strong.append(html.Text(op.content)); childNodes.add(strong); @@ -246,13 +258,7 @@ html.Element deltaToHtml(Delta delta, [String? subType]) { 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/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart index d3eeb073b9..3d1979dad0 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart @@ -60,10 +60,7 @@ _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 = stringify(textNodeToHtml(textNode)); debugPrint('copy html: $htmlString'); RichClipboard.setData(RichClipboardData(html: htmlString)); } else { @@ -81,18 +78,12 @@ _handleCopy(EditorState editorState) async { 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); + nodes.add(textNodeToHtml(textNode)); } else if (node == endNode) { - final htmlElement = - deltaToHtml(textNode.delta.slice(0, selection.end.offset), subType); - nodes.add(htmlElement); + nodes.add(textNodeToHtml(textNode, end: selection.end.offset)); } else { - final htmlElement = deltaToHtml(textNode.delta, subType); - nodes.add(htmlElement); + nodes.add(textNodeToHtml(textNode)); } } // TODO: handle image and other blocks From a59c809847c8737ed1592d4b9bb2a913c72e870c Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 10 Aug 2022 11:44:32 +0800 Subject: [PATCH 2/8] feat: copy checkbox style --- .../lib/infra/html_converter.dart | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart index 13f30ce4ef..68bb8555f9 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart @@ -149,8 +149,16 @@ class HTMLConverter { _handleImage(nodes, image); return; } + 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) { @@ -161,7 +169,12 @@ class HTMLConverter { } if (delta.operations.isNotEmpty) { - nodes.add(TextNode(type: "text", delta: delta, attributes: attributes)); + final textNode = TextNode(type: "text", delta: delta); + if (isCheckbox) { + textNode.attributes["subtype"] = StyleKey.checkbox; + textNode.attributes["checkbox"] = checked; + } + nodes.add(textNode); } } @@ -207,12 +220,16 @@ class HTMLConverter { } } -html.Element textNodeToHtml(TextNode textNode, {int? end}) { +html.Element textNodeToHtml(TextNode textNode, {int? end, bool? checked}) { String? subType = textNode.attributes["subtype"]; - return deltaToHtml(textNode.delta, subType: subType, end: end); + return deltaToHtml(textNode.delta, + subType: subType, + end: end, + checked: textNode.attributes["checkbox"] == true); } -html.Element deltaToHtml(Delta delta, {String? subType, int? end}) { +html.Element deltaToHtml(Delta delta, + {String? subType, int? end, bool? checked}) { if (end != null) { delta = delta.slice(0, end); } @@ -223,7 +240,11 @@ html.Element deltaToHtml(Delta delta, {String? subType, int? end}) { if (subType == StyleKey.bulletedList) { tagName = tagList; } else if (subType == StyleKey.checkbox) { - childNodes.add(html.Element.html('')); + final node = html.Element.html(''); + if (checked != null && checked) { + node.attributes["checked"] = "true"; + } + childNodes.add(node); } for (final op in delta.operations) { From 4140994fedb603c4e1a63aad6ea0af9e274a9167 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 10 Aug 2022 11:52:04 +0800 Subject: [PATCH 3/8] feat: paste ordered list --- .../lib/infra/html_converter.dart | 301 +++++++++++------- .../copy_paste_handler.dart | 70 +--- 2 files changed, 198 insertions(+), 173 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart index 68bb8555f9..bf743fb497 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart @@ -4,14 +4,13 @@ import 'package:flowy_editor/document/attributes.dart'; import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/text_delta.dart'; import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; -import 'package:flutter/foundation.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"; @@ -22,23 +21,21 @@ const String tagStrong = "strong"; const String tagSpan = "span"; const String tagCode = "code"; -class HTMLConverter { +/// Converting the HTML to nodes +class HTMLToNodesConverter { final html.Document _document; 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 || @@ -50,55 +47,60 @@ class HTMLConverter { // 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; } Attributes? _getDeltaAttributesFromHtmlAttributes( @@ -142,12 +144,12 @@ class HTMLConverter { } } - _handleRichText(List nodes, html.Element element, + 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; @@ -168,112 +170,197 @@ class HTMLConverter { } } - if (delta.operations.isNotEmpty) { - final textNode = TextNode(type: "text", delta: delta); - if (isCheckbox) { - textNode.attributes["subtype"] = StyleKey.checkbox; - textNode.attributes["checkbox"] = checked; - } - nodes.add(textNode); + final textNode = TextNode(type: "text", delta: delta); + 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 = []; + element.children.forEach((child) { + result + .addAll(_handleListElement(child, {"subtype": StyleKey.numberList})); + }); + 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)); } } - } -} - -html.Element textNodeToHtml(TextNode textNode, {int? end, bool? checked}) { - String? subType = textNode.attributes["subtype"]; - return deltaToHtml(textNode.delta, - subType: subType, - end: end, - checked: textNode.attributes["checkbox"] == true); -} - -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) { - 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 && attributes[StyleKey.bold] == true) { - final strong = html.Element.tag("strong"); - strong.append(html.Text(op.content)); - childNodes.add(strong); - } 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("li"); - result.append(p); return result; - } else { - final p = html.Element.tag(tagName); - for (final node in childNodes) { - p.append(node); + } +} + +class _HTMLNormalizer { + final List nodes; + html.Element? _pendingList; + + _HTMLNormalizer(this.nodes); + + List normalize() { + final result = []; + + for (final item in nodes) { + if (item is html.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; + } +} + +class NodesToHTMLConverter { + final List nodes; + final int? startOffset; + final int? endOffset; + + NodesToHTMLConverter({required this.nodes, this.startOffset, this.endOffset}); + + List toHTMLNodes() { + final result = []; + for (final node in nodes) { + if (node.type == "text") { + final textNode = node as TextNode; + if (node == nodes.first) { + result.add(_textNodeToHtml(textNode)); + } else if (node == nodes.last) { + result.add(_textNodeToHtml(textNode, end: endOffset)); + } else { + result.add(_textNodeToHtml(textNode)); + } + } + // TODO: handle image and other blocks + } + return result; + } + + String toHTMLString() { + final elements = toHTMLNodes(); + final copyString = _HTMLNormalizer(elements).normalize().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); + } + + 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 && attributes[StyleKey.bold] == true) { + final strong = html.Element.tag("strong"); + strong.append(html.Text(op.content)); + childNodes.add(strong); + } 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; } } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart index 3d1979dad0..4174e8eaee 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/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/service/keyboard_service.dart'; import 'package:flowy_editor/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,7 +15,7 @@ _handleCopy(EditorState editorState) async { final nodeAtPath = editorState.document.nodeAtPath(selection.end.path)!; if (nodeAtPath.type == "text") { final textNode = nodeAtPath as TextNode; - final htmlString = stringify(textNodeToHtml(textNode)); + final htmlString = NodesToHTMLConverter(nodes: [textNode]).toHTMLString(); debugPrint('copy html: $htmlString'); RichClipboard.setData(RichClipboardData(html: htmlString)); } else { @@ -71,26 +26,10 @@ _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; - if (node == beginNode) { - nodes.add(textNodeToHtml(textNode)); - } else if (node == endNode) { - nodes.add(textNodeToHtml(textNode, end: selection.end.offset)); - } else { - nodes.add(textNodeToHtml(textNode)); - } - } - // 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).toHTMLString(); debugPrint('copy html: $copyString'); RichClipboard.setData(RichClipboardData(html: copyString)); } @@ -107,8 +46,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; From e60630663568f34c2b694c2d980b3a2781c8df8b Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 10 Aug 2022 13:32:27 +0800 Subject: [PATCH 4/8] feat: converter --- .../lib/infra/html_converter.dart | 75 +++++++------------ 1 file changed, 25 insertions(+), 50 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart index bf743fb497..a297a12db1 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart @@ -230,78 +230,53 @@ class HTMLToNodesConverter { } } -class _HTMLNormalizer { - final List nodes; - html.Element? _pendingList; - - _HTMLNormalizer(this.nodes); - - List normalize() { - final result = []; - - for (final item in nodes) { - if (item is html.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; - } -} - class NodesToHTMLConverter { final List nodes; final int? startOffset; final int? endOffset; + final List _result = []; + html.Element? _stashListContainer; NodesToHTMLConverter({required this.nodes, this.startOffset, this.endOffset}); List toHTMLNodes() { - final result = []; for (final node in nodes) { if (node.type == "text") { final textNode = node as TextNode; if (node == nodes.first) { - result.add(_textNodeToHtml(textNode)); + _addTextNode(textNode); } else if (node == nodes.last) { - result.add(_textNodeToHtml(textNode, end: endOffset)); + _addTextNode(textNode, end: endOffset); } else { - result.add(_textNodeToHtml(textNode)); + _addTextNode(textNode); } } // TODO: handle image and other blocks } - return result; + 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 ? "ol" : "ul"); + _stashListContainer?.append(element); + } else { + if (_stashListContainer != null) { + _result.add(_stashListContainer!); + _stashListContainer = null; + } + _result.add(element); + } } String toHTMLString() { final elements = toHTMLNodes(); - final copyString = _HTMLNormalizer(elements).normalize().fold( + final copyString = elements.fold( "", ((previousValue, element) => previousValue + stringify(element))); return copyString; } From 430e9974be3595f322c6afb56c23ed341be8ff2d Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 10 Aug 2022 13:53:10 +0800 Subject: [PATCH 5/8] feat: convert number list --- .../lib/infra/html_converter.dart | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart index a297a12db1..9c45324651 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart @@ -170,7 +170,8 @@ class HTMLToNodesConverter { } } - final textNode = TextNode(type: "text", delta: delta); + final textNode = + TextNode(type: "text", delta: delta, attributes: attributes); if (isCheckbox) { textNode.attributes["subtype"] = StyleKey.checkbox; textNode.attributes["checkbox"] = checked; @@ -198,10 +199,11 @@ class HTMLToNodesConverter { List _handleOrderedList(html.Element element) { final result = []; - element.children.forEach((child) { - result - .addAll(_handleListElement(child, {"subtype": StyleKey.numberList})); - }); + 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; } @@ -253,6 +255,10 @@ class NodesToHTMLConverter { } // TODO: handle image and other blocks } + if (_stashListContainer != null) { + _result.add(_stashListContainer!); + _stashListContainer = null; + } return _result; } @@ -263,7 +269,8 @@ class NodesToHTMLConverter { _addElement(TextNode textNode, html.Element element) { if (element.localName == tagList) { final isNumbered = textNode.attributes["subtype"] == StyleKey.numberList; - _stashListContainer ??= html.Element.tag(isNumbered ? "ol" : "ul"); + _stashListContainer ??= + html.Element.tag(isNumbered ? tagOrderedList : tagUnorderedList); _stashListContainer?.append(element); } else { if (_stashListContainer != null) { From 20fb71455070717302c1461525a31374ceed0586 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 10 Aug 2022 14:01:57 +0800 Subject: [PATCH 6/8] refactor: rename highlightColor to backgroundColor --- .../flowy_editor/example/assets/example.json | 2 +- .../lib/infra/html_converter.dart | 60 +++++++++++++++++-- .../lib/render/rich_text/rich_text_style.dart | 14 ++--- .../copy_paste_handler.dart | 12 +++- 4 files changed, 74 insertions(+), 14 deletions(-) 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/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart index 9c45324651..7117c82eaa 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart @@ -4,6 +4,7 @@ import 'package:flowy_editor/document/attributes.dart'; import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/text_delta.dart'; import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; +import 'package:flowy_editor/service/internal_key_event_handlers/delete_text_handler.dart'; import 'package:html/parser.dart' show parse; import 'package:html/dom.dart' as html; @@ -239,7 +240,28 @@ class NodesToHTMLConverter { final List _result = []; html.Element? _stashListContainer; - NodesToHTMLConverter({required this.nodes, this.startOffset, this.endOffset}); + 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)); + } + } + } List toHTMLNodes() { for (final node in nodes) { @@ -296,6 +318,26 @@ class NodesToHTMLConverter { checked: textNode.attributes["checkbox"] == true); } + String _attributesToCssStyle(Map attributes) { + final cssMap = {}; + if (attributes[StyleKey.backgroundColor] != null) { + cssMap["background-color"] = attributes[StyleKey.backgroundColor]; + } else if (attributes[StyleKey.color] != null) { + cssMap["color"] = attributes[StyleKey.color]; + } + 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) { @@ -319,9 +361,19 @@ class NodesToHTMLConverter { if (op is TextInsert) { final attributes = op.attributes; if (attributes != null && attributes[StyleKey.bold] == true) { - final strong = html.Element.tag("strong"); - strong.append(html.Text(op.content)); - childNodes.add(strong); + 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)); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart index c44fd8dac1..c9f0905b38 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/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/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart index 4174e8eaee..34681ea21f 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart @@ -15,7 +15,11 @@ _handleCopy(EditorState editorState) async { final nodeAtPath = editorState.document.nodeAtPath(selection.end.path)!; if (nodeAtPath.type == "text") { final textNode = nodeAtPath as TextNode; - final htmlString = NodesToHTMLConverter(nodes: [textNode]).toHTMLString(); + final htmlString = NodesToHTMLConverter( + nodes: [textNode], + startOffset: selection.start.offset, + endOffset: selection.end.offset) + .toHTMLString(); debugPrint('copy html: $htmlString'); RichClipboard.setData(RichClipboardData(html: htmlString)); } else { @@ -29,7 +33,11 @@ _handleCopy(EditorState editorState) async { final nodes = NodeIterator(editorState.document, beginNode, endNode).toList(); - final copyString = NodesToHTMLConverter(nodes: nodes).toHTMLString(); + final copyString = NodesToHTMLConverter( + nodes: nodes, + startOffset: selection.start.offset, + endOffset: selection.end.offset) + .toHTMLString(); debugPrint('copy html: $copyString'); RichClipboard.setData(RichClipboardData(html: copyString)); } From c2a295d9cda911a428c235d8b68d5989d0a23f40 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 10 Aug 2022 14:36:39 +0800 Subject: [PATCH 7/8] feat: copy styles of text delta --- .../lib/infra/html_converter.dart | 103 ++++++++++++++---- 1 file changed, 84 insertions(+), 19 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart index 7117c82eaa..db016c5fc5 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart @@ -4,7 +4,7 @@ import 'package:flowy_editor/document/attributes.dart'; import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/text_delta.dart'; import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; -import 'package:flowy_editor/service/internal_key_event_handlers/delete_text_handler.dart'; +import 'package:flutter/material.dart'; import 'package:html/parser.dart' show parse; import 'package:html/dom.dart' as html; @@ -22,6 +22,12 @@ const String tagStrong = "strong"; const String tagSpan = "span"; const String tagCode = "code"; +extension on Color { + String toRgbaString() { + return 'rgba($red, $green, $blue, $alpha)'; + } +} + /// Converting the HTML to nodes class HTMLToNodesConverter { final html.Document _document; @@ -104,29 +110,78 @@ class HTMLToNodesConverter { 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; } + 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, @@ -321,9 +376,19 @@ class NodesToHTMLConverter { String _attributesToCssStyle(Map attributes) { final cssMap = {}; if (attributes[StyleKey.backgroundColor] != null) { - cssMap["background-color"] = attributes[StyleKey.backgroundColor]; - } else if (attributes[StyleKey.color] != null) { - cssMap["color"] = attributes[StyleKey.color]; + 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); } @@ -334,7 +399,7 @@ class NodesToHTMLConverter { if (previousValue.isEmpty) { return kv; } - return '$previousValue; $kv"'; + return '$previousValue; $kv'; }); } @@ -360,7 +425,7 @@ class NodesToHTMLConverter { for (final op in delta.operations) { if (op is TextInsert) { final attributes = op.attributes; - if (attributes != null && attributes[StyleKey.bold] == true) { + if (attributes != null) { if (attributes.length == 1 && attributes[StyleKey.bold] == true) { final strong = html.Element.tag(tagStrong); strong.append(html.Text(op.content)); From a26b6938079c5d77bed6c72493bbdeed178f4742 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 10 Aug 2022 15:59:03 +0800 Subject: [PATCH 8/8] feat: add comments to HTMLConvertert --- .../lib/src/infra/html_converter.dart | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) 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 8444bb7394..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 @@ -31,6 +31,12 @@ extension on Color { /// 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; HTMLToNodesConverter(String htmlString) : _document = parse(htmlString); @@ -51,7 +57,7 @@ class HTMLToNodesConverter { 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) { result.addAll(_handleBTag(child)); @@ -152,6 +158,8 @@ class HTMLToNodesConverter { 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; @@ -200,6 +208,10 @@ class HTMLToNodesConverter { } } + /// 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); @@ -288,11 +300,23 @@ class HTMLToNodesConverter { } } +/// [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 = []; + + /// According to the W3C specs. The bullet list should be wrapped as + /// + ///

    + ///
  • xxx
  • + ///
  • xxx
  • + ///
  • xxx
  • + ///
+ /// + /// This container is used to save the list elements temporarily. html.Element? _stashListContainer; NodesToHTMLConverter(