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 8da4aeccce..aa4f8252d5 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 @@ -19,7 +19,7 @@ import '../../shared/util.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - group('copy and paste in document', () { + group('copy and paste in document:', () { testWidgets('paste multiple lines at the first line', (tester) async { // mock the clipboard const lines = 3; @@ -172,305 +172,314 @@ void main() { }, ); }); - }); - testWidgets('paste text on part of bullet list', (tester) async { - const plainText = 'test'; + testWidgets('paste text on part of bullet list', (tester) async { + const plainText = 'test'; - await tester.pasteContent( - plainText: plainText, - beforeTest: (editorState) async { - final transaction = editorState.transaction; - transaction.insertNodes( - [0], - [ - Node( - type: BulletedListBlockKeys.type, - attributes: { - 'delta': [ - {"insert": "bullet list"}, - ], - }, - ), - ], - ); - - // Set the selection to the second numbered list node (which has empty delta) - transaction.afterSelection = Selection( - start: Position(path: [0], offset: 7), - end: Position(path: [0], offset: 11), - ); - - await editorState.apply(transaction); - await tester.pumpAndSettle(); - }, - (editorState) { - final node = editorState.getNodeAtPath([0]); - expect(node?.delta?.toPlainText(), 'bullet test'); - expect(node?.type, BulletedListBlockKeys.type); - }, - ); - }); - - 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, 1); - 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, 1); - final node = editorState.getNodeAtPath([0])!; - expect(node.type, ImageBlockKeys.type); - expect(node.attributes[ImageBlockKeys.url], isNotNull); - }); - }); - - testWidgets('paste image(gif) from memory', (tester) async { - 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, 1); - final node = editorState.getNodeAtPath([0])!; - expect(node.type, ImageBlockKeys.type); - expect(node.attributes[ImageBlockKeys.url], isNotNull); - }); - }); - - testWidgets( - 'format the selected text to href when pasting url if available', - (tester) async { - const text = 'appflowy'; - const url = 'https://appflowy.io'; await tester.pasteContent( - plainText: url, + plainText: plainText, beforeTest: (editorState) async { - await tester.ime.insertText(text); - await tester.editor.updateSelection( - Selection.single( - path: [0], - startOffset: 0, - endOffset: text.length, - ), + final transaction = editorState.transaction; + transaction.insertNodes( + [0], + [ + Node( + type: BulletedListBlockKeys.type, + attributes: { + 'delta': [ + {"insert": "bullet list"}, + ], + }, + ), + ], ); - }, - (editorState) { - final node = editorState.getNodeAtPath([0])!; - expect(node.type, ParagraphBlockKeys.type); - expect(node.delta!.toJson(), [ - { - 'insert': text, - 'attributes': {'href': url}, - } - ]); - }, - ); - }, - ); - // https://github.com/AppFlowy-IO/AppFlowy/issues/3263 - testWidgets( - 'paste the image from clipboard when html and image are both available', - (tester) async { - const html = - '''image'''; - final image = await rootBundle.load('assets/test/images/sample.png'); - final bytes = image.buffer.asUint8List(); - await tester.pasteContent( - html: html, - image: ('png', bytes), - (editorState) { - expect(editorState.document.root.children.length, 1); - final node = editorState.getNodeAtPath([0])!; - expect(node.type, ImageBlockKeys.type); - }, - ); - }, - ); + // Set the selection to the second numbered list node (which has empty delta) + transaction.afterSelection = Selection( + start: Position(path: [0], offset: 7), + end: Position(path: [0], offset: 11), + ); - testWidgets('paste the html content contains section', (tester) async { - const html = - '''
AppFlowy
Hello World
'''; - await tester.pasteContent(html: html, (editorState) { - expect(editorState.document.root.children.length, 2); - final node1 = editorState.getNodeAtPath([0])!; - final node2 = editorState.getNodeAtPath([1])!; - expect(node1.type, ParagraphBlockKeys.type); - expect(node2.type, ParagraphBlockKeys.type); - }); - }); - - testWidgets('paste the html from google translation', (tester) async { - const html = - '''
new force
Assessment focus: potential motivations, empathy

➢Personality characteristics and potential motivations:
-Reflection of self-worth
-Need to be respected
-Have a unique definition of success
-Be true to your own lifestyle
'''; - await tester.pasteContent(html: html, (editorState) { - expect(editorState.document.root.children.length, 8); - }); - }); - - testWidgets( - 'auto convert url to link preview block', - (tester) async { - const url = 'https://appflowy.io'; - await tester.pasteContent(plainText: url, (editorState) async { - // the second one is the paragraph node - expect(editorState.document.root.children.length, 2); - final node = editorState.getNodeAtPath([0])!; - expect(node.type, LinkPreviewBlockKeys.type); - expect(node.attributes[LinkPreviewBlockKeys.url], url); - }); - - // hover on the link preview block - // click the more button - // and select convert to link - await tester.hoverOnWidget( - find.byType(CustomLinkPreviewWidget), - onHover: () async { - final convertToLinkButton = find.byWidgetPredicate((widget) { - return widget is MenuBlockButton && - widget.tooltip == - LocaleKeys.document_plugins_urlPreview_convertToLink.tr(); - }); - expect(convertToLinkButton, findsOneWidget); - await tester.tap(convertToLinkButton); + await editorState.apply(transaction); await tester.pumpAndSettle(); }, + (editorState) { + final node = editorState.getNodeAtPath([0]); + expect(node?.delta?.toPlainText(), 'bullet test'); + expect(node?.type, BulletedListBlockKeys.type); + }, ); + }); - await tester.pumpAndSettle(); - - final editorState = tester.editor.getCurrentEditorState(); - final textNode = editorState.getNodeAtPath([0])!; - expect(textNode.type, ParagraphBlockKeys.type); - expect(textNode.delta!.toJson(), [ - { - 'insert': url, - 'attributes': {'href': url}, - } - ]); - }, - ); - - testWidgets( - 'ctrl/cmd+z to undo the auto convert url to link preview block', - (tester) async { - const url = 'https://appflowy.io'; - await tester.pasteContent(plainText: url, (editorState) async { - // the second one is the paragraph node - expect(editorState.document.root.children.length, 2); + 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, 1); final node = editorState.getNodeAtPath([0])!; - expect(node.type, LinkPreviewBlockKeys.type); - expect(node.attributes[LinkPreviewBlockKeys.url], url); + expect(node.type, ImageBlockKeys.type); + expect(node.attributes[ImageBlockKeys.url], isNotNull); }); - - await tester.editor.tapLineOfEditorAt(0); - await tester.simulateKeyEvent( - LogicalKeyboardKey.keyZ, - isControlPressed: - UniversalPlatform.isLinux || UniversalPlatform.isWindows, - isMetaPressed: UniversalPlatform.isMacOS, - ); - await tester.pumpAndSettle(); - - final editorState = tester.editor.getCurrentEditorState(); - final node = editorState.getNodeAtPath([0])!; - expect(node.type, ParagraphBlockKeys.type); - expect(node.delta!.toJson(), [ - { - 'insert': url, - 'attributes': {'href': url}, - } - ]); - }, - ); - - testWidgets( - 'paste the nodes start with non-delta node', - (tester) async { - await tester.pasteContent((_) {}); - const text = 'Hello World'; - final editorState = tester.editor.getCurrentEditorState(); - final transaction = editorState.transaction; - // [image_block] - // [paragraph_block] - transaction.insertNodes([ - 0, - ], [ - customImageNode(url: ''), - paragraphNode(text: text), - ]); - await editorState.apply(transaction); - await tester.pumpAndSettle(); - - await tester.editor.tapLineOfEditorAt(0); - // select all and copy - await tester.simulateKeyEvent( - LogicalKeyboardKey.keyA, - isControlPressed: - UniversalPlatform.isLinux || UniversalPlatform.isWindows, - isMetaPressed: UniversalPlatform.isMacOS, - ); - await tester.simulateKeyEvent( - LogicalKeyboardKey.keyC, - isControlPressed: - UniversalPlatform.isLinux || UniversalPlatform.isWindows, - isMetaPressed: UniversalPlatform.isMacOS, - ); - - // put the cursor to the end of the paragraph block - await tester.editor.tapLineOfEditorAt(0); - - // paste the content - await tester.simulateKeyEvent( - LogicalKeyboardKey.keyV, - isControlPressed: - UniversalPlatform.isLinux || UniversalPlatform.isWindows, - isMetaPressed: UniversalPlatform.isMacOS, - ); - await tester.pumpAndSettle(); - - // expect the image and the paragraph block are inserted below the cursor - expect(editorState.getNodeAtPath([0])!.type, CustomImageBlockKeys.type); - expect(editorState.getNodeAtPath([1])!.type, ParagraphBlockKeys.type); - expect(editorState.getNodeAtPath([2])!.type, CustomImageBlockKeys.type); - expect(editorState.getNodeAtPath([3])!.type, ParagraphBlockKeys.type); - }, - ); - - testWidgets('paste the url without protocol', (tester) async { - // paste the image that from local file - const plainText = '1.jpg'; - final image = await rootBundle.load('assets/test/images/sample.jpeg'); - final bytes = image.buffer.asUint8List(); - await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes), - (editorState) { - final node = editorState.getNodeAtPath([0])!; - expect(node.type, ImageBlockKeys.type); - expect(node.attributes[ImageBlockKeys.url], isNotEmpty); }); - }); - testWidgets('paste the image url', (tester) async { - const plainText = 'https://appflowy.io/1.jpg'; - final image = await rootBundle.load('assets/test/images/sample.jpeg'); - final bytes = image.buffer.asUint8List(); - await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes), - (editorState) { - final node = editorState.getNodeAtPath([0])!; - expect(node.type, ImageBlockKeys.type); - expect(node.attributes[ImageBlockKeys.url], isNotEmpty); + 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, 1); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ImageBlockKeys.type); + expect(node.attributes[ImageBlockKeys.url], isNotNull); + }); }); - }); - const testMarkdownText = ''' + testWidgets('paste image(gif) from memory', (tester) async { + 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, 1); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ImageBlockKeys.type); + expect(node.attributes[ImageBlockKeys.url], isNotNull); + }); + }); + + testWidgets( + 'format the selected text to href when pasting url if available', + (tester) async { + const text = 'appflowy'; + const url = 'https://appflowy.io'; + await tester.pasteContent( + plainText: url, + beforeTest: (editorState) async { + await tester.ime.insertText(text); + await tester.editor.updateSelection( + Selection.single( + path: [0], + startOffset: 0, + endOffset: text.length, + ), + ); + }, + (editorState) { + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ParagraphBlockKeys.type); + expect(node.delta!.toJson(), [ + { + 'insert': text, + 'attributes': {'href': url}, + } + ]); + }, + ); + }, + ); + + // https://github.com/AppFlowy-IO/AppFlowy/issues/3263 + testWidgets( + 'paste the image from clipboard when html and image are both available', + (tester) async { + const html = + '''image'''; + final image = await rootBundle.load('assets/test/images/sample.png'); + final bytes = image.buffer.asUint8List(); + await tester.pasteContent( + html: html, + image: ('png', bytes), + (editorState) { + expect(editorState.document.root.children.length, 1); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ImageBlockKeys.type); + }, + ); + }, + ); + + testWidgets('paste the html content contains section', (tester) async { + const html = + '''
AppFlowy
Hello World
'''; + await tester.pasteContent(html: html, (editorState) { + expect(editorState.document.root.children.length, 2); + final node1 = editorState.getNodeAtPath([0])!; + final node2 = editorState.getNodeAtPath([1])!; + expect(node1.type, ParagraphBlockKeys.type); + expect(node2.type, ParagraphBlockKeys.type); + }); + }); + + testWidgets('paste the html from google translation', (tester) async { + const html = + '''
new force
Assessment focus: potential motivations, empathy

➢Personality characteristics and potential motivations:
-Reflection of self-worth
-Need to be respected
-Have a unique definition of success
-Be true to your own lifestyle
'''; + await tester.pasteContent(html: html, (editorState) { + expect(editorState.document.root.children.length, 8); + }); + }); + + testWidgets( + 'auto convert url to link preview block', + (tester) async { + const url = 'https://appflowy.io'; + await tester.pasteContent(plainText: url, (editorState) async { + // the second one is the paragraph node + expect(editorState.document.root.children.length, 2); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, LinkPreviewBlockKeys.type); + expect(node.attributes[LinkPreviewBlockKeys.url], url); + }); + + // hover on the link preview block + // click the more button + // and select convert to link + await tester.hoverOnWidget( + find.byType(CustomLinkPreviewWidget), + onHover: () async { + final convertToLinkButton = find.byWidgetPredicate((widget) { + return widget is MenuBlockButton && + widget.tooltip == + LocaleKeys.document_plugins_urlPreview_convertToLink.tr(); + }); + expect(convertToLinkButton, findsOneWidget); + await tester.tap(convertToLinkButton); + await tester.pumpAndSettle(); + }, + ); + + await tester.pumpAndSettle(); + + final editorState = tester.editor.getCurrentEditorState(); + final textNode = editorState.getNodeAtPath([0])!; + expect(textNode.type, ParagraphBlockKeys.type); + expect(textNode.delta!.toJson(), [ + { + 'insert': url, + 'attributes': {'href': url}, + } + ]); + }, + ); + + testWidgets( + 'ctrl/cmd+z to undo the auto convert url to link preview block', + (tester) async { + const url = 'https://appflowy.io'; + await tester.pasteContent(plainText: url, (editorState) async { + // the second one is the paragraph node + expect(editorState.document.root.children.length, 2); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, LinkPreviewBlockKeys.type); + expect(node.attributes[LinkPreviewBlockKeys.url], url); + }); + + await tester.editor.tapLineOfEditorAt(0); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyZ, + isControlPressed: + UniversalPlatform.isLinux || UniversalPlatform.isWindows, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + final editorState = tester.editor.getCurrentEditorState(); + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ParagraphBlockKeys.type); + expect(node.delta!.toJson(), [ + { + 'insert': url, + 'attributes': {'href': url}, + } + ]); + }, + ); + + testWidgets( + 'paste the nodes start with non-delta node', + (tester) async { + await tester.pasteContent((_) {}); + const text = 'Hello World'; + final editorState = tester.editor.getCurrentEditorState(); + final transaction = editorState.transaction; + // [image_block] + // [paragraph_block] + transaction.insertNodes([ + 0, + ], [ + customImageNode(url: ''), + paragraphNode(text: text), + ]); + await editorState.apply(transaction); + await tester.pumpAndSettle(); + + await tester.editor.tapLineOfEditorAt(0); + // select all and copy + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyA, + isControlPressed: + UniversalPlatform.isLinux || UniversalPlatform.isWindows, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyC, + isControlPressed: + UniversalPlatform.isLinux || UniversalPlatform.isWindows, + isMetaPressed: UniversalPlatform.isMacOS, + ); + + // put the cursor to the end of the paragraph block + await tester.editor.tapLineOfEditorAt(0); + + // paste the content + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: + UniversalPlatform.isLinux || UniversalPlatform.isWindows, + isMetaPressed: UniversalPlatform.isMacOS, + ); + await tester.pumpAndSettle(); + + // expect the image and the paragraph block are inserted below the cursor + expect(editorState.getNodeAtPath([0])!.type, CustomImageBlockKeys.type); + expect(editorState.getNodeAtPath([1])!.type, ParagraphBlockKeys.type); + expect(editorState.getNodeAtPath([2])!.type, CustomImageBlockKeys.type); + expect(editorState.getNodeAtPath([3])!.type, ParagraphBlockKeys.type); + }, + ); + + testWidgets('paste the url without protocol', (tester) async { + // paste the image that from local file + const plainText = '1.jpg'; + final image = await rootBundle.load('assets/test/images/sample.jpeg'); + final bytes = image.buffer.asUint8List(); + await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes), + (editorState) { + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ImageBlockKeys.type); + expect(node.attributes[ImageBlockKeys.url], isNotEmpty); + }); + }); + + testWidgets('paste the image url', (tester) async { + const plainText = 'https://appflowy.io/1.jpg'; + final image = await rootBundle.load('assets/test/images/sample.jpeg'); + final bytes = image.buffer.asUint8List(); + await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes), + (editorState) { + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ImageBlockKeys.type); + expect(node.attributes[ImageBlockKeys.url], isNotEmpty); + }); + }); + + testWidgets('paste image url without extension', (tester) async { + const plainText = + 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640'; + await tester.pasteContent(plainText: plainText, (editorState) { + final node = editorState.getNodeAtPath([0])!; + expect(node.type, ImageBlockKeys.type); + expect(node.attributes[ImageBlockKeys.url], isNotEmpty); + }); + }); + + const testMarkdownText = ''' # I'm h1 ## I'm h2 ### I'm h3 @@ -478,34 +487,35 @@ void main() { ##### 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', (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); - } - }, - ); + 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); + } + }, + ); + }); }); } @@ -523,7 +533,7 @@ extension on WidgetTester { await tapAnonymousSignInButton(); // create a new document - await createNewPageWithNameUnderParent(name: 'Test Document'); + await createNewPageWithNameUnderParent(); // tap the editor await tapButton(find.byType(AppFlowyEditor)); @@ -546,7 +556,7 @@ extension on WidgetTester { isShiftPressed: pasteAsPlain, isMetaPressed: Platform.isMacOS, ); - await pumpAndSettle(); + await pumpAndSettle(const Duration(milliseconds: 1000)); test(editor.getCurrentEditorState()); } diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart index 79a4868a9f..cf20fb2742 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/presentation/message/ai_message_action_bar.dart @@ -13,6 +13,7 @@ import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.d 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/shared/markdown_to_document.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; @@ -170,7 +171,7 @@ class CopyButton extends StatelessWidget { ); await getIt().setData( ClipboardServiceData( - plainText: textMessage.text, + plainText: _getTrimmedPlainText(textMessage.text), inAppJson: jsonEncode(document.toJson()), ), ); @@ -184,6 +185,16 @@ class CopyButton extends StatelessWidget { ), ); } + + String _getTrimmedPlainText(String plainText) { + // match and capture inner url as group + final matches = singleLineMarkdownImageRegex.allMatches(plainText); + if (matches.length != 1) { + return plainText; + } + + return matches.first[1] ?? plainText; + } } class RegenerateButton extends StatelessWidget { 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 ed353103fb..06ccf9aae5 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 @@ -8,12 +8,12 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart'; import 'package:appflowy/shared/clipboard_state.dart'; import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/util/default_extensions.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:http/http.dart' as http; import 'package:string_validator/string_validator.dart'; /// - support @@ -154,17 +154,14 @@ Future _pasteAsLinkPreview( EditorState editorState, String? text, ) async { - // 1. the url should contains a protocol - // 2. the url should not be an image url - if (text == null || - text.isImageUrl() || - !isURL(text, {'require_protocol': true})) { + // the url should contain a protocol + if (text == null || !isURL(text, {'require_protocol': true})) { return false; } final selection = editorState.selection; // Apply the update only when the selection is collapsed - // and at the start of the current line + // and at the start of the current line if (selection == null || !selection.isCollapsed || selection.startIndex != 0) { @@ -173,44 +170,52 @@ Future _pasteAsLinkPreview( final node = editorState.getNodeAtPath(selection.start.path); // Apply the update only when the current node is a paragraph - // and the paragraph is empty + // and the paragraph is empty if (node == null || node.type != ParagraphBlockKeys.type || node.delta?.toPlainText().isNotEmpty == true) { return false; } - // 1. insert the text with link format - // 2. convert it the link preview node - final textTransaction = editorState.transaction; - textTransaction.insertText( - node, - 0, - text, - attributes: {AppFlowyRichTextKeys.href: text}, - ); + final bool isImageUrl; + try { + isImageUrl = await _isImageUrl(text); + } catch (e) { + Log.info('unable to get content header'); + return false; + } + + // insert the text with link format + final textTransaction = editorState.transaction + ..insertText( + node, + 0, + text, + attributes: {AppFlowyRichTextKeys.href: text}, + ); await editorState.apply( textTransaction, skipHistoryDebounce: true, ); - final linkPreviewTransaction = editorState.transaction; - final insertedNodes = [ - linkPreviewNode(url: text), + // convert it to image or link preview node + final replacementInsertedNodes = [ + isImageUrl ? imageNode(url: text) : linkPreviewNode(url: text), // if the next node is null, insert a empty paragraph node if (node.next == null) paragraphNode(), ]; - linkPreviewTransaction.insertNodes( - selection.start.path, - insertedNodes, - ); - linkPreviewTransaction.deleteNode(node); - linkPreviewTransaction.afterSelection = Selection.collapsed( - Position( - path: node.path.next, - ), - ); - await editorState.apply(linkPreviewTransaction); + + final replacementTransaction = editorState.transaction + ..insertNodes( + selection.start.path, + replacementInsertedNodes, + ) + ..deleteNode(node) + ..afterSelection = Selection.collapsed( + Position(path: node.path.next), + ); + + await editorState.apply(replacementTransaction); return true; } @@ -235,3 +240,16 @@ Future doPlainPaste(EditorState editorState) async { Log.info('unable to parse the clipboard content'); return; } + +Future _isImageUrl(String text) async { + final response = await http.head(Uri.parse(text)); + + if (response.statusCode == 200) { + final contentType = response.headers['content-type']; + if (contentType != null) { + return contentType.startsWith('image/'); + } + } + + throw 'bad status code'; +} diff --git a/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart index 4b07db8c78..15c2cdb3fe 100644 --- a/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart +++ b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart @@ -13,6 +13,9 @@ const _imgUrlPattern = r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.jpeg|.gif|.webm|.webp|.bmp)(\?[^\s[",><]*)?'; final imgUrlRegex = RegExp(_imgUrlPattern); +const _singleLineMarkdownImagePattern = "^!\\[.*\\]\\(($_hrefPattern)\\)\$"; +final singleLineMarkdownImageRegex = RegExp(_singleLineMarkdownImagePattern); + /// This pattern allows for both HTTP and HTTPS Scheme /// It allows for query parameters /// It only allows the following video extensions: