mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-12-16 17:56:03 +00:00
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:
parent
0b0e10baa8
commit
c5a91e10df
File diff suppressed because one or more lines are too long
@ -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 {
|
||||||
|
|||||||
@ -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,17 +154,14 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
final selection = editorState.selection;
|
final selection = editorState.selection;
|
||||||
// Apply the update only when the selection is collapsed
|
// 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 ||
|
if (selection == null ||
|
||||||
!selection.isCollapsed ||
|
!selection.isCollapsed ||
|
||||||
selection.startIndex != 0) {
|
selection.startIndex != 0) {
|
||||||
@ -173,44 +170,52 @@ Future<bool> _pasteAsLinkPreview(
|
|||||||
|
|
||||||
final node = editorState.getNodeAtPath(selection.start.path);
|
final node = editorState.getNodeAtPath(selection.start.path);
|
||||||
// Apply the update only when the current node is a paragraph
|
// Apply the update only when the current node is a paragraph
|
||||||
// and the paragraph is empty
|
// and the paragraph is empty
|
||||||
if (node == null ||
|
if (node == null ||
|
||||||
node.type != ParagraphBlockKeys.type ||
|
node.type != ParagraphBlockKeys.type ||
|
||||||
node.delta?.toPlainText().isNotEmpty == true) {
|
node.delta?.toPlainText().isNotEmpty == true) {
|
||||||
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) {
|
||||||
node,
|
Log.info('unable to get content header');
|
||||||
0,
|
return false;
|
||||||
text,
|
}
|
||||||
attributes: {AppFlowyRichTextKeys.href: text},
|
|
||||||
);
|
// insert the text with link format
|
||||||
|
final textTransaction = editorState.transaction
|
||||||
|
..insertText(
|
||||||
|
node,
|
||||||
|
0,
|
||||||
|
text,
|
||||||
|
attributes: {AppFlowyRichTextKeys.href: text},
|
||||||
|
);
|
||||||
await editorState.apply(
|
await editorState.apply(
|
||||||
textTransaction,
|
textTransaction,
|
||||||
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(
|
|
||||||
selection.start.path,
|
final replacementTransaction = editorState.transaction
|
||||||
insertedNodes,
|
..insertNodes(
|
||||||
);
|
selection.start.path,
|
||||||
linkPreviewTransaction.deleteNode(node);
|
replacementInsertedNodes,
|
||||||
linkPreviewTransaction.afterSelection = Selection.collapsed(
|
)
|
||||||
Position(
|
..deleteNode(node)
|
||||||
path: node.path.next,
|
..afterSelection = Selection.collapsed(
|
||||||
),
|
Position(path: node.path.next),
|
||||||
);
|
);
|
||||||
await editorState.apply(linkPreviewTransaction);
|
|
||||||
|
await editorState.apply(replacementTransaction);
|
||||||
|
|
||||||
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';
|
||||||
|
}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user