From 2a6412f81a53f7f96d743f8ef87618e7046fdef9 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 3 Aug 2022 17:29:45 +0800 Subject: [PATCH 1/5] feat: copy from html --- .../lib/infra/html_converter.dart | 60 +++++++++++++++---- .../copy_paste_handler.dart | 39 +++++++++--- .../lib/service/selection_service.dart | 2 +- 3 files changed, 83 insertions(+), 18 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 ece4f6b9f4..6895643744 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 @@ -1,5 +1,6 @@ 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:flutter/foundation.dart'; @@ -20,7 +21,8 @@ class HTMLConverter { if (child is html.Element) { if (child.localName == "a" || child.localName == "span" || - child.localName == "strong") { + child.localName == "strong" || + child.localName == "b") { _handleRichTextElement(delta, child); } else { _handleElement(result, child); @@ -63,9 +65,33 @@ class HTMLConverter { _handleRichText(nodes, element); } + 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; + } + } + } + } + + return attrs.isEmpty ? null : attrs; + } + _handleRichTextElement(Delta delta, html.Element element) { if (element.localName == "span") { - delta.insert(element.text); + delta.insert(element.text, + _getDeltaAttributesFromHtmlAttributes(element.attributes)); } else if (element.localName == "a") { final hyperLink = element.attributes["href"]; Map? attributes; @@ -73,8 +99,10 @@ class HTMLConverter { attributes = {"href": hyperLink}; } delta.insert(element.text, attributes); - } else if (element.localName == "strong") { + } else if (element.localName == "strong" || element.localName == "b") { delta.insert(element.text, {"bold": true}); + } else { + delta.insert(element.text); } } @@ -89,13 +117,7 @@ class HTMLConverter { for (final child in element.nodes.toList()) { if (child is html.Element) { - if (child.localName == "a" || - child.localName == "span" || - child.localName == "strong") { - _handleRichTextElement(delta, element); - } else { - delta.insert(child.text); - } + _handleRichTextElement(delta, child); } else { delta.insert(child.text ?? ""); } @@ -147,3 +169,21 @@ class HTMLConverter { } } } + +String deltaToHtml(Delta delta) { + var result = "

"; + + for (final op in delta.operations) { + if (op is TextInsert) { + final attributes = op.attributes; + if (attributes != null && attributes["bold"] == true) { + result += '${op.content}'; + } else { + result += op.content; + } + } + } + + result += "

"; + return result; +} 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 6e6e30dbf4..f413801771 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 @@ -5,7 +5,26 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:rich_clipboard/rich_clipboard.dart'; -_handleCopy() async { +_handleCopy(EditorState editorState) async { + final selection = editorState.cursorSelection; + if (selection == null || selection.isCollapsed) { + return; + } + if (pathEquals(selection.start.path, selection.end.path)) { + 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 = deltaToHtml(delta); + debugPrint('copy html: $htmlString'); + RichClipboard.setData(RichClipboardData(html: htmlString)); + } else { + debugPrint("unimplemented: copy non-text"); + } + return; + } debugPrint('copy'); } @@ -20,6 +39,7 @@ _pasteHTML(EditorState editorState, String html) { return; } + debugPrint('paste html: $html'); final converter = HTMLConverter(html); final nodes = converter.toNodes(); @@ -38,6 +58,7 @@ _pasteHTML(EditorState editorState, String html) { tb.setAfterSelection(Selection.collapsed(Position( path: path, offset: startOffset + firstTextNode.delta.length))); tb.commit(); + return; } } @@ -64,12 +85,16 @@ _pasteMultipleLinesInText( .delete(remain.length) .concat(firstTextNode.delta)); - path[path.length - 1]++; final tailNodes = nodes.sublist(1); - if (tailNodes.last.type == "text") { - final tailTextNode = tailNodes.last as TextNode; - tailTextNode.delta = tailTextNode.delta.concat(remain); - } else if (remain.length > 0) { + path[path.length - 1]++; + if (tailNodes.isNotEmpty) { + if (tailNodes.last.type == "text") { + final tailTextNode = tailNodes.last as TextNode; + tailTextNode.delta = tailTextNode.delta.concat(remain); + } else if (remain.length > 0) { + tailNodes.add(TextNode(type: "text", delta: remain)); + } + } else { tailNodes.add(TextNode(type: "text", delta: remain)); } @@ -165,7 +190,7 @@ _handleCut() { FlowyKeyEventHandler copyPasteKeysHandler = (editorState, event) { if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyC) { - _handleCopy(); + _handleCopy(editorState); return KeyEventResult.handled; } if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyV) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart index 59632773e5..a304272846 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart @@ -437,7 +437,7 @@ class _FlowySelectionState extends State final selection = Selection( start: isDownward ? start : end, end: isDownward ? end : start); debugPrint('[_onPanUpdate] isDownward = $isDownward, $selection'); - editorState.service.selectionService.updateSelection(selection); + editorState.updateCursorSelection(selection); } } From 8da6faa74bc36064ea1511ebd99b1162f4ceffe8 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 3 Aug 2022 19:35:37 +0800 Subject: [PATCH 2/5] feat: node traverser --- .../lib/document/node_traverser.dart | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/document/node_traverser.dart diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node_traverser.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node_traverser.dart new file mode 100644 index 0000000000..b4005f054a --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node_traverser.dart @@ -0,0 +1,42 @@ +import 'package:flowy_editor/document/node.dart'; + +import './state_tree.dart'; +import './node.dart'; + +/// [NodeTraverser] is used to traverse the nodes in visual order. +class NodeTraverser { + final StateTree stateTree; + Node? currentNode; + + NodeTraverser(this.stateTree, Node beginNode) : currentNode = beginNode; + + Node? next() { + final node = currentNode; + if (node == null) { + return null; + } + + if (node.children.isNotEmpty) { + currentNode = _findLeadingChild(node); + } else if (node.next != null) { + currentNode = node.next!; + } else { + final parent = node.parent!; + final nextOfParent = parent.next; + if (nextOfParent == null) { + currentNode = null; + } else { + currentNode = _findLeadingChild(node); + } + } + + return node; + } + + Node _findLeadingChild(Node node) { + while (node.children.isNotEmpty) { + node = node.children.first; + } + return node; + } +} From 7ef053eb0d6477ef9c9e1de1fe1c4bd92e817370 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 3 Aug 2022 19:46:13 +0800 Subject: [PATCH 3/5] feat: copy multiple text --- .../copy_paste_handler.dart | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) 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 f413801771..a79c121684 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,6 +1,7 @@ import 'package:flowy_editor/flowy_editor.dart'; import 'package:flowy_editor/service/keyboard_service.dart'; import 'package:flowy_editor/infra/html_converter.dart'; +import 'package:flowy_editor/document/node_traverser.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:rich_clipboard/rich_clipboard.dart'; @@ -25,7 +26,37 @@ _handleCopy(EditorState editorState) async { } return; } - debugPrint('copy'); + + final beginNode = editorState.document.nodeAtPath(selection.start.path)!; + final endNode = editorState.document.nodeAtPath(selection.end.path)!; + final traverser = NodeTraverser(editorState.document, beginNode); + + var copyString = ""; + while (traverser.currentNode != null) { + final node = traverser.next()!; + if (node.type == "text") { + final textNode = node as TextNode; + if (node == beginNode) { + final htmlString = + deltaToHtml(textNode.delta.slice(selection.start.offset)); + copyString += htmlString; + } else if (node == endNode) { + final htmlString = + deltaToHtml(textNode.delta.slice(0, selection.end.offset)); + copyString += htmlString; + } else { + final htmlString = deltaToHtml(textNode.delta); + copyString += htmlString; + } + } + // TODO: handle image and other blocks + + if (node == endNode) { + break; + } + } + debugPrint('copy html: $copyString'); + RichClipboard.setData(RichClipboardData(html: copyString)); } _pasteHTML(EditorState editorState, String html) { From 061168bd8278fab18aee3b7fb63edc39e5976653 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Thu, 4 Aug 2022 10:41:07 +0800 Subject: [PATCH 4/5] refactor: use contant variable --- .../lib/infra/html_converter.dart | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 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 6895643744..9c7872c901 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 @@ -7,6 +7,18 @@ import 'package:flutter/foundation.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 tagUnorderedList = "ul"; +const String tagList = "li"; +const String tagParagraph = "p"; +const String tagImage = "img"; +const String tagAnchor = "a"; +const String tagBold = "b"; +const String tagStrong = "strong"; +const String tagSpan = "span"; + class HTMLConverter { final html.Document _document; @@ -19,10 +31,10 @@ class HTMLConverter { final childNodes = _document.body?.nodes.toList() ?? []; for (final child in childNodes) { if (child is html.Element) { - if (child.localName == "a" || - child.localName == "span" || - child.localName == "strong" || - child.localName == "b") { + if (child.localName == tagAnchor || + child.localName == tagSpan || + child.localName == tagStrong || + child.localName == tagBold) { _handleRichTextElement(delta, child); } else { _handleElement(result, child); @@ -40,17 +52,17 @@ class HTMLConverter { } _handleElement(List nodes, html.Element element) { - if (element.localName == "h1") { - _handleHeadingElement(nodes, element, "h1"); - } else if (element.localName == "h2") { - _handleHeadingElement(nodes, element, "h2"); - } else if (element.localName == "h3") { - _handleHeadingElement(nodes, element, "h3"); - } else if (element.localName == "ul") { + if (element.localName == tagH1) { + _handleHeadingElement(nodes, element, tagH1); + } else if (element.localName == tagH2) { + _handleHeadingElement(nodes, element, tagH2); + } else if (element.localName == tagH3) { + _handleHeadingElement(nodes, element, tagH3); + } else if (element.localName == tagUnorderedList) { _handleUnorderedList(nodes, element); - } else if (element.localName == "li") { + } else if (element.localName == tagList) { _handleListElement(nodes, element); - } else if (element.localName == "p") { + } else if (element.localName == tagParagraph) { _handleParagraph(nodes, element); } else { final delta = Delta(); @@ -89,17 +101,17 @@ class HTMLConverter { } _handleRichTextElement(Delta delta, html.Element element) { - if (element.localName == "span") { + if (element.localName == tagSpan) { delta.insert(element.text, _getDeltaAttributesFromHtmlAttributes(element.attributes)); - } else if (element.localName == "a") { + } else if (element.localName == tagAnchor) { final hyperLink = element.attributes["href"]; Map? attributes; if (hyperLink != null) { attributes = {"href": hyperLink}; } delta.insert(element.text, attributes); - } else if (element.localName == "strong" || element.localName == "b") { + } else if (element.localName == tagStrong || element.localName == tagBold) { delta.insert(element.text, {"bold": true}); } else { delta.insert(element.text); @@ -107,7 +119,7 @@ class HTMLConverter { } _handleRichText(List nodes, html.Element element) { - final image = element.querySelector("img"); + final image = element.querySelector(tagImage); if (image != null) { _handleImage(nodes, image); return; From 2eecda0483e2de585d90a0721a41d12da9f91ef7 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Thu, 4 Aug 2022 10:53:41 +0800 Subject: [PATCH 5/5] refactor: from node traverser to node iterator --- .../lib/document/node_iterator.dart | 64 +++++++++++++++++++ .../lib/document/node_traverser.dart | 42 ------------ .../copy_paste_handler.dart | 11 ++-- 3 files changed, 68 insertions(+), 49 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart delete mode 100644 frontend/app_flowy/packages/flowy_editor/lib/document/node_traverser.dart diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart new file mode 100644 index 0000000000..8603c043e4 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart @@ -0,0 +1,64 @@ +import 'package:flowy_editor/document/node.dart'; + +import './state_tree.dart'; +import './node.dart'; + +/// [NodeIterator] is used to traverse the nodes in visual order. +class NodeIterator implements Iterator { + final StateTree stateTree; + final Node _startNode; + final Node? _endNode; + Node? _currentNode; + bool _began = false; + + NodeIterator(this.stateTree, Node startNode, [Node? endNode]) + : _startNode = startNode, + _endNode = endNode; + + @override + bool moveNext() { + if (!_began) { + _currentNode = _startNode; + _began = true; + return true; + } + + final node = _currentNode; + if (node == null) { + return false; + } + + if (_endNode != null && _endNode == node) { + _currentNode = null; + return false; + } + + if (node.children.isNotEmpty) { + _currentNode = _findLeadingChild(node); + } else if (node.next != null) { + _currentNode = node.next!; + } else { + final parent = node.parent!; + final nextOfParent = parent.next; + if (nextOfParent == null) { + _currentNode = null; + } else { + _currentNode = _findLeadingChild(node); + } + } + + return _currentNode != null; + } + + Node _findLeadingChild(Node node) { + while (node.children.isNotEmpty) { + node = node.children.first; + } + return node; + } + + @override + Node get current { + return _currentNode!; + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node_traverser.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node_traverser.dart deleted file mode 100644 index b4005f054a..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node_traverser.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flowy_editor/document/node.dart'; - -import './state_tree.dart'; -import './node.dart'; - -/// [NodeTraverser] is used to traverse the nodes in visual order. -class NodeTraverser { - final StateTree stateTree; - Node? currentNode; - - NodeTraverser(this.stateTree, Node beginNode) : currentNode = beginNode; - - Node? next() { - final node = currentNode; - if (node == null) { - return null; - } - - if (node.children.isNotEmpty) { - currentNode = _findLeadingChild(node); - } else if (node.next != null) { - currentNode = node.next!; - } else { - final parent = node.parent!; - final nextOfParent = parent.next; - if (nextOfParent == null) { - currentNode = null; - } else { - currentNode = _findLeadingChild(node); - } - } - - return node; - } - - Node _findLeadingChild(Node node) { - while (node.children.isNotEmpty) { - node = node.children.first; - } - return node; - } -} 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 a79c121684..cde1c4e122 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,7 +1,7 @@ import 'package:flowy_editor/flowy_editor.dart'; import 'package:flowy_editor/service/keyboard_service.dart'; import 'package:flowy_editor/infra/html_converter.dart'; -import 'package:flowy_editor/document/node_traverser.dart'; +import 'package:flowy_editor/document/node_iterator.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:rich_clipboard/rich_clipboard.dart'; @@ -29,11 +29,11 @@ _handleCopy(EditorState editorState) async { final beginNode = editorState.document.nodeAtPath(selection.start.path)!; final endNode = editorState.document.nodeAtPath(selection.end.path)!; - final traverser = NodeTraverser(editorState.document, beginNode); + final traverser = NodeIterator(editorState.document, beginNode, endNode); var copyString = ""; - while (traverser.currentNode != null) { - final node = traverser.next()!; + while (traverser.moveNext()) { + final node = traverser.current; if (node.type == "text") { final textNode = node as TextNode; if (node == beginNode) { @@ -51,9 +51,6 @@ _handleCopy(EditorState editorState) async { } // TODO: handle image and other blocks - if (node == endNode) { - break; - } } debugPrint('copy html: $copyString'); RichClipboard.setData(RichClipboardData(html: copyString));