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/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart index ece4f6b9f4..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 @@ -1,11 +1,24 @@ 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'; 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; @@ -18,9 +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") { + if (child.localName == tagAnchor || + child.localName == tagSpan || + child.localName == tagStrong || + child.localName == tagBold) { _handleRichTextElement(delta, child); } else { _handleElement(result, child); @@ -38,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(); @@ -63,23 +77,49 @@ 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); - } else if (element.localName == "a") { + if (element.localName == tagSpan) { + delta.insert(element.text, + _getDeltaAttributesFromHtmlAttributes(element.attributes)); + } 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") { + } else if (element.localName == tagStrong || element.localName == tagBold) { delta.insert(element.text, {"bold": true}); + } else { + delta.insert(element.text); } } _handleRichText(List nodes, html.Element element) { - final image = element.querySelector("img"); + final image = element.querySelector(tagImage); if (image != null) { _handleImage(nodes, image); return; @@ -89,13 +129,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 +181,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..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,12 +1,59 @@ 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_iterator.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:rich_clipboard/rich_clipboard.dart'; -_handleCopy() async { - debugPrint('copy'); +_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; + } + + final beginNode = editorState.document.nodeAtPath(selection.start.path)!; + final endNode = editorState.document.nodeAtPath(selection.end.path)!; + final traverser = NodeIterator(editorState.document, beginNode, endNode); + + var copyString = ""; + while (traverser.moveNext()) { + final node = traverser.current; + 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 + + } + debugPrint('copy html: $copyString'); + RichClipboard.setData(RichClipboardData(html: copyString)); } _pasteHTML(EditorState editorState, String html) { @@ -20,6 +67,7 @@ _pasteHTML(EditorState editorState, String html) { return; } + debugPrint('paste html: $html'); final converter = HTMLConverter(html); final nodes = converter.toNodes(); @@ -38,6 +86,7 @@ _pasteHTML(EditorState editorState, String html) { tb.setAfterSelection(Selection.collapsed(Position( path: path, offset: startOffset + firstTextNode.delta.length))); tb.commit(); + return; } } @@ -64,12 +113,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 +218,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); } }