瀏覽代碼

Merge remote-tracking branch 'origin/main' into feat/toolbar_service

Lucas.Xu 2 年之前
父節點
當前提交
70853b918e

+ 1 - 2
frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart

@@ -90,8 +90,7 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> with Selectable {
 
 
   @override
   @override
   Position getPositionInOffset(Offset start) {
   Position getPositionInOffset(Offset start) {
-    // TODO: implement getPositionInOffset
-    throw UnimplementedError();
+    return Position(path: node.path, offset: 0);
   }
   }
 
 
   @override
   @override

+ 4 - 0
frontend/app_flowy/packages/flowy_editor/example/linux/flutter/generated_plugin_registrant.cc

@@ -6,9 +6,13 @@
 
 
 #include "generated_plugin_registrant.h"
 #include "generated_plugin_registrant.h"
 
 
+#include <rich_clipboard_linux/rich_clipboard_plugin.h>
 #include <url_launcher_linux/url_launcher_plugin.h>
 #include <url_launcher_linux/url_launcher_plugin.h>
 
 
 void fl_register_plugins(FlPluginRegistry* registry) {
 void fl_register_plugins(FlPluginRegistry* registry) {
+  g_autoptr(FlPluginRegistrar) rich_clipboard_linux_registrar =
+      fl_plugin_registry_get_registrar_for_plugin(registry, "RichClipboardPlugin");
+  rich_clipboard_plugin_register_with_registrar(rich_clipboard_linux_registrar);
   g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
   g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
       fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
       fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
   url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
   url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

+ 1 - 0
frontend/app_flowy/packages/flowy_editor/example/linux/flutter/generated_plugins.cmake

@@ -3,6 +3,7 @@
 #
 #
 
 
 list(APPEND FLUTTER_PLUGIN_LIST
 list(APPEND FLUTTER_PLUGIN_LIST
+  rich_clipboard_linux
   url_launcher_linux
   url_launcher_linux
 )
 )
 
 

+ 2 - 0
frontend/app_flowy/packages/flowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift

@@ -5,8 +5,10 @@
 import FlutterMacOS
 import FlutterMacOS
 import Foundation
 import Foundation
 
 
+import rich_clipboard_macos
 import url_launcher_macos
 import url_launcher_macos
 
 
 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
+  RichClipboardPlugin.register(with: registry.registrar(forPlugin: "RichClipboardPlugin"))
   UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
   UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
 }
 }

+ 6 - 0
frontend/app_flowy/packages/flowy_editor/example/macos/Podfile.lock

@@ -1,20 +1,26 @@
 PODS:
 PODS:
   - FlutterMacOS (1.0.0)
   - FlutterMacOS (1.0.0)
+  - rich_clipboard_macos (0.0.1):
+    - FlutterMacOS
   - url_launcher_macos (0.0.1):
   - url_launcher_macos (0.0.1):
     - FlutterMacOS
     - FlutterMacOS
 
 
 DEPENDENCIES:
 DEPENDENCIES:
   - FlutterMacOS (from `Flutter/ephemeral`)
   - FlutterMacOS (from `Flutter/ephemeral`)
+  - rich_clipboard_macos (from `Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos`)
   - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
   - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
 
 
 EXTERNAL SOURCES:
 EXTERNAL SOURCES:
   FlutterMacOS:
   FlutterMacOS:
     :path: Flutter/ephemeral
     :path: Flutter/ephemeral
+  rich_clipboard_macos:
+    :path: Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos
   url_launcher_macos:
   url_launcher_macos:
     :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
     :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
 
 
 SPEC CHECKSUMS:
 SPEC CHECKSUMS:
   FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424
   FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424
+  rich_clipboard_macos: 43364b66b9dc69d203eb8dd6d758e2d12e02723c
   url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3
   url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3
 
 
 PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c
 PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c

+ 85 - 1
frontend/app_flowy/packages/flowy_editor/example/pubspec.lock

@@ -43,6 +43,13 @@ packages:
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
     version: "1.16.0"
     version: "1.16.0"
+  csslib:
+    dependency: transitive
+    description:
+      name: csslib
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.17.2"
   cupertino_icons:
   cupertino_icons:
     dependency: "direct main"
     dependency: "direct main"
     description:
     description:
@@ -57,6 +64,13 @@ packages:
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
     version: "1.3.0"
     version: "1.3.0"
+  ffi:
+    dependency: transitive
+    description:
+      name: ffi
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.2.1"
   flowy_editor:
   flowy_editor:
     dependency: "direct main"
     dependency: "direct main"
     description:
     description:
@@ -100,6 +114,13 @@ packages:
     description: flutter
     description: flutter
     source: sdk
     source: sdk
     version: "0.0.0"
     version: "0.0.0"
+  html:
+    dependency: transitive
+    description:
+      name: html
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.15.0"
   js:
   js:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -184,6 +205,62 @@ packages:
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
     version: "6.0.3"
     version: "6.0.3"
+  rich_clipboard:
+    dependency: transitive
+    description:
+      name: rich_clipboard
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.0"
+  rich_clipboard_android:
+    dependency: transitive
+    description:
+      name: rich_clipboard_android
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.0"
+  rich_clipboard_ios:
+    dependency: transitive
+    description:
+      name: rich_clipboard_ios
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.0"
+  rich_clipboard_linux:
+    dependency: transitive
+    description:
+      name: rich_clipboard_linux
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.0"
+  rich_clipboard_macos:
+    dependency: transitive
+    description:
+      name: rich_clipboard_macos
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.1"
+  rich_clipboard_platform_interface:
+    dependency: transitive
+    description:
+      name: rich_clipboard_platform_interface
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.0"
+  rich_clipboard_web:
+    dependency: transitive
+    description:
+      name: rich_clipboard_web
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.0"
+  rich_clipboard_windows:
+    dependency: transitive
+    description:
+      name: rich_clipboard_windows
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.0"
   sky_engine:
   sky_engine:
     dependency: transitive
     dependency: transitive
     description: flutter
     description: flutter
@@ -294,6 +371,13 @@ packages:
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
     version: "2.1.2"
     version: "2.1.2"
+  win32:
+    dependency: transitive
+    description:
+      name: win32
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.6.1"
   xml:
   xml:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -303,4 +387,4 @@ packages:
     version: "6.1.0"
     version: "6.1.0"
 sdks:
 sdks:
   dart: ">=2.17.0 <3.0.0"
   dart: ">=2.17.0 <3.0.0"
-  flutter: ">=2.11.0-0.1.pre"
+  flutter: ">=3.0.0"

+ 4 - 3
frontend/app_flowy/packages/flowy_editor/lib/document/node.dart

@@ -176,10 +176,11 @@ class TextNode extends Node {
 
 
   TextNode({
   TextNode({
     required super.type,
     required super.type,
-    required super.children,
-    required super.attributes,
     required Delta delta,
     required Delta delta,
-  }) : _delta = delta;
+    LinkedList<Node>? children,
+    Attributes? attributes,
+  })  : _delta = delta,
+        super(children: children ?? LinkedList(), attributes: attributes ?? {});
 
 
   TextNode.empty()
   TextNode.empty()
       : _delta = Delta([TextInsert(' ')]),
       : _delta = Delta([TextInsert(' ')]),

+ 74 - 0
frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart

@@ -0,0 +1,74 @@
+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!;
+  }
+
+  List<Node> toList() {
+    final result = <Node>[];
+
+    while (moveNext()) {
+      result.add(current);
+    }
+
+    return result;
+  }
+}

+ 201 - 0
frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart

@@ -0,0 +1,201 @@
+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;
+
+  HTMLConverter(String htmlString) : _document = parse(htmlString);
+
+  List<Node> toNodes() {
+    final result = <Node>[];
+    final delta = Delta();
+
+    final childNodes = _document.body?.nodes.toList() ?? <html.Node>[];
+    for (final child in childNodes) {
+      if (child is html.Element) {
+        if (child.localName == tagAnchor ||
+            child.localName == tagSpan ||
+            child.localName == tagStrong ||
+            child.localName == tagBold) {
+          _handleRichTextElement(delta, child);
+        } else {
+          _handleElement(result, child);
+        }
+      } else {
+        delta.insert(child.text ?? "");
+      }
+    }
+
+    if (delta.operations.isNotEmpty) {
+      result.add(TextNode(type: "text", delta: delta));
+    }
+
+    return result;
+  }
+
+  _handleElement(List<Node> nodes, html.Element element) {
+    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 == tagList) {
+      _handleListElement(nodes, element);
+    } else if (element.localName == tagParagraph) {
+      _handleParagraph(nodes, element);
+    } else {
+      final delta = Delta();
+      delta.insert(element.text);
+      if (delta.operations.isNotEmpty) {
+        nodes.add(TextNode(type: "text", delta: delta));
+      }
+    }
+  }
+
+  _handleParagraph(List<Node> nodes, html.Element element) {
+    _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 == 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 == 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(tagImage);
+    if (image != null) {
+      _handleImage(nodes, image);
+      return;
+    }
+
+    var delta = Delta();
+
+    for (final child in element.nodes.toList()) {
+      if (child is html.Element) {
+        _handleRichTextElement(delta, child);
+      } else {
+        delta.insert(child.text ?? "");
+      }
+    }
+
+    if (delta.operations.isNotEmpty) {
+      nodes.add(TextNode(type: "text", delta: delta));
+    }
+  }
+
+  _handleImage(List<Node> nodes, html.Element element) {
+    final src = element.attributes["src"];
+    final attributes = <String, dynamic>{};
+    if (src != null) {
+      attributes["image_src"] = src;
+    }
+    debugPrint("insert image: $src");
+    nodes.add(
+        Node(type: "image", attributes: attributes, children: LinkedList()));
+  }
+
+  _handleUnorderedList(List<Node> nodes, html.Element element) {
+    element.children.forEach((child) {
+      _handleListElement(nodes, child);
+    });
+  }
+
+  _handleHeadingElement(
+    List<Node> nodes,
+    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));
+    }
+  }
+
+  _handleListElement(List<Node> nodes, html.Element element) {
+    final childNodes = element.nodes.toList();
+    for (final child in childNodes) {
+      if (child is html.Element) {
+        _handleRichText(nodes, child);
+      }
+    }
+  }
+}
+
+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;
+}

+ 4 - 0
frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart

@@ -80,6 +80,10 @@ class TransactionBuilder {
     add(TextEditOperation(path, delta, inverted));
     add(TextEditOperation(path, delta, inverted));
   }
   }
 
 
+  setAfterSelection(Selection sel) {
+    afterSelection = sel;
+  }
+
   mergeText(TextNode firstNode, TextNode secondNode,
   mergeText(TextNode firstNode, TextNode secondNode,
       {int? firstOffset, int secondOffset = 0}) {
       {int? firstOffset, int secondOffset = 0}) {
     final firstLength = firstNode.delta.length;
     final firstLength = firstNode.delta.length;

+ 2 - 6
frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart

@@ -1,4 +1,3 @@
-import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 
 
 import 'package:flowy_editor/editor_state.dart';
 import 'package:flowy_editor/editor_state.dart';
@@ -11,6 +10,7 @@ import 'package:flowy_editor/render/rich_text/number_list_text.dart';
 import 'package:flowy_editor/render/rich_text/quoted_text.dart';
 import 'package:flowy_editor/render/rich_text/quoted_text.dart';
 import 'package:flowy_editor/service/input_service.dart';
 import 'package:flowy_editor/service/input_service.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/arrow_keys_handler.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/arrow_keys_handler.dart';
+import 'package:flowy_editor/service/internal_key_event_handlers/copy_paste_handler.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/delete_nodes_handler.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/delete_nodes_handler.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/delete_text_handler.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/delete_text_handler.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart';
@@ -37,6 +37,7 @@ List<FlowyKeyEventHandler> defaultKeyEventHandler = [
   slashShortcutHandler,
   slashShortcutHandler,
   flowyDeleteNodesHandler,
   flowyDeleteNodesHandler,
   arrowKeysHandler,
   arrowKeysHandler,
+  copyPasteKeysHandler,
   enterInEdgeOfTextNodeHandler,
   enterInEdgeOfTextNodeHandler,
   updateTextStyleByCommandXHandler,
   updateTextStyleByCommandXHandler,
 ];
 ];
@@ -70,7 +71,6 @@ class _FlowyEditorState extends State<FlowyEditor> {
   void initState() {
   void initState() {
     super.initState();
     super.initState();
 
 
-    _scrollController = ScrollController()..addListener(_scrollCallback);
     editorState.service.renderPluginService = _createRenderPlugin();
     editorState.service.renderPluginService = _createRenderPlugin();
   }
   }
 
 
@@ -131,8 +131,4 @@ class _FlowyEditorState extends State<FlowyEditor> {
           ...widget.customBuilders,
           ...widget.customBuilders,
         },
         },
       );
       );
-
-  void _scrollCallback() {
-    debugPrint('scrolling');
-  }
 }
 }

+ 233 - 0
frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart

@@ -0,0 +1,233 @@
+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(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) {
+  final selection = editorState.cursorSelection;
+  if (selection == null) {
+    return;
+  }
+
+  final path = [...selection.end.path];
+  if (path.isEmpty) {
+    return;
+  }
+
+  debugPrint('paste html: $html');
+  final converter = HTMLConverter(html);
+  final nodes = converter.toNodes();
+
+  if (nodes.isEmpty) {
+    return;
+  } else if (nodes.length == 1) {
+    final firstNode = nodes[0];
+    final nodeAtPath = editorState.document.nodeAtPath(path)!;
+    final tb = TransactionBuilder(editorState);
+    final startOffset = selection.start.offset;
+    if (nodeAtPath.type == "text" && firstNode.type == "text") {
+      final textNodeAtPath = nodeAtPath as TextNode;
+      final firstTextNode = firstNode as TextNode;
+      tb.textEdit(textNodeAtPath,
+          () => Delta().retain(startOffset).concat(firstTextNode.delta));
+      tb.setAfterSelection(Selection.collapsed(Position(
+          path: path, offset: startOffset + firstTextNode.delta.length)));
+      tb.commit();
+      return;
+    }
+  }
+
+  _pasteMultipleLinesInText(editorState, path, selection.start.offset, nodes);
+}
+
+_pasteMultipleLinesInText(
+    EditorState editorState, List<int> path, int offset, List<Node> nodes) {
+  final tb = TransactionBuilder(editorState);
+
+  final firstNode = nodes[0];
+  final nodeAtPath = editorState.document.nodeAtPath(path)!;
+
+  if (nodeAtPath.type == "text" && firstNode.type == "text") {
+    // split and merge
+    final textNodeAtPath = nodeAtPath as TextNode;
+    final firstTextNode = firstNode as TextNode;
+    final remain = textNodeAtPath.delta.slice(offset);
+
+    tb.textEdit(
+        textNodeAtPath,
+        () => Delta()
+            .retain(offset)
+            .delete(remain.length)
+            .concat(firstTextNode.delta));
+
+    final tailNodes = nodes.sublist(1);
+    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));
+    }
+
+    tb.insertNodes(path, tailNodes);
+    tb.commit();
+    return;
+  }
+
+  path[path.length - 1]++;
+  tb.insertNodes(path, nodes);
+  tb.commit();
+}
+
+_handlePaste(EditorState editorState) async {
+  final data = await RichClipboard.getData();
+  if (data.html != null) {
+    _pasteHTML(editorState, data.html!);
+    return;
+  }
+  if (data.text != null) {
+    _handlePastePlainText(editorState, data.text!);
+    return;
+  }
+}
+
+_handlePastePlainText(EditorState editorState, String plainText) {
+  final selection = editorState.cursorSelection;
+  if (selection == null) {
+    return;
+  }
+
+  final lines = plainText
+      .split("\n")
+      .map((e) => e.replaceAll(RegExp(r'\r'), ""))
+      .toList();
+
+  if (lines.isEmpty) {
+    return;
+  } else if (lines.length == 1) {
+    final node =
+        editorState.document.nodeAtPath(selection.end.path)! as TextNode;
+    final beginOffset = selection.end.offset;
+    TransactionBuilder(editorState)
+      ..textEdit(node, () => Delta().retain(beginOffset).insert(lines[0]))
+      ..setAfterSelection(Selection.collapsed(Position(
+          path: selection.end.path, offset: beginOffset + lines[0].length)))
+      ..commit();
+  } else {
+    final firstLine = lines[0];
+    final beginOffset = selection.end.offset;
+    final remains = lines.sublist(1);
+
+    final path = [...selection.end.path];
+    if (path.isEmpty) {
+      return;
+    }
+
+    final node =
+        editorState.document.nodeAtPath(selection.end.path)! as TextNode;
+    final insertedLineSuffix = node.delta.slice(beginOffset);
+
+    path[path.length - 1]++;
+    var index = 0;
+    final tb = TransactionBuilder(editorState);
+    final nodes = remains.map((e) {
+      if (index++ == remains.length - 1) {
+        return TextNode(
+            type: "text",
+            delta: Delta().insert(e).addAll(insertedLineSuffix.operations));
+      }
+      return TextNode(type: "text", delta: Delta().insert(e));
+    }).toList();
+    // insert first line
+    tb.textEdit(
+        node,
+        () => Delta()
+            .retain(beginOffset)
+            .insert(firstLine)
+            .delete(node.delta.length - beginOffset));
+    // insert remains
+    tb.insertNodes(path, nodes);
+    tb.commit();
+
+    // fixme: don't set the cursor manually
+    editorState.updateCursorSelection(Selection.collapsed(
+        Position(path: nodes.last.path, offset: lines.last.length)));
+  }
+}
+
+_handleCut() {
+  debugPrint('cut');
+}
+
+FlowyKeyEventHandler copyPasteKeysHandler = (editorState, event) {
+  if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyC) {
+    _handleCopy(editorState);
+    return KeyEventResult.handled;
+  }
+  if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyV) {
+    _handlePaste(editorState);
+    return KeyEventResult.handled;
+  }
+  if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyX) {
+    _handleCut();
+    return KeyEventResult.handled;
+  }
+  return KeyEventResult.ignored;
+};

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

@@ -5,8 +5,10 @@ import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 
 
 import 'package:flowy_editor/document/node.dart';
 import 'package:flowy_editor/document/node.dart';
+import 'package:flowy_editor/document/node_iterator.dart';
 import 'package:flowy_editor/document/position.dart';
 import 'package:flowy_editor/document/position.dart';
 import 'package:flowy_editor/document/selection.dart';
 import 'package:flowy_editor/document/selection.dart';
+import 'package:flowy_editor/document/state_tree.dart';
 import 'package:flowy_editor/editor_state.dart';
 import 'package:flowy_editor/editor_state.dart';
 import 'package:flowy_editor/extensions/node_extensions.dart';
 import 'package:flowy_editor/extensions/node_extensions.dart';
 import 'package:flowy_editor/render/selection/cursor_widget.dart';
 import 'package:flowy_editor/render/selection/cursor_widget.dart';
@@ -129,7 +131,7 @@ class _FlowySelectionState extends State<FlowySelection>
 
 
   @override
   @override
   List<Node> getNodesInSelection(Selection selection) =>
   List<Node> getNodesInSelection(Selection selection) =>
-      _selectedNodesInSelection(editorState.document.root, selection);
+      _selectedNodesInSelection(editorState.document, selection);
 
 
   @override
   @override
   void initState() {
   void initState() {
@@ -381,7 +383,7 @@ class _FlowySelectionState extends State<FlowySelection>
       final selection = Selection(
       final selection = Selection(
           start: isDownward ? start : end, end: isDownward ? end : start);
           start: isDownward ? start : end, end: isDownward ? end : start);
       debugPrint('[_onPanUpdate] isDownward = $isDownward, $selection');
       debugPrint('[_onPanUpdate] isDownward = $isDownward, $selection');
-      editorState.service.selectionService.updateSelection(selection);
+      editorState.updateCursorSelection(selection);
     }
     }
 
 
     _scrollUpOrDownIfNeeded(panEndOffset!);
     _scrollUpOrDownIfNeeded(panEndOffset!);
@@ -393,8 +395,7 @@ class _FlowySelectionState extends State<FlowySelection>
   }
   }
 
 
   void _updateSelection(Selection selection) {
   void _updateSelection(Selection selection) {
-    final nodes =
-        _selectedNodesInSelection(editorState.document.root, selection);
+    final nodes = _selectedNodesInSelection(editorState.document, selection);
 
 
     currentSelection = selection;
     currentSelection = selection;
     currentSelectedNodes.value = nodes;
     currentSelectedNodes.value = nodes;
@@ -503,17 +504,11 @@ class _FlowySelectionState extends State<FlowySelection>
     currentState?.show();
     currentState?.show();
   }
   }
 
 
-  List<Node> _selectedNodesInSelection(Node node, Selection selection) {
-    List<Node> result = [];
-    if (node.parent != null) {
-      if (node.inSelection(selection)) {
-        result.add(node);
-      }
-    }
-    for (final child in node.children) {
-      result.addAll(_selectedNodesInSelection(child, selection));
-    }
-    return result;
+  List<Node> _selectedNodesInSelection(
+      StateTree stateTree, Selection selection) {
+    final startNode = stateTree.nodeAtPath(selection.start.path)!;
+    final endNode = stateTree.nodeAtPath(selection.end.path)!;
+    return NodeIterator(stateTree, startNode, endNode).toList();
   }
   }
 
 
   void _scrollUpOrDownIfNeeded(Offset offset) {
   void _scrollUpOrDownIfNeeded(Offset offset) {

+ 2 - 0
frontend/app_flowy/packages/flowy_editor/pubspec.yaml

@@ -11,6 +11,8 @@ dependencies:
   flutter:
   flutter:
     sdk: flutter
     sdk: flutter
 
 
+  rich_clipboard: ^1.0.0
+  html: ^0.15.0
   flutter_svg: ^1.1.1+1
   flutter_svg: ^1.1.1+1
   provider: ^6.0.3
   provider: ^6.0.3
 
 

+ 119 - 0
frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_tests.rs

@@ -14,6 +14,7 @@ mod tests {
         let field_type = FieldType::URL;
         let field_type = FieldType::URL;
         let field_rev = FieldBuilder::from_field_type(&field_type).build();
         let field_rev = FieldBuilder::from_field_type(&field_type).build();
         assert_url(&type_option, "123", "123", "", &field_type, &field_rev);
         assert_url(&type_option, "123", "123", "", &field_type, &field_rev);
+        assert_url(&type_option, "", "", "", &field_type, &field_rev);
     }
     }
 
 
     /// The expected_str will equal to the input string, but the expected_url will not be empty
     /// The expected_str will equal to the input string, but the expected_url will not be empty
@@ -42,6 +43,124 @@ mod tests {
         );
         );
     }
     }
 
 
+    /// if there's a http url and some words following it in the input string.
+    #[test]
+    fn url_type_option_contains_url_with_string_after_test() {
+        let type_option = URLTypeOption::default();
+        let field_type = FieldType::URL;
+        let field_rev = FieldBuilder::from_field_type(&field_type).build();
+        assert_url(
+            &type_option,
+            "AppFlowy website - https://www.appflowy.io welcome!",
+            "AppFlowy website - https://www.appflowy.io welcome!",
+            "https://www.appflowy.io/",
+            &field_type,
+            &field_rev,
+        );
+
+        assert_url(
+            &type_option,
+            "AppFlowy website appflowy.io welcome!",
+            "AppFlowy website appflowy.io welcome!",
+            "https://appflowy.io",
+            &field_type,
+            &field_rev,
+        );
+    }
+
+    /// if there's a http url and special words following it in the input string.
+    #[test]
+    fn url_type_option_contains_url_with_special_string_after_test() {
+        let type_option = URLTypeOption::default();
+        let field_type = FieldType::URL;
+        let field_rev = FieldBuilder::from_field_type(&field_type).build();
+        assert_url(
+            &type_option,
+            "AppFlowy website - https://www.appflowy.io!",
+            "AppFlowy website - https://www.appflowy.io!",
+            "https://www.appflowy.io/",
+            &field_type,
+            &field_rev,
+        );
+
+        assert_url(
+            &type_option,
+            "AppFlowy website appflowy.io!",
+            "AppFlowy website appflowy.io!",
+            "https://appflowy.io",
+            &field_type,
+            &field_rev,
+        );
+    }
+
+    /// if there's a level4 url in the input string.
+    #[test]
+    fn level4_url_type_test() {
+        let type_option = URLTypeOption::default();
+        let field_type = FieldType::URL;
+        let field_rev = FieldBuilder::from_field_type(&field_type).build();
+        assert_url(
+            &type_option,
+            "test - https://tester.testgroup.appflowy.io",
+            "test - https://tester.testgroup.appflowy.io",
+            "https://tester.testgroup.appflowy.io/",
+            &field_type,
+            &field_rev,
+        );
+
+        assert_url(
+            &type_option,
+            "test tester.testgroup.appflowy.io",
+            "test tester.testgroup.appflowy.io",
+            "https://tester.testgroup.appflowy.io",
+            &field_type,
+            &field_rev,
+        );
+    }
+
+    /// urls with different top level domains.
+    #[test]
+    fn different_top_level_domains_test() {
+        let type_option = URLTypeOption::default();
+        let field_type = FieldType::URL;
+        let field_rev = FieldBuilder::from_field_type(&field_type).build();
+        assert_url(
+            &type_option,
+            "appflowy - https://appflowy.com",
+            "appflowy - https://appflowy.com",
+            "https://appflowy.com/",
+            &field_type,
+            &field_rev,
+        );
+
+        assert_url(
+            &type_option,
+            "appflowy - https://appflowy.top",
+            "appflowy - https://appflowy.top",
+            "https://appflowy.top/",
+            &field_type,
+            &field_rev,
+        );
+
+        assert_url(
+            &type_option,
+            "appflowy - https://appflowy.net",
+            "appflowy - https://appflowy.net",
+            "https://appflowy.net/",
+            &field_type,
+            &field_rev,
+        );
+
+        assert_url(
+            &type_option,
+            "appflowy - https://appflowy.edu",
+            "appflowy - https://appflowy.edu",
+            "https://appflowy.edu/",
+            &field_type,
+            &field_rev,
+        );
+    }
+
     fn assert_url(
     fn assert_url(
         type_option: &URLTypeOption,
         type_option: &URLTypeOption,
         input_str: &str,
         input_str: &str,