mirror of
				https://github.com/AppFlowy-IO/AppFlowy.git
				synced 2025-11-03 19:43:52 +00:00 
			
		
		
		
	feat: improve copy paste plugins, and support in-app copy-paste (#3233)
This commit is contained in:
		
							parent
							
								
									30155924a9
								
							
						
					
					
						commit
						bd30e31f6c
					
				
							
								
								
									
										
											BIN
										
									
								
								frontend/appflowy_flutter/assets/test/images/sample.gif
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/appflowy_flutter/assets/test/images/sample.gif
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 96 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								frontend/appflowy_flutter/assets/test/images/sample.jpeg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/appflowy_flutter/assets/test/images/sample.jpeg
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 22 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								frontend/appflowy_flutter/assets/test/images/sample.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/appflowy_flutter/assets/test/images/sample.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 38 KiB  | 
@ -1,5 +1,7 @@
 | 
				
			|||||||
import 'dart:io';
 | 
					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:appflowy_editor/appflowy_editor.dart';
 | 
				
			||||||
import 'package:flutter/services.dart';
 | 
					import 'package:flutter/services.dart';
 | 
				
			||||||
import 'package:flutter_test/flutter_test.dart';
 | 
					import 'package:flutter_test/flutter_test.dart';
 | 
				
			||||||
@ -12,36 +14,220 @@ void main() {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  group('copy and paste in document', () {
 | 
					  group('copy and paste in document', () {
 | 
				
			||||||
    testWidgets('paste multiple lines at the first line', (tester) async {
 | 
					    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
 | 
					      // mock the clipboard
 | 
				
			||||||
      const lines = 3;
 | 
					      const lines = 3;
 | 
				
			||||||
      AppFlowyClipboard.mockSetData(
 | 
					      await tester.pasteContent(
 | 
				
			||||||
        AppFlowyClipboardData(
 | 
					        plainText: List.generate(lines, (index) => 'line $index').join('\n'),
 | 
				
			||||||
          text: 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
 | 
					    // ## **User Installation**
 | 
				
			||||||
      await tester.simulateKeyEvent(
 | 
					    // - [Windows/Mac/Linux](https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/mac-windows-linux-packages)
 | 
				
			||||||
        LogicalKeyboardKey.keyV,
 | 
					    // - [Docker](https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/installing-with-docker)
 | 
				
			||||||
        isControlPressed: Platform.isLinux || Platform.isWindows,
 | 
					    // - [Source](https://appflowy.gitbook.io/docs/essential-documentation/install-appflowy/installation-methods/from-source)
 | 
				
			||||||
        isMetaPressed: Platform.isMacOS,
 | 
					    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();
 | 
					    testWidgets('paste code from VSCode', (tester) async {
 | 
				
			||||||
      expect(editorState.document.root.children.length, 4);
 | 
					      await tester.pasteContent(
 | 
				
			||||||
      for (var i = 0; i < lines; i++) {
 | 
					          html:
 | 
				
			||||||
        expect(
 | 
					              '''<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.getNodeAtPath([i])!.delta!.toPlainText(),
 | 
					          (editorState) {
 | 
				
			||||||
          'line $i',
 | 
					        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);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -50,6 +50,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
 | 
				
			|||||||
  final List<CommandShortcutEvent> commandShortcutEvents = [
 | 
					  final List<CommandShortcutEvent> commandShortcutEvents = [
 | 
				
			||||||
    toggleToggleListCommand,
 | 
					    toggleToggleListCommand,
 | 
				
			||||||
    ...codeBlockCommands,
 | 
					    ...codeBlockCommands,
 | 
				
			||||||
 | 
					    customCopyCommand,
 | 
				
			||||||
 | 
					    customPasteCommand,
 | 
				
			||||||
    ...standardCommandShortcutEvents,
 | 
					    ...standardCommandShortcutEvents,
 | 
				
			||||||
  ];
 | 
					  ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -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;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -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;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@ -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;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@ -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(),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -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());
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -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);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -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',
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -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());
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -3,6 +3,8 @@ export 'actions/option_action.dart';
 | 
				
			|||||||
export 'callout/callout_block_component.dart';
 | 
					export 'callout/callout_block_component.dart';
 | 
				
			||||||
export 'code_block/code_block_component.dart';
 | 
					export 'code_block/code_block_component.dart';
 | 
				
			||||||
export 'code_block/code_block_shortcut_event.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/database_view_block_component.dart';
 | 
				
			||||||
export 'database/inline_database_menu_item.dart';
 | 
					export 'database/inline_database_menu_item.dart';
 | 
				
			||||||
export 'database/referenced_database_menu_item.dart';
 | 
					export 'database/referenced_database_menu_item.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/field/field_service.dart';
 | 
				
			||||||
import 'package:appflowy/plugins/database_view/application/setting/property_bloc.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/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/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/startup/startup.dart';
 | 
				
			||||||
import 'package:appflowy/user/application/auth/auth_service.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/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_listener.dart';
 | 
				
			||||||
import 'package:appflowy/user/application/user_service.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/workspace/application/favorite/favorite_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/user/presentation/router.dart';
 | 
				
			||||||
import 'package:appflowy/plugins/trash/application/prelude.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/view/prelude.dart';
 | 
				
			||||||
 | 
					import 'package:appflowy/workspace/application/workspace/prelude.dart';
 | 
				
			||||||
import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.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-folder2/view.pb.dart';
 | 
				
			||||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.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:fluttertoast/fluttertoast.dart';
 | 
				
			||||||
import 'package:get_it/get_it.dart';
 | 
					import 'package:get_it/get_it.dart';
 | 
				
			||||||
import 'package:http/http.dart' as http;
 | 
					import 'package:http/http.dart' as http;
 | 
				
			||||||
@ -79,6 +80,10 @@ void _resolveCommonService(
 | 
				
			|||||||
      );
 | 
					      );
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getIt.registerFactory<ClipboardService>(
 | 
				
			||||||
 | 
					    () => ClipboardService(),
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
void _resolveUserDeps(GetIt getIt) {
 | 
					void _resolveUserDeps(GetIt getIt) {
 | 
				
			||||||
 | 
				
			|||||||
@ -318,10 +318,10 @@ packages:
 | 
				
			|||||||
    dependency: "direct main"
 | 
					    dependency: "direct main"
 | 
				
			||||||
    description:
 | 
					    description:
 | 
				
			||||||
      name: device_info_plus
 | 
					      name: device_info_plus
 | 
				
			||||||
      sha256: "499c61743e13909c13374a8c209075385858c614b9c0f2487b5f9995eeaf7369"
 | 
					      sha256: "86add5ef97215562d2e090535b0a16f197902b10c369c558a100e74ea06e8659"
 | 
				
			||||||
      url: "https://pub.dev"
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
    source: hosted
 | 
					    source: hosted
 | 
				
			||||||
    version: "9.0.1"
 | 
					    version: "9.0.3"
 | 
				
			||||||
  device_info_plus_platform_interface:
 | 
					  device_info_plus_platform_interface:
 | 
				
			||||||
    dependency: transitive
 | 
					    dependency: transitive
 | 
				
			||||||
    description:
 | 
					    description:
 | 
				
			||||||
@ -721,6 +721,22 @@ packages:
 | 
				
			|||||||
      url: "https://pub.dev"
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
    source: hosted
 | 
					    source: hosted
 | 
				
			||||||
    version: "1.0.4"
 | 
					    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:
 | 
					  isolates:
 | 
				
			||||||
    dependency: transitive
 | 
					    dependency: transitive
 | 
				
			||||||
    description:
 | 
					    description:
 | 
				
			||||||
@ -1001,6 +1017,14 @@ packages:
 | 
				
			|||||||
      url: "https://pub.dev"
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
    source: hosted
 | 
					    source: hosted
 | 
				
			||||||
    version: "5.4.0"
 | 
					    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:
 | 
					  platform:
 | 
				
			||||||
    dependency: transitive
 | 
					    dependency: transitive
 | 
				
			||||||
    description:
 | 
					    description:
 | 
				
			||||||
@ -1423,6 +1447,22 @@ packages:
 | 
				
			|||||||
      url: "https://github.com/LucasXu0/supabase-flutter"
 | 
					      url: "https://github.com/LucasXu0/supabase-flutter"
 | 
				
			||||||
    source: git
 | 
					    source: git
 | 
				
			||||||
    version: "1.10.12"
 | 
					    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:
 | 
					  sync_http:
 | 
				
			||||||
    dependency: transitive
 | 
					    dependency: transitive
 | 
				
			||||||
    description:
 | 
					    description:
 | 
				
			||||||
 | 
				
			|||||||
@ -107,6 +107,7 @@ dependencies:
 | 
				
			|||||||
  url_protocol:
 | 
					  url_protocol:
 | 
				
			||||||
  hive: ^2.2.3
 | 
					  hive: ^2.2.3
 | 
				
			||||||
  hive_flutter: ^1.1.0
 | 
					  hive_flutter: ^1.1.0
 | 
				
			||||||
 | 
					  super_clipboard: ^0.6.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
dev_dependencies:
 | 
					dev_dependencies:
 | 
				
			||||||
  flutter_lints: ^2.0.1
 | 
					  flutter_lints: ^2.0.1
 | 
				
			||||||
@ -200,6 +201,7 @@ flutter:
 | 
				
			|||||||
    # The following assets will be excluded in release.
 | 
					    # The following assets will be excluded in release.
 | 
				
			||||||
    # BEGIN: EXCLUDE_IN_RELEASE
 | 
					    # BEGIN: EXCLUDE_IN_RELEASE
 | 
				
			||||||
    - assets/test/workspaces/
 | 
					    - assets/test/workspaces/
 | 
				
			||||||
 | 
					    - assets/test/images/
 | 
				
			||||||
    - assets/template/
 | 
					    - assets/template/
 | 
				
			||||||
    - assets/test/workspaces/markdowns/
 | 
					    - assets/test/workspaces/markdowns/
 | 
				
			||||||
    - assets/test/workspaces/database/
 | 
					    - assets/test/workspaces/database/
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user