فهرست منبع

Merge pull request #750 from AppFlowy-IO/feat/copy-paste

Feat: paste rich text in flowy editor
Nathan.fooo 2 سال پیش
والد
کامیت
3065f6d236

+ 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
   Position getPositionInOffset(Offset start) {
-    // TODO: implement getPositionInOffset
-    throw UnimplementedError();
+    return Position(path: node.path, offset: 0);
   }
 
   @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 <rich_clipboard_linux/rich_clipboard_plugin.h>
 #include <url_launcher_linux/url_launcher_plugin.h>
 
 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 =
       fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
   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
+  rich_clipboard_linux
   url_launcher_linux
 )
 

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

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

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

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

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

@@ -43,6 +43,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "1.16.0"
+  csslib:
+    dependency: transitive
+    description:
+      name: csslib
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.17.2"
   cupertino_icons:
     dependency: "direct main"
     description:
@@ -57,6 +64,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "1.3.0"
+  ffi:
+    dependency: transitive
+    description:
+      name: ffi
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.2.1"
   flowy_editor:
     dependency: "direct main"
     description:
@@ -93,6 +107,13 @@ packages:
     description: flutter
     source: sdk
     version: "0.0.0"
+  html:
+    dependency: transitive
+    description:
+      name: html
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.15.0"
   js:
     dependency: transitive
     description:
@@ -177,6 +198,62 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     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:
     dependency: transitive
     description: flutter
@@ -287,6 +364,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.1.2"
+  win32:
+    dependency: transitive
+    description:
+      name: win32
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.6.1"
   xml:
     dependency: transitive
     description:
@@ -296,4 +380,4 @@ packages:
     version: "6.1.0"
 sdks:
   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({
     required super.type,
-    required super.children,
-    required super.attributes,
     required Delta delta,
-  }) : _delta = delta;
+    LinkedList<Node>? children,
+    Attributes? attributes,
+  })  : _delta = delta,
+        super(children: children ?? LinkedList(), attributes: attributes ?? {});
 
   TextNode.empty()
       : _delta = Delta([TextInsert(' ')]),

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

@@ -0,0 +1,149 @@
+import 'dart:collection';
+
+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;
+
+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 == "a" ||
+            child.localName == "span" ||
+            child.localName == "strong") {
+          _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 == "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") {
+      _handleUnorderedList(nodes, element);
+    } else if (element.localName == "li") {
+      _handleListElement(nodes, element);
+    } else if (element.localName == "p") {
+      _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);
+  }
+
+  _handleRichTextElement(Delta delta, html.Element element) {
+    if (element.localName == "span") {
+      delta.insert(element.text);
+    } else if (element.localName == "a") {
+      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") {
+      delta.insert(element.text, {"bold": true});
+    }
+  }
+
+  _handleRichText(List<Node> nodes, html.Element element) {
+    final image = element.querySelector("img");
+    if (image != null) {
+      _handleImage(nodes, image);
+      return;
+    }
+
+    var delta = Delta();
+
+    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);
+        }
+      } 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);
+      }
+    }
+  }
+}

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

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

+ 7 - 5
frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart

@@ -7,17 +7,18 @@ import 'package:flowy_editor/render/editor/editor_entry.dart';
 import 'package:flowy_editor/render/rich_text/bulleted_list_text.dart';
 import 'package:flowy_editor/render/rich_text/checkbox_text.dart';
 import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart';
-import 'package:flowy_editor/render/rich_text/heading_text.dart';
-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/service/input_service.dart';
+import 'package:flowy_editor/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart';
+import 'package:flowy_editor/service/render_plugin_service.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/enter_in_edge_of_text_node_handler.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/shortcut_handler.dart';
 import 'package:flowy_editor/service/keyboard_service.dart';
-import 'package:flowy_editor/service/render_plugin_service.dart';
 import 'package:flowy_editor/service/selection_service.dart';
+import 'package:flowy_editor/render/rich_text/heading_text.dart';
+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/service/toolbar_service.dart';
 
 NodeWidgetBuilders defaultBuilders = {
@@ -35,6 +36,7 @@ List<FlowyKeyEventHandler> defaultKeyEventHandler = [
   slashShortcutHandler,
   flowyDeleteNodesHandler,
   arrowKeysHandler,
+  copyPasteKeysHandler,
   enterInEdgeOfTextNodeHandler,
   updateTextStyleByCommandXHandler,
 ];

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

@@ -0,0 +1,180 @@
+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:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:rich_clipboard/rich_clipboard.dart';
+
+_handleCopy() async {
+  debugPrint('copy');
+}
+
+_pasteHTML(EditorState editorState, String html) {
+  final selection = editorState.cursorSelection;
+  if (selection == null) {
+    return;
+  }
+
+  final path = [...selection.end.path];
+  if (path.isEmpty) {
+    return;
+  }
+
+  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();
+    }
+  }
+
+  _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));
+
+    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) {
+      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();
+    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;
+};

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

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