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

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

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/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<bool> _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<bool> _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<void> doPlainPaste(EditorState editorState) async {
Log.info('unable to parse the clipboard content');
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[",><]*)?';
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: