feat: add option to paste plain text (#7045)

* feat: add option to paste plain text

* refactor: optimize the code

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>
Co-authored-by: Lucas <lucas.xu@appflowy.io>

---------

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>
Co-authored-by: Lucas <lucas.xu@appflowy.io>
This commit is contained in:
Morn 2024-12-26 13:22:27 +08:00 committed by GitHub
parent 200b367e4c
commit 3959cdba3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 127 additions and 25 deletions

View File

@ -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();

View File

@ -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<ClipboardService>().setData(
ClipboardServiceData(

View File

@ -13,6 +13,11 @@ final List<List<ContextMenuItem>> 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),

View File

@ -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<ClipboardState>().didPaste();
}
});
return KeyEventResult.handled;
};
Future<void> doPaste(EditorState editorState) async {
final selection = editorState.selection;
if (selection == null) {
@ -119,7 +143,7 @@ Future<void> 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<bool> _pasteAsLinkPreview(
return true;
}
Future<void> doPlainPaste(EditorState editorState) async {
final selection = editorState.selection;
if (selection == null) {
return;
}
EditorNotification.paste().post();
// dispatch the paste event
final data = await getIt<ClipboardService>().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;
}

View File

@ -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<void> 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<void> 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<bool> pasteHtmlIfAvailable(String plainText) async {
final selection = this.selection;
if (selection == null ||

View File

@ -30,6 +30,7 @@ List<CommandShortcutEvent> commandShortcutEvents = [
customCopyCommand,
customPasteCommand,
customPastePlainTextCommand,
customCutCommand,
customUndoCommand,
customRedoCommand,
@ -43,6 +44,7 @@ List<CommandShortcutEvent> commandShortcutEvents = [
copyCommand,
cutCommand,
pasteCommand,
pasteTextWithoutFormattingCommand,
toggleTodoListCommand,
undoCommand,
redoCommand,

View File

@ -1847,7 +1847,8 @@
"contextMenu": {
"copy": "Copy",
"cut": "Cut",
"paste": "Paste"
"paste": "Paste",
"pasteAsPlainText": "Paste as plain text"
},
"action": "Actions",
"database": {