mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-12-14 16:52:20 +00:00
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:
parent
200b367e4c
commit
3959cdba3a
@ -1,7 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
import 'package:appflowy/generated/locale_keys.g.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/block_menu/block_menu_button.dart';
|
||||||
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/clipboard_service.dart';
|
||||||
@ -11,6 +9,7 @@ import 'package:appflowy/startup/startup.dart';
|
|||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:integration_test/integration_test.dart';
|
import 'package:integration_test/integration_test.dart';
|
||||||
import 'package:universal_platform/universal_platform.dart';
|
import 'package:universal_platform/universal_platform.dart';
|
||||||
@ -27,10 +26,13 @@ void main() {
|
|||||||
await tester.pasteContent(
|
await tester.pasteContent(
|
||||||
plainText: List.generate(lines, (index) => 'line $index').join('\n'),
|
plainText: List.generate(lines, (index) => 'line $index').join('\n'),
|
||||||
(editorState) {
|
(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++) {
|
for (var i = 0; i < lines; i++) {
|
||||||
expect(
|
expect(
|
||||||
editorState.getNodeAtPath([i])!.delta!.toPlainText(),
|
textLines[i],
|
||||||
'line $i',
|
'line $i',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -467,6 +469,44 @@ void main() {
|
|||||||
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
|
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 {
|
extension on WidgetTester {
|
||||||
@ -476,6 +516,7 @@ extension on WidgetTester {
|
|||||||
String? plainText,
|
String? plainText,
|
||||||
String? html,
|
String? html,
|
||||||
String? inAppJson,
|
String? inAppJson,
|
||||||
|
bool pasteAsPlain = false,
|
||||||
(String, Uint8List?)? image,
|
(String, Uint8List?)? image,
|
||||||
}) async {
|
}) async {
|
||||||
await initializeAppFlowy();
|
await initializeAppFlowy();
|
||||||
@ -502,6 +543,7 @@ extension on WidgetTester {
|
|||||||
await simulateKeyEvent(
|
await simulateKeyEvent(
|
||||||
LogicalKeyboardKey.keyV,
|
LogicalKeyboardKey.keyV,
|
||||||
isControlPressed: Platform.isLinux || Platform.isWindows,
|
isControlPressed: Platform.isLinux || Platform.isWindows,
|
||||||
|
isShiftPressed: pasteAsPlain,
|
||||||
isMetaPressed: Platform.isMacOS,
|
isMetaPressed: Platform.isMacOS,
|
||||||
);
|
);
|
||||||
await pumpAndSettle();
|
await pumpAndSettle();
|
||||||
|
|||||||
@ -37,12 +37,12 @@ void main() {
|
|||||||
|
|
||||||
// set clipboard data
|
// set clipboard data
|
||||||
final data = [
|
final data = [
|
||||||
"123456\n",
|
"123456\n\n",
|
||||||
...List.generate(100, (_) => "${generateRandomString(50)}\n"),
|
...List.generate(100, (_) => "${generateRandomString(50)}\n\n"),
|
||||||
"1234567\n",
|
"1234567\n\n",
|
||||||
...List.generate(100, (_) => "${generateRandomString(50)}\n"),
|
...List.generate(100, (_) => "${generateRandomString(50)}\n\n"),
|
||||||
"12345678\n",
|
"12345678\n\n",
|
||||||
...List.generate(100, (_) => "${generateRandomString(50)}\n"),
|
...List.generate(100, (_) => "${generateRandomString(50)}\n\n"),
|
||||||
].join();
|
].join();
|
||||||
await getIt<ClipboardService>().setData(
|
await getIt<ClipboardService>().setData(
|
||||||
ClipboardServiceData(
|
ClipboardServiceData(
|
||||||
|
|||||||
@ -13,6 +13,11 @@ final List<List<ContextMenuItem>> customContextMenuItems = [
|
|||||||
getName: LocaleKeys.document_plugins_contextMenu_paste.tr,
|
getName: LocaleKeys.document_plugins_contextMenu_paste.tr,
|
||||||
onPressed: (editorState) => customPasteCommand.execute(editorState),
|
onPressed: (editorState) => customPasteCommand.execute(editorState),
|
||||||
),
|
),
|
||||||
|
ContextMenuItem(
|
||||||
|
getName: LocaleKeys.document_plugins_contextMenu_pasteAsPlainText.tr,
|
||||||
|
onPressed: (editorState) =>
|
||||||
|
customPastePlainTextCommand.execute(editorState),
|
||||||
|
),
|
||||||
ContextMenuItem(
|
ContextMenuItem(
|
||||||
getName: LocaleKeys.document_plugins_contextMenu_cut.tr,
|
getName: LocaleKeys.document_plugins_contextMenu_cut.tr,
|
||||||
onPressed: (editorState) => customCutCommand.execute(editorState),
|
onPressed: (editorState) => customCutCommand.execute(editorState),
|
||||||
|
|||||||
@ -29,6 +29,14 @@ final CommandShortcutEvent customPasteCommand = CommandShortcutEvent(
|
|||||||
handler: _pasteCommandHandler,
|
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) {
|
CommandShortcutEventHandler _pasteCommandHandler = (editorState) {
|
||||||
final selection = editorState.selection;
|
final selection = editorState.selection;
|
||||||
if (selection == null) {
|
if (selection == null) {
|
||||||
@ -45,6 +53,22 @@ CommandShortcutEventHandler _pasteCommandHandler = (editorState) {
|
|||||||
return KeyEventResult.handled;
|
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 {
|
Future<void> doPaste(EditorState editorState) async {
|
||||||
final selection = editorState.selection;
|
final selection = editorState.selection;
|
||||||
if (selection == null) {
|
if (selection == null) {
|
||||||
@ -119,7 +143,7 @@ Future<void> doPaste(EditorState editorState) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (plainText != null && plainText.isNotEmpty) {
|
if (plainText != null && plainText.isNotEmpty) {
|
||||||
await editorState.pastePlainText(plainText);
|
await editorState.pasteText(plainText);
|
||||||
return Log.info('Pasted plain text');
|
return Log.info('Pasted plain text');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,3 +214,24 @@ Future<bool> _pasteAsLinkPreview(
|
|||||||
|
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,14 +1,10 @@
|
|||||||
|
import 'package:appflowy/shared/markdown_to_document.dart';
|
||||||
import 'package:appflowy/shared/patterns/common_patterns.dart';
|
import 'package:appflowy/shared/patterns/common_patterns.dart';
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
|
||||||
extension PasteFromPlainText on EditorState {
|
extension PasteFromPlainText on EditorState {
|
||||||
Future<void> pastePlainText(String plainText) async {
|
Future<void> pastePlainText(String plainText) async {
|
||||||
if (await pasteHtmlIfAvailable(plainText)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await deleteSelectionIfNeeded();
|
await deleteSelectionIfNeeded();
|
||||||
|
|
||||||
final nodes = plainText
|
final nodes = plainText
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map(
|
.map(
|
||||||
@ -16,14 +12,7 @@ extension PasteFromPlainText on EditorState {
|
|||||||
..replaceAll(r'\r', '')
|
..replaceAll(r'\r', '')
|
||||||
..trimRight(),
|
..trimRight(),
|
||||||
)
|
)
|
||||||
.map((e) {
|
.map((e) => Delta()..insert(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))
|
.map((e) => paragraphNode(delta: e))
|
||||||
.toList();
|
.toList();
|
||||||
if (nodes.isEmpty) {
|
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 {
|
Future<bool> pasteHtmlIfAvailable(String plainText) async {
|
||||||
final selection = this.selection;
|
final selection = this.selection;
|
||||||
if (selection == null ||
|
if (selection == null ||
|
||||||
|
|||||||
@ -30,6 +30,7 @@ List<CommandShortcutEvent> commandShortcutEvents = [
|
|||||||
|
|
||||||
customCopyCommand,
|
customCopyCommand,
|
||||||
customPasteCommand,
|
customPasteCommand,
|
||||||
|
customPastePlainTextCommand,
|
||||||
customCutCommand,
|
customCutCommand,
|
||||||
customUndoCommand,
|
customUndoCommand,
|
||||||
customRedoCommand,
|
customRedoCommand,
|
||||||
@ -43,6 +44,7 @@ List<CommandShortcutEvent> commandShortcutEvents = [
|
|||||||
copyCommand,
|
copyCommand,
|
||||||
cutCommand,
|
cutCommand,
|
||||||
pasteCommand,
|
pasteCommand,
|
||||||
|
pasteTextWithoutFormattingCommand,
|
||||||
toggleTodoListCommand,
|
toggleTodoListCommand,
|
||||||
undoCommand,
|
undoCommand,
|
||||||
redoCommand,
|
redoCommand,
|
||||||
|
|||||||
@ -1847,7 +1847,8 @@
|
|||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"cut": "Cut",
|
"cut": "Cut",
|
||||||
"paste": "Paste"
|
"paste": "Paste",
|
||||||
|
"pasteAsPlainText": "Paste as plain text"
|
||||||
},
|
},
|
||||||
"action": "Actions",
|
"action": "Actions",
|
||||||
"database": {
|
"database": {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user