Bläddra i källkod

feat: improve copy paste plugins, and support in-app copy-paste (#3233)

Lucas.Xu 1 år sedan
förälder
incheckning
bd30e31f6c
17 ändrade filer med 800 tillägg och 37 borttagningar
  1. BIN
      frontend/appflowy_flutter/assets/test/images/sample.gif
  2. BIN
      frontend/appflowy_flutter/assets/test/images/sample.jpeg
  3. BIN
      frontend/appflowy_flutter/assets/test/images/sample.png
  4. 211 25
      frontend/appflowy_flutter/integration_test/document/document_copy_and_paste_test.dart
  5. 2 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart
  6. 129 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart
  7. 52 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_copy_command.dart
  8. 62 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart
  9. 155 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart
  10. 23 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart
  11. 44 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart
  12. 25 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_in_app_json.dart
  13. 36 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart
  14. 2 0
      frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart
  15. 15 10
      frontend/appflowy_flutter/lib/startup/deps_resolver.dart
  16. 42 2
      frontend/appflowy_flutter/pubspec.lock
  17. 2 0
      frontend/appflowy_flutter/pubspec.yaml

BIN
frontend/appflowy_flutter/assets/test/images/sample.gif


BIN
frontend/appflowy_flutter/assets/test/images/sample.jpeg


BIN
frontend/appflowy_flutter/assets/test/images/sample.png


+ 211 - 25
frontend/appflowy_flutter/integration_test/document/document_copy_and_paste_test.dart

@@ -1,5 +1,7 @@
 import 'dart:io';
 
+import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
+import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.dart';
@@ -12,36 +14,220 @@ void main() {
 
   group('copy and paste in document', () {
     testWidgets('paste multiple lines at the first line', (tester) async {
-      await tester.initializeAppFlowy();
-      await tester.tapGoButton();
-
-      // create a new document
-      await tester.createNewPageWithName();
-
       // mock the clipboard
       const lines = 3;
-      AppFlowyClipboard.mockSetData(
-        AppFlowyClipboardData(
-          text: List.generate(lines, (index) => 'line $index').join('\n'),
-        ),
+      await tester.pasteContent(
+        plainText: List.generate(lines, (index) => 'line $index').join('\n'),
+        (editorState) {
+          expect(editorState.document.root.children.length, 3);
+          for (var i = 0; i < lines; i++) {
+            expect(
+              editorState.getNodeAtPath([i])!.delta!.toPlainText(),
+              'line $i',
+            );
+          }
+        },
       );
+    });
 
-      // paste the text
-      await tester.simulateKeyEvent(
-        LogicalKeyboardKey.keyV,
-        isControlPressed: Platform.isLinux || Platform.isWindows,
-        isMetaPressed: Platform.isMacOS,
+    // ## **User Installation**
+    // - [Windows/Mac/Linux](https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/mac-windows-linux-packages)
+    // - [Docker](https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/installing-with-docker)
+    // - [Source](https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/from-source)
+    testWidgets('paste content from html, sample 1', (tester) async {
+      await tester.pasteContent(
+        html:
+            '''<meta charset='utf-8'><h2><strong>User Installation</strong></h2>
+<ul>
+<li><a href="https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/mac-windows-linux-packages">Windows/Mac/Linux</a></li>
+<li><a href="https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/installing-with-docker">Docker</a></li>
+<li><a href="https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/from-source">Source</a></li>
+</ul>''',
+        (editorState) {
+          expect(editorState.document.root.children.length, 4);
+          final node1 = editorState.getNodeAtPath([0])!;
+          final node2 = editorState.getNodeAtPath([1])!;
+          final node3 = editorState.getNodeAtPath([2])!;
+          final node4 = editorState.getNodeAtPath([3])!;
+          expect(node1.delta!.toJson(), [
+            {
+              "insert": "User Installation",
+              "attributes": {"bold": true},
+            }
+          ]);
+          expect(node2.delta!.toJson(), [
+            {
+              "insert": "Windows/Mac/Linux",
+              "attributes": {
+                "href":
+                    "https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/mac-windows-linux-packages",
+              },
+            }
+          ]);
+          expect(
+            node3.delta!.toJson(),
+            [
+              {
+                "insert": "Docker",
+                "attributes": {
+                  "href":
+                      "https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/installing-with-docker",
+                },
+              }
+            ],
+          );
+          expect(
+            node4.delta!.toJson(),
+            [
+              {
+                "insert": "Source",
+                "attributes": {
+                  "href":
+                      "https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/from-source",
+                },
+              }
+            ],
+          );
+        },
       );
-      await tester.pumpAndSettle();
-
-      final editorState = tester.editor.getCurrentEditorState();
-      expect(editorState.document.root.children.length, 4);
-      for (var i = 0; i < lines; i++) {
-        expect(
-          editorState.getNodeAtPath([i])!.delta!.toPlainText(),
-          'line $i',
-        );
-      }
+    });
+
+    testWidgets('paste code from VSCode', (tester) async {
+      await tester.pasteContent(
+          html:
+              '''<meta charset='utf-8'><div style="color: #bbbbbb;background-color: #262335;font-family: Consolas, 'JetBrains Mono', monospace, 'cascadia code', Menlo, Monaco, 'Courier New', monospace;font-weight: normal;font-size: 14px;line-height: 21px;white-space: pre;"><div><span style="color: #fede5d;">void</span><span style="color: #ff7edb;"> </span><span style="color: #36f9f6;">main</span><span style="color: #ff7edb;">() {</span></div><div><span style="color: #ff7edb;">  </span><span style="color: #36f9f6;">runApp</span><span style="color: #ff7edb;">(</span><span style="color: #fede5d;">const</span><span style="color: #ff7edb;"> </span><span style="color: #fe4450;">MyApp</span><span style="color: #ff7edb;">());</span></div><div><span style="color: #ff7edb;">}</span></div></div>''',
+          (editorState) {
+        expect(editorState.document.root.children.length, 3);
+        final node1 = editorState.getNodeAtPath([0])!;
+        final node2 = editorState.getNodeAtPath([1])!;
+        final node3 = editorState.getNodeAtPath([2])!;
+        expect(node1.type, ParagraphBlockKeys.type);
+        expect(node2.type, ParagraphBlockKeys.type);
+        expect(node3.type, ParagraphBlockKeys.type);
+        expect(node1.delta!.toJson(), [
+          {
+            "insert": "void",
+            "attributes": {"font_color": "0xfffede5d"},
+          },
+          {
+            "insert": " ",
+            "attributes": {"font_color": "0xffff7edb"},
+          },
+          {
+            "insert": "main",
+            "attributes": {"font_color": "0xff36f9f6"},
+          },
+          {
+            "insert": "() {",
+            "attributes": {"font_color": "0xffff7edb"},
+          }
+        ]);
+        expect(node2.delta!.toJson(), [
+          {
+            "insert": "  ",
+            "attributes": {"font_color": "0xffff7edb"},
+          },
+          {
+            "insert": "runApp",
+            "attributes": {"font_color": "0xff36f9f6"},
+          },
+          {
+            "insert": "(",
+            "attributes": {"font_color": "0xffff7edb"},
+          },
+          {
+            "insert": "const",
+            "attributes": {"font_color": "0xfffede5d"},
+          },
+          {
+            "insert": " ",
+            "attributes": {"font_color": "0xffff7edb"},
+          },
+          {
+            "insert": "MyApp",
+            "attributes": {"font_color": "0xfffe4450"},
+          },
+          {
+            "insert": "());",
+            "attributes": {"font_color": "0xffff7edb"},
+          }
+        ]);
+        expect(node3.delta!.toJson(), [
+          {
+            "insert": "}",
+            "attributes": {"font_color": "0xffff7edb"},
+          }
+        ]);
+      });
     });
   });
+
+  testWidgets('paste image(png) from memory', (tester) async {
+    final image = await rootBundle.load('assets/test/images/sample.png');
+    final bytes = image.buffer.asUint8List();
+    await tester.pasteContent(image: ('png', bytes), (editorState) {
+      expect(editorState.document.root.children.length, 2);
+      final node = editorState.getNodeAtPath([0])!;
+      expect(node.type, ImageBlockKeys.type);
+      expect(node.attributes[ImageBlockKeys.url], isNotNull);
+    });
+  });
+
+  testWidgets('paste image(jpeg) from memory', (tester) async {
+    final image = await rootBundle.load('assets/test/images/sample.jpeg');
+    final bytes = image.buffer.asUint8List();
+    await tester.pasteContent(image: ('jpeg', bytes), (editorState) {
+      expect(editorState.document.root.children.length, 2);
+      final node = editorState.getNodeAtPath([0])!;
+      expect(node.type, ImageBlockKeys.type);
+      expect(node.attributes[ImageBlockKeys.url], isNotNull);
+    });
+  });
+
+  testWidgets('paste image(gif) from memory', (tester) async {
+    // It's not supported yet.
+    // final image = await rootBundle.load('assets/test/images/sample.gif');
+    // final bytes = image.buffer.asUint8List();
+    // await tester.pasteContent(image: ('gif', bytes), (editorState) {
+    //   expect(editorState.document.root.children.length, 2);
+    //   final node = editorState.getNodeAtPath([0])!;
+    //   expect(node.type, ImageBlockKeys.type);
+    //   expect(node.attributes[ImageBlockKeys.url], isNotNull);
+    // });
+  });
+}
+
+extension on WidgetTester {
+  Future<void> pasteContent(
+    void Function(EditorState editorState) test, {
+    String? plainText,
+    String? html,
+    (String, Uint8List?)? image,
+  }) async {
+    await initializeAppFlowy();
+    await tapGoButton();
+
+    // create a new document
+    await createNewPageWithName();
+
+    // mock the clipboard
+    getIt<ClipboardService>().setData(
+      ClipboardServiceData(
+        plainText: plainText,
+        html: html,
+        image: image,
+      ),
+    );
+
+    // paste the text
+    await simulateKeyEvent(
+      LogicalKeyboardKey.keyV,
+      isControlPressed: Platform.isLinux || Platform.isWindows,
+      isMetaPressed: Platform.isMacOS,
+    );
+    await pumpAndSettle();
+
+    final editorState = editor.getCurrentEditorState();
+    test(editorState);
+  }
 }

+ 2 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart

@@ -50,6 +50,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
   final List<CommandShortcutEvent> commandShortcutEvents = [
     toggleToggleListCommand,
     ...codeBlockCommands,
+    customCopyCommand,
+    customPasteCommand,
     ...standardCommandShortcutEvents,
   ];
 

+ 129 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart

@@ -0,0 +1,129 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:appflowy_backend/log.dart';
+import 'package:flutter/foundation.dart';
+import 'package:super_clipboard/super_clipboard.dart';
+
+/// Used for in-app copy and paste without losing the format.
+///
+/// It's a Json string representing the copied editor nodes.
+final inAppJsonFormat = CustomValueFormat<String>(
+  applicationId: 'io.appflowy.InAppJsonType',
+  onDecode: (value, platformType) async {
+    if (value is PlatformDataProvider) {
+      final data = await value.getData(platformType);
+      if (data is List<int>) {
+        return utf8.decode(data, allowMalformed: true);
+      }
+      if (data is String) {
+        return Uri.decodeFull(data);
+      }
+    }
+    return null;
+  },
+);
+
+class ClipboardServiceData {
+  const ClipboardServiceData({
+    this.plainText,
+    this.html,
+    this.image,
+    this.inAppJson,
+  });
+
+  final String? plainText;
+  final String? html;
+  final (String, Uint8List?)? image;
+  final String? inAppJson;
+}
+
+class ClipboardService {
+  Future<void> setData(ClipboardServiceData data) async {
+    final plainText = data.plainText;
+    final html = data.html;
+    final inAppJson = data.inAppJson;
+    final image = data.image;
+
+    final item = DataWriterItem();
+    if (plainText != null) {
+      item.add(Formats.plainText(plainText));
+    }
+    if (html != null) {
+      item.add(Formats.htmlText(html));
+    }
+    if (inAppJson != null) {
+      item.add(inAppJsonFormat(inAppJson));
+    }
+    if (image != null && image.$2?.isNotEmpty == true) {
+      switch (image.$1) {
+        case 'png':
+          item.add(Formats.png(image.$2!));
+          break;
+        case 'jpeg':
+          item.add(Formats.jpeg(image.$2!));
+          break;
+        case 'gif':
+          item.add(Formats.gif(image.$2!));
+          break;
+        default:
+          throw Exception('unsupported image format: ${image.$1}');
+      }
+    }
+    await ClipboardWriter.instance.write([item]);
+  }
+
+  Future<ClipboardServiceData> getData() async {
+    final reader = await ClipboardReader.readClipboard();
+
+    for (final item in reader.items) {
+      final availableFormats = await item.rawReader!.getAvailableFormats();
+      Log.debug(
+        'availableFormats: $availableFormats',
+      );
+    }
+
+    final plainText = await reader.readValue(Formats.plainText);
+    final html = await reader.readValue(Formats.htmlText);
+    final inAppJson = await reader.readValue(inAppJsonFormat);
+    (String, Uint8List?)? image;
+    if (reader.canProvide(Formats.png)) {
+      image = ('png', await reader.readFile(Formats.png));
+    } else if (reader.canProvide(Formats.jpeg)) {
+      image = ('jpeg', await reader.readFile(Formats.jpeg));
+    } else if (reader.canProvide(Formats.gif)) {
+      image = ('gif', await reader.readFile(Formats.gif));
+    }
+
+    return ClipboardServiceData(
+      plainText: plainText,
+      html: html,
+      image: image,
+      inAppJson: inAppJson,
+    );
+  }
+}
+
+extension on DataReader {
+  Future<Uint8List?>? readFile(FileFormat format) {
+    final c = Completer<Uint8List?>();
+    final progress = getFile(
+      format,
+      (file) async {
+        try {
+          final all = await file.readAll();
+          c.complete(all);
+        } catch (e) {
+          c.completeError(e);
+        }
+      },
+      onError: (e) {
+        c.completeError(e);
+      },
+    );
+    if (progress == null) {
+      c.complete(null);
+    }
+    return c.future;
+  }
+}

+ 52 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_copy_command.dart

@@ -0,0 +1,52 @@
+import 'dart:convert';
+
+import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
+import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter/material.dart';
+
+/// Copy.
+///
+/// - support
+///   - desktop
+///   - web
+///   - mobile
+///
+final CommandShortcutEvent customCopyCommand = CommandShortcutEvent(
+  key: 'copy the selected content',
+  command: 'ctrl+c',
+  macOSCommand: 'cmd+c',
+  handler: _copyCommandHandler,
+);
+
+CommandShortcutEventHandler _copyCommandHandler = (editorState) {
+  final selection = editorState.selection?.normalized;
+  if (selection == null || selection.isCollapsed) {
+    return KeyEventResult.ignored;
+  }
+
+  // plain text.
+  final text = editorState.getTextInSelection(selection).join('\n');
+
+  final nodes = editorState.getSelectedNodes(selection);
+  final document = Document.blank()..insert([0], nodes);
+
+  // in app json
+  final inAppJson = jsonEncode(document.toJson());
+
+  // html
+  final html = documentToHTML(document);
+
+  () async {
+    await getIt<ClipboardService>().setData(
+      ClipboardServiceData(
+        plainText: text,
+        html: html,
+        inAppJson: inAppJson,
+        image: null,
+      ),
+    );
+  }();
+
+  return KeyEventResult.handled;
+};

+ 62 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart

@@ -0,0 +1,62 @@
+import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_in_app_json.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart';
+import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter/material.dart';
+
+/// Paste.
+///
+/// - support
+///   - desktop
+///   - web
+///   - mobile
+///
+final CommandShortcutEvent customPasteCommand = CommandShortcutEvent(
+  key: 'paste the content',
+  command: 'ctrl+v',
+  macOSCommand: 'cmd+v',
+  handler: _pasteCommandHandler,
+);
+
+CommandShortcutEventHandler _pasteCommandHandler = (editorState) {
+  final selection = editorState.selection;
+  if (selection == null) {
+    return KeyEventResult.ignored;
+  }
+
+  // because the event handler is not async, so we need to use wrap the async function here
+  () async {
+    // dispatch the paste event
+    final data = await getIt<ClipboardService>().getData();
+    final inAppJson = data.inAppJson;
+    final html = data.html;
+    final plainText = data.plainText;
+    final image = data.image;
+
+    // Order:
+    // 1. in app json format
+    // 2. html
+    // 3. image
+    // 4. plain text
+
+    if (inAppJson != null && inAppJson.isNotEmpty) {
+      await editorState.deleteSelectionIfNeeded();
+      await editorState.pasteInAppJson(inAppJson);
+    } else if (html != null && html.isNotEmpty) {
+      await editorState.deleteSelectionIfNeeded();
+      await editorState.pasteHtml(html);
+    } else if (image != null && image.$2?.isNotEmpty == true) {
+      await editorState.deleteSelectionIfNeeded();
+      await editorState.pasteImage(image.$1, image.$2!);
+    } else if (plainText != null && plainText.isNotEmpty) {
+      await editorState.deleteSelectionIfNeeded();
+      await editorState.pastePlainText(plainText);
+    }
+  }();
+
+  return KeyEventResult.handled;
+};

+ 155 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart

@@ -0,0 +1,155 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+
+extension PasteNodes on EditorState {
+  Future<void> pasteSingleLineNode(Node insertedNode) async {
+    final selection = await deleteSelectionIfNeeded();
+    if (selection == null) {
+      return;
+    }
+    final node = getNodeAtPath(selection.start.path);
+    final delta = node?.delta;
+    if (node == null || delta == null) {
+      return;
+    }
+    final transaction = this.transaction;
+    final insertedDelta = insertedNode.delta;
+    // if the node is empty, replace it with the inserted node.
+    if (delta.isEmpty || insertedDelta == null) {
+      transaction.insertNode(
+        selection.end.path.next,
+        node.copyWith(
+          type: node.type,
+          attributes: {
+            ...node.attributes,
+            ...insertedNode.attributes,
+          },
+        ),
+      );
+      transaction.deleteNode(node);
+      transaction.afterSelection = Selection.collapsed(
+        Position(
+          path: selection.end.path,
+          offset: insertedDelta?.length ?? 0,
+        ),
+      );
+    } else {
+      // if the node is not empty, insert the delta from inserted node after the selection.
+      transaction.insertTextDelta(node, selection.endIndex, insertedDelta);
+    }
+    await apply(transaction);
+  }
+
+  Future<void> pasteMultiLineNodes(List<Node> nodes) async {
+    assert(nodes.length > 1);
+
+    final selection = await deleteSelectionIfNeeded();
+    if (selection == null) {
+      return;
+    }
+    final node = getNodeAtPath(selection.start.path);
+    final delta = node?.delta;
+    if (node == null || delta == null) {
+      return;
+    }
+    final transaction = this.transaction;
+
+    final lastNodeLength = nodes.last.delta?.length ?? 0;
+    // merge the current selected node delta into the nodes.
+    if (delta.isNotEmpty) {
+      nodes.first.insertDelta(
+        delta.slice(0, selection.startIndex),
+        insertAfter: false,
+      );
+
+      nodes.last.insertDelta(
+        delta.slice(selection.endIndex),
+        insertAfter: true,
+      );
+    }
+
+    if (delta.isEmpty && node.type != ParagraphBlockKeys.type) {
+      nodes[0] = nodes.first.copyWith(
+        type: node.type,
+        attributes: {
+          ...node.attributes,
+          ...nodes.first.attributes,
+        },
+      );
+    }
+
+    for (final child in node.children) {
+      nodes.last.insert(child);
+    }
+
+    transaction.insertNodes(selection.end.path, nodes);
+
+    // delete the current node.
+    transaction.deleteNode(node);
+
+    var path = selection.end.path;
+    for (var i = 0; i < nodes.length; i++) {
+      path = path.next;
+    }
+    transaction.afterSelection = Selection.collapsed(
+      Position(
+        path: path.previous, // because a node is deleted.
+        offset: lastNodeLength,
+      ),
+    );
+
+    await apply(transaction);
+  }
+
+  // delete the selection if it's not collapsed.
+  Future<Selection?> deleteSelectionIfNeeded() async {
+    final selection = this.selection;
+    if (selection == null) {
+      return null;
+    }
+
+    // delete the selection first.
+    if (!selection.isCollapsed) {
+      deleteSelection(selection);
+    }
+
+    // fetch selection again.selection = editorState.selection;
+    assert(this.selection?.isCollapsed == true);
+    return this.selection;
+  }
+}
+
+extension on Node {
+  void insertDelta(Delta delta, {bool insertAfter = true}) {
+    assert(delta.every((element) => element is TextInsert));
+    if (this.delta == null) {
+      updateAttributes({
+        blockComponentDelta: delta.toJson(),
+      });
+    } else if (insertAfter) {
+      updateAttributes(
+        {
+          blockComponentDelta: this
+              .delta!
+              .compose(
+                Delta()
+                  ..retain(this.delta!.length)
+                  ..addAll(delta),
+              )
+              .toJson(),
+        },
+      );
+    } else {
+      updateAttributes(
+        {
+          blockComponentDelta: delta
+              .compose(
+                Delta()
+                  ..retain(delta.length)
+                  ..addAll(this.delta!),
+              )
+              .toJson(),
+        },
+      );
+    }
+  }
+}

+ 23 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart

@@ -0,0 +1,23 @@
+import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+
+extension PasteFromHtml on EditorState {
+  Future<void> pasteHtml(String html) async {
+    final nodes = htmlToDocument(html).root.children.toList();
+    // remove the front and back empty line
+    while (nodes.isNotEmpty && nodes.first.delta?.isEmpty == true) {
+      nodes.removeAt(0);
+    }
+    while (nodes.isNotEmpty && nodes.last.delta?.isEmpty == true) {
+      nodes.removeLast();
+    }
+    if (nodes.isEmpty) {
+      return;
+    }
+    if (nodes.length == 1) {
+      await pasteSingleLineNode(nodes.first);
+    } else {
+      await pasteMultiLineNodes(nodes.toList());
+    }
+  }
+}

+ 44 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart

@@ -0,0 +1,44 @@
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy/workspace/application/settings/application_data_storage.dart';
+import 'package:appflowy_backend/log.dart';
+import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
+import 'package:flowy_infra/uuid.dart';
+import 'package:path/path.dart' as p;
+
+extension PasteFromImage on EditorState {
+  static final supportedImageFormats = [
+    'png',
+    'jpeg',
+    'gif',
+  ];
+
+  Future<void> pasteImage(String format, Uint8List imageBytes) async {
+    if (!supportedImageFormats.contains(format)) {
+      return;
+    }
+
+    final path = await getIt<ApplicationDataStorage>().getPath();
+    final imagePath = p.join(
+      path,
+      'images',
+    );
+    try {
+      // create the directory if not exists
+      final directory = Directory(imagePath);
+      if (!directory.existsSync()) {
+        await directory.create(recursive: true);
+      }
+      final copyToPath = p.join(
+        imagePath,
+        '${uuid()}.$format',
+      );
+      await File(copyToPath).writeAsBytes(imageBytes);
+      await insertImageNode(copyToPath);
+    } catch (e) {
+      Log.error('cannot copy image file', e);
+    }
+  }
+}

+ 25 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_in_app_json.dart

@@ -0,0 +1,25 @@
+import 'dart:convert';
+
+import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart';
+import 'package:appflowy_backend/log.dart';
+import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
+
+extension PasteFromInAppJson on EditorState {
+  Future<void> pasteInAppJson(String inAppJson) async {
+    try {
+      final nodes = Document.fromJson(jsonDecode(inAppJson)).root.children;
+      if (nodes.isEmpty) {
+        return;
+      }
+      if (nodes.length == 1) {
+        await pasteSingleLineNode(nodes.first);
+      } else {
+        await pasteMultiLineNodes(nodes.toList());
+      }
+    } catch (e) {
+      Log.error(
+        'Failed to paste in app json: $inAppJson, error: $e',
+      );
+    }
+  }
+}

+ 36 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart

@@ -0,0 +1,36 @@
+import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/editor_state_paste_node_extension.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+
+RegExp _hrefRegex = RegExp(
+  r'https?://(?:www\.)?[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,}(?:/[^\s]*)?',
+);
+
+extension PasteFromPlainText on EditorState {
+  Future<void> pastePlainText(String plainText) async {
+    final nodes = plainText
+        .split('\n')
+        .map(
+          (e) => e
+            ..replaceAll(r'\r', '')
+            ..trimRight(),
+        )
+        .map((e) {
+          // parse the url content
+          final Attributes attributes = {};
+          if (_hrefRegex.hasMatch(e)) {
+            attributes[AppFlowyRichTextKeys.href] = e;
+          }
+          return Delta()..insert(e, attributes: attributes);
+        })
+        .map((e) => paragraphNode(delta: e))
+        .toList();
+    if (nodes.isEmpty) {
+      return;
+    }
+    if (nodes.length == 1) {
+      await pasteSingleLineNode(nodes.first);
+    } else {
+      await pasteMultiLineNodes(nodes.toList());
+    }
+  }
+}

+ 2 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart

@@ -3,6 +3,8 @@ export 'actions/option_action.dart';
 export 'callout/callout_block_component.dart';
 export 'code_block/code_block_component.dart';
 export 'code_block/code_block_shortcut_event.dart';
+export 'copy_and_paste/custom_copy_command.dart';
+export 'copy_and_paste/custom_paste_command.dart';
 export 'database/database_view_block_component.dart';
 export 'database/inline_database_menu_item.dart';
 export 'database/referenced_database_menu_item.dart';

+ 15 - 10
frontend/appflowy_flutter/lib/startup/deps_resolver.dart

@@ -6,28 +6,29 @@ import 'package:appflowy/plugins/database_view/application/field/field_controlle
 import 'package:appflowy/plugins/database_view/application/field/field_service.dart';
 import 'package:appflowy/plugins/database_view/application/setting/property_bloc.dart';
 import 'package:appflowy/plugins/database_view/grid/application/grid_header_bloc.dart';
+import 'package:appflowy/plugins/document/application/prelude.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/service/openai_client.dart';
+import 'package:appflowy/plugins/trash/application/prelude.dart';
 import 'package:appflowy/startup/startup.dart';
 import 'package:appflowy/user/application/auth/auth_service.dart';
 import 'package:appflowy/user/application/auth/supabase_auth_service.dart';
+import 'package:appflowy/user/application/prelude.dart';
 import 'package:appflowy/user/application/user_listener.dart';
 import 'package:appflowy/user/application/user_service.dart';
-import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
-import 'package:flowy_infra/file_picker/file_picker_impl.dart';
-import 'package:flowy_infra/file_picker/file_picker_service.dart';
-import 'package:appflowy/plugins/document/application/prelude.dart';
+import 'package:appflowy/user/presentation/router.dart';
+import 'package:appflowy/workspace/application/edit_panel/edit_panel_bloc.dart';
 import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
+import 'package:appflowy/workspace/application/settings/prelude.dart';
+import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
 import 'package:appflowy/workspace/application/user/prelude.dart';
-import 'package:appflowy/workspace/application/workspace/prelude.dart';
-import 'package:appflowy/workspace/application/edit_panel/edit_panel_bloc.dart';
 import 'package:appflowy/workspace/application/view/prelude.dart';
-import 'package:appflowy/workspace/application/settings/prelude.dart';
-import 'package:appflowy/user/application/prelude.dart';
-import 'package:appflowy/user/presentation/router.dart';
-import 'package:appflowy/plugins/trash/application/prelude.dart';
+import 'package:appflowy/workspace/application/workspace/prelude.dart';
 import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
+import 'package:flowy_infra/file_picker/file_picker_impl.dart';
+import 'package:flowy_infra/file_picker/file_picker_service.dart';
 import 'package:fluttertoast/fluttertoast.dart';
 import 'package:get_it/get_it.dart';
 import 'package:http/http.dart' as http;
@@ -79,6 +80,10 @@ void _resolveCommonService(
       );
     },
   );
+
+  getIt.registerFactory<ClipboardService>(
+    () => ClipboardService(),
+  );
 }
 
 void _resolveUserDeps(GetIt getIt) {

+ 42 - 2
frontend/appflowy_flutter/pubspec.lock

@@ -318,10 +318,10 @@ packages:
     dependency: "direct main"
     description:
       name: device_info_plus
-      sha256: "499c61743e13909c13374a8c209075385858c614b9c0f2487b5f9995eeaf7369"
+      sha256: "86add5ef97215562d2e090535b0a16f197902b10c369c558a100e74ea06e8659"
       url: "https://pub.dev"
     source: hosted
-    version: "9.0.1"
+    version: "9.0.3"
   device_info_plus_platform_interface:
     dependency: transitive
     description:
@@ -721,6 +721,22 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.0.4"
+  irondash_engine_context:
+    dependency: transitive
+    description:
+      name: irondash_engine_context
+      sha256: "6431b11844472574a90803c02f1e55221e6a390a872786735f6757a67dacd678"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.3.0"
+  irondash_message_channel:
+    dependency: transitive
+    description:
+      name: irondash_message_channel
+      sha256: "4114739083d1c63e6a1a8b93f09dd69b3cf9a9d6ee215ae7f23079307197ebba"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.3.0"
   isolates:
     dependency: transitive
     description:
@@ -1001,6 +1017,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "5.4.0"
+  pixel_snap:
+    dependency: transitive
+    description:
+      name: pixel_snap
+      sha256: "5de3662b926c9bc189578cf90f9d5b350ee61bc8e20e8a91fa1dfdd26c9f5ece"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.1.2"
   platform:
     dependency: transitive
     description:
@@ -1423,6 +1447,22 @@ packages:
       url: "https://github.com/LucasXu0/supabase-flutter"
     source: git
     version: "1.10.12"
+  super_clipboard:
+    dependency: "direct main"
+    description:
+      name: super_clipboard
+      sha256: "204284b1a721d33a65bcab077b191a3b7379b46a231f05688d17220153338ede"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.6.0"
+  super_native_extensions:
+    dependency: transitive
+    description:
+      name: super_native_extensions
+      sha256: "1f15e9b1adb0bc59cf9b889a0b248f3c192fa17e2d5c923aeeec6d4fa2eeffd6"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.6.0"
   sync_http:
     dependency: transitive
     description:

+ 2 - 0
frontend/appflowy_flutter/pubspec.yaml

@@ -107,6 +107,7 @@ dependencies:
   url_protocol:
   hive: ^2.2.3
   hive_flutter: ^1.1.0
+  super_clipboard: ^0.6.0
 
 dev_dependencies:
   flutter_lints: ^2.0.1
@@ -200,6 +201,7 @@ flutter:
     # The following assets will be excluded in release.
     # BEGIN: EXCLUDE_IN_RELEASE
     - assets/test/workspaces/
+    - assets/test/images/
     - assets/template/
     - assets/test/workspaces/markdowns/
     - assets/test/workspaces/database/