chore: support pasting image links that have no file extension (#7262)

* test: speed up copypasta tests

* chore: strip away markdown syntax if message is image only

* chore: paste image urls with no file extension

* test: add integration test

* test: group tests

* chore: apply code suggestions to 3 files
This commit is contained in:
Richard Shiue 2025-01-22 22:13:18 +08:00 committed by GitHub
parent 0b0e10baa8
commit c5a91e10df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 386 additions and 344 deletions

View File

@ -19,7 +19,7 @@ import '../../shared/util.dart';
void main() { void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
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 {
// mock the clipboard // mock the clipboard
const lines = 3; const lines = 3;
@ -172,7 +172,6 @@ void main() {
}, },
); );
}); });
});
testWidgets('paste text on part of bullet list', (tester) async { testWidgets('paste text on part of bullet list', (tester) async {
const plainText = 'test'; const plainText = 'test';
@ -470,6 +469,16 @@ void main() {
}); });
}); });
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 = ''' const testMarkdownText = '''
# I'm h1 # I'm h1
## I'm h2 ## I'm h2
@ -507,6 +516,7 @@ void main() {
}, },
); );
}); });
});
} }
extension on WidgetTester { extension on WidgetTester {
@ -523,7 +533,7 @@ extension on WidgetTester {
await tapAnonymousSignInButton(); await tapAnonymousSignInButton();
// create a new document // create a new document
await createNewPageWithNameUnderParent(name: 'Test Document'); await createNewPageWithNameUnderParent();
// tap the editor // tap the editor
await tapButton(find.byType(AppFlowyEditor)); await tapButton(find.byType(AppFlowyEditor));
@ -546,7 +556,7 @@ extension on WidgetTester {
isShiftPressed: pasteAsPlain, isShiftPressed: pasteAsPlain,
isMetaPressed: Platform.isMacOS, isMetaPressed: Platform.isMacOS,
); );
await pumpAndSettle(); await pumpAndSettle(const Duration(milliseconds: 1000));
test(editor.getCurrentEditorState()); test(editor.getCurrentEditorState());
} }

View File

@ -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/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/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/markdown_to_document.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/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart'; import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
@ -170,7 +171,7 @@ class CopyButton extends StatelessWidget {
); );
await getIt<ClipboardService>().setData( await getIt<ClipboardService>().setData(
ClipboardServiceData( ClipboardServiceData(
plainText: textMessage.text, plainText: _getTrimmedPlainText(textMessage.text),
inAppJson: jsonEncode(document.toJson()), 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 { class RegenerateButton extends StatelessWidget {

View File

@ -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/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart';
import 'package:appflowy/shared/clipboard_state.dart'; import 'package:appflowy/shared/clipboard_state.dart';
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/default_extensions.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.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:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:http/http.dart' as http;
import 'package:string_validator/string_validator.dart'; import 'package:string_validator/string_validator.dart';
/// - support /// - support
@ -154,11 +154,8 @@ Future<bool> _pasteAsLinkPreview(
EditorState editorState, EditorState editorState,
String? text, String? text,
) async { ) async {
// 1. the url should contains a protocol // the url should contain a protocol
// 2. the url should not be an image url if (text == null || !isURL(text, {'require_protocol': true})) {
if (text == null ||
text.isImageUrl() ||
!isURL(text, {'require_protocol': true})) {
return false; return false;
} }
@ -180,10 +177,17 @@ Future<bool> _pasteAsLinkPreview(
return false; return false;
} }
// 1. insert the text with link format final bool isImageUrl;
// 2. convert it the link preview node try {
final textTransaction = editorState.transaction; isImageUrl = await _isImageUrl(text);
textTransaction.insertText( } catch (e) {
Log.info('unable to get content header');
return false;
}
// insert the text with link format
final textTransaction = editorState.transaction
..insertText(
node, node,
0, 0,
text, text,
@ -194,23 +198,24 @@ Future<bool> _pasteAsLinkPreview(
skipHistoryDebounce: true, skipHistoryDebounce: true,
); );
final linkPreviewTransaction = editorState.transaction; // convert it to image or link preview node
final insertedNodes = [ final replacementInsertedNodes = [
linkPreviewNode(url: text), isImageUrl ? imageNode(url: text) : linkPreviewNode(url: text),
// if the next node is null, insert a empty paragraph node // if the next node is null, insert a empty paragraph node
if (node.next == null) paragraphNode(), if (node.next == null) paragraphNode(),
]; ];
linkPreviewTransaction.insertNodes(
final replacementTransaction = editorState.transaction
..insertNodes(
selection.start.path, selection.start.path,
insertedNodes, replacementInsertedNodes,
)
..deleteNode(node)
..afterSelection = Selection.collapsed(
Position(path: node.path.next),
); );
linkPreviewTransaction.deleteNode(node);
linkPreviewTransaction.afterSelection = Selection.collapsed( await editorState.apply(replacementTransaction);
Position(
path: node.path.next,
),
);
await editorState.apply(linkPreviewTransaction);
return true; return true;
} }
@ -235,3 +240,16 @@ Future<void> doPlainPaste(EditorState editorState) async {
Log.info('unable to parse the clipboard content'); Log.info('unable to parse the clipboard content');
return; return;
} }
Future<bool> _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';
}

View File

@ -13,6 +13,9 @@ const _imgUrlPattern =
r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.jpeg|.gif|.webm|.webp|.bmp)(\?[^\s[",><]*)?'; r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.jpeg|.gif|.webm|.webp|.bmp)(\?[^\s[",><]*)?';
final imgUrlRegex = RegExp(_imgUrlPattern); final imgUrlRegex = RegExp(_imgUrlPattern);
const _singleLineMarkdownImagePattern = "^!\\[.*\\]\\(($_hrefPattern)\\)\$";
final singleLineMarkdownImageRegex = RegExp(_singleLineMarkdownImagePattern);
/// This pattern allows for both HTTP and HTTPS Scheme /// This pattern allows for both HTTP and HTTPS Scheme
/// It allows for query parameters /// It allows for query parameters
/// It only allows the following video extensions: /// It only allows the following video extensions: