Parcourir la source

Merge pull request #761 from AppFlowy-IO/feat/copy-as-html

Feat: copy html from document
Vincent Chan il y a 2 ans
Parent
commit
22976a6847

+ 64 - 0
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<Node> {
+  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!;
+  }
+}

+ 76 - 24
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() ?? <html.Node>[];
     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<Node> 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<Object, String> htmlAttributes) {
+    final attrs = <String, dynamic>{};
+    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<String, dynamic>? 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<Node> 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 = "<p>";
+
+  for (final op in delta.operations) {
+    if (op is TextInsert) {
+      final attributes = op.attributes;
+      if (attributes != null && attributes["bold"] == true) {
+        result += '<strong>${op.content}</strong>';
+      } else {
+        result += op.content;
+      }
+    }
+  }
+
+  result += "</p>";
+  return result;
+}

+ 61 - 8
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) {

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart

@@ -437,7 +437,7 @@ class _FlowySelectionState extends State<FlowySelection>
       final selection = Selection(
           start: isDownward ? start : end, end: isDownward ? end : start);
       debugPrint('[_onPanUpdate] isDownward = $isDownward, $selection');
-      editorState.service.selectionService.updateSelection(selection);
+      editorState.updateCursorSelection(selection);
     }
   }