diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart index 5e03490113933..8da4aeccce3cf 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_copy_and_paste_test.dart @@ -1,7 +1,5 @@ import 'dart:io'; -import 'package:flutter/services.dart'; - import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; @@ -11,6 +9,7 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -27,10 +26,13 @@ void main() { await tester.pasteContent( plainText: List.generate(lines, (index) => 'line $index').join('\n'), (editorState) { - expect(editorState.document.root.children.length, 3); + expect(editorState.document.root.children.length, 1); + final text = + editorState.document.root.children.first.delta!.toPlainText(); + final textLines = text.split('\n'); for (var i = 0; i < lines; i++) { expect( - editorState.getNodeAtPath([i])!.delta!.toPlainText(), + textLines[i], 'line $i', ); } @@ -467,6 +469,44 @@ void main() { expect(node.attributes[ImageBlockKeys.url], isNotEmpty); }); }); + + const testMarkdownText = ''' +# I'm h1 +## I'm h2 +### I'm h3 +#### I'm h4 +##### I'm h5 +###### I'm h6'''; + + testWidgets('paste markdowns', (tester) async { + await tester.pasteContent( + plainText: testMarkdownText, + (editorState) { + final children = editorState.document.root.children; + expect(children.length, 6); + for (int i = 1; i <= children.length; i++) { + final text = children[i - 1].delta!.toPlainText(); + expect(text, 'I\'m h$i'); + } + }, + ); + }); + + testWidgets('paste markdowns as plain', (tester) async { + await tester.pasteContent( + plainText: testMarkdownText, + pasteAsPlain: true, + (editorState) { + final children = editorState.document.root.children; + expect(children.length, 6); + for (int i = 1; i <= children.length; i++) { + final text = children[i - 1].delta!.toPlainText(); + final expectText = '${'#' * i} I\'m h$i'; + expect(text, expectText); + } + }, + ); + }); } extension on WidgetTester { @@ -476,6 +516,7 @@ extension on WidgetTester { String? plainText, String? html, String? inAppJson, + bool pasteAsPlain = false, (String, Uint8List?)? image, }) async { await initializeAppFlowy(); @@ -502,6 +543,7 @@ extension on WidgetTester { await simulateKeyEvent( LogicalKeyboardKey.keyV, isControlPressed: Platform.isLinux || Platform.isWindows, + isShiftPressed: pasteAsPlain, isMetaPressed: Platform.isMacOS, ); await pumpAndSettle(); diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_find_menu_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_find_menu_test.dart index 2c7cbdacb5a5a..6212e7d9cf180 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_find_menu_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_find_menu_test.dart @@ -37,12 +37,12 @@ void main() { // set clipboard data final data = [ - "123456\n", - ...List.generate(100, (_) => "${generateRandomString(50)}\n"), - "1234567\n", - ...List.generate(100, (_) => "${generateRandomString(50)}\n"), - "12345678\n", - ...List.generate(100, (_) => "${generateRandomString(50)}\n"), + "123456\n\n", + ...List.generate(100, (_) => "${generateRandomString(50)}\n\n"), + "1234567\n\n", + ...List.generate(100, (_) => "${generateRandomString(50)}\n\n"), + "12345678\n\n", + ...List.generate(100, (_) => "${generateRandomString(50)}\n\n"), ].join(); await getIt().setData( ClipboardServiceData( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/context_menu/custom_context_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/context_menu/custom_context_menu.dart index 6dc8724d26ef5..cc496ff9d46d7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/context_menu/custom_context_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/context_menu/custom_context_menu.dart @@ -13,6 +13,11 @@ final List> customContextMenuItems = [ getName: LocaleKeys.document_plugins_contextMenu_paste.tr, onPressed: (editorState) => customPasteCommand.execute(editorState), ), + ContextMenuItem( + getName: LocaleKeys.document_plugins_contextMenu_pasteAsPlainText.tr, + onPressed: (editorState) => + customPastePlainTextCommand.execute(editorState), + ), ContextMenuItem( getName: LocaleKeys.document_plugins_contextMenu_cut.tr, onPressed: (editorState) => customCutCommand.execute(editorState), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart index 60dd47c42d8cc..ed353103fb7e7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/custom_paste_command.dart @@ -29,6 +29,14 @@ final CommandShortcutEvent customPasteCommand = CommandShortcutEvent( handler: _pasteCommandHandler, ); +final CommandShortcutEvent customPastePlainTextCommand = CommandShortcutEvent( + key: 'paste the plain content', + getDescription: () => AppFlowyEditorL10n.current.cmdPasteContent, + command: 'ctrl+shift+v', + macOSCommand: 'cmd+shift+v', + handler: _pastePlainCommandHandler, +); + CommandShortcutEventHandler _pasteCommandHandler = (editorState) { final selection = editorState.selection; if (selection == null) { @@ -45,6 +53,22 @@ CommandShortcutEventHandler _pasteCommandHandler = (editorState) { return KeyEventResult.handled; }; +CommandShortcutEventHandler _pastePlainCommandHandler = (editorState) { + final selection = editorState.selection; + if (selection == null) { + return KeyEventResult.ignored; + } + + doPlainPaste(editorState).then((_) { + final context = editorState.document.root.context; + if (context != null && context.mounted) { + context.read().didPaste(); + } + }); + + return KeyEventResult.handled; +}; + Future doPaste(EditorState editorState) async { final selection = editorState.selection; if (selection == null) { @@ -119,7 +143,7 @@ Future doPaste(EditorState editorState) async { } if (plainText != null && plainText.isNotEmpty) { - await editorState.pastePlainText(plainText); + await editorState.pasteText(plainText); return Log.info('Pasted plain text'); } @@ -190,3 +214,24 @@ Future _pasteAsLinkPreview( return true; } + +Future doPlainPaste(EditorState editorState) async { + final selection = editorState.selection; + if (selection == null) { + return; + } + + EditorNotification.paste().post(); + + // dispatch the paste event + final data = await getIt().getData(); + final plainText = data.plainText; + if (plainText != null && plainText.isNotEmpty) { + await editorState.pastePlainText(plainText); + Log.info('Pasted plain text'); + return; + } + + Log.info('unable to parse the clipboard content'); + return; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart index b09c8c0dc4c6e..90ed4511283f5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart @@ -1,14 +1,10 @@ +import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; extension PasteFromPlainText on EditorState { Future pastePlainText(String plainText) async { - if (await pasteHtmlIfAvailable(plainText)) { - return; - } - await deleteSelectionIfNeeded(); - final nodes = plainText .split('\n') .map( @@ -16,14 +12,7 @@ extension PasteFromPlainText on EditorState { ..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) => Delta()..insert(e)) .map((e) => paragraphNode(delta: e)) .toList(); if (nodes.isEmpty) { @@ -36,6 +25,24 @@ extension PasteFromPlainText on EditorState { } } + Future pasteText(String plainText) async { + if (await pasteHtmlIfAvailable(plainText)) { + return; + } + + await deleteSelectionIfNeeded(); + + final nodes = customMarkdownToDocument(plainText).root.children; + if (nodes.isEmpty) { + return; + } + if (nodes.length == 1) { + await pasteSingleLineNode(nodes.first); + } else { + await pasteMultiLineNodes(nodes.toList()); + } + } + Future pasteHtmlIfAvailable(String plainText) async { final selection = this.selection; if (selection == null || diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart index a3d1630680e0d..8b168bcd30160 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart @@ -30,6 +30,7 @@ List commandShortcutEvents = [ customCopyCommand, customPasteCommand, + customPastePlainTextCommand, customCutCommand, customUndoCommand, customRedoCommand, @@ -43,6 +44,7 @@ List commandShortcutEvents = [ copyCommand, cutCommand, pasteCommand, + pasteTextWithoutFormattingCommand, toggleTodoListCommand, undoCommand, redoCommand, diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index d0e6351fc4a45..6cf3cac15a5d2 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -1841,7 +1841,8 @@ "contextMenu": { "copy": "Copy", "cut": "Cut", - "paste": "Paste" + "paste": "Paste", + "pasteAsPlainText": "Paste as plain text" }, "action": "Actions", "database": {