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 9bd6a007f9..4a20398c4c 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 @@ -1,12 +1,17 @@ import 'dart:io'; +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/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:universal_platform/universal_platform.dart'; import '../../shared/util.dart'; @@ -311,13 +316,75 @@ void main() { 'auto convert url to link preview block', (tester) async { const url = 'https://appflowy.io'; - await tester.pasteContent(plainText: url, (editorState) { + 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}, + } + ]); }, ); diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_block_action_widget.dart b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_block_action_widget.dart index 7f7abdfab8..b8d0699969 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_block_action_widget.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/bottom_sheet/bottom_sheet_block_action_widget.dart @@ -49,7 +49,13 @@ class BlockActionBottomSheet extends StatelessWidget { FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.button_duplicate.tr(), - leftIcon: const FlowySvg(FlowySvgs.m_duplicate_s), + leftIcon: const Padding( + padding: EdgeInsets.all(2), + child: FlowySvg( + FlowySvgs.copy_s, + size: Size.square(16), + ), + ), onTap: () => onAction(BlockActionBottomSheetType.duplicate), ), @@ -59,7 +65,8 @@ class BlockActionBottomSheet extends StatelessWidget { showTopBorder: false, text: LocaleKeys.button_delete.tr(), leftIcon: FlowySvg( - FlowySvgs.m_delete_s, + FlowySvgs.trash_s, + size: const Size.square(18), color: Theme.of(context).colorScheme.error, ), textColor: Theme.of(context).colorScheme.error, 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 d8cd2f62df..69d6a933f6 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 @@ -117,12 +117,14 @@ Future _pasteAsLinkPreview( // 1. the url should contains a protocol // 2. the url should not be an image url if (text == null || - !isURL(text, {'require_protocol': true}) || - text.isImageUrl()) { + text.isImageUrl() || + !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 if (selection == null || !selection.isCollapsed || selection.startIndex != 0) { @@ -130,18 +132,45 @@ 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 if (node == null || node.type != ParagraphBlockKeys.type || node.delta?.toPlainText().isNotEmpty == true) { return false; } - final transaction = editorState.transaction; - transaction.insertNode( - selection.start.path, - linkPreviewNode(url: text), + // 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}, ); - await editorState.apply(transaction); + await editorState.apply( + textTransaction, + skipHistoryDebounce: true, + ); + + final linkPreviewTransaction = editorState.transaction; + final insertedNodes = [ + 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); return true; } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart index ea5ceee8d2..879a71f008 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart @@ -123,7 +123,10 @@ class CustomLinkPreviewWidget extends StatelessWidget { node: node, editorState: context.read(), extendActionWidgets: _buildExtendActionWidgets(context), - child: child, + child: GestureDetector( + onTap: () => afLaunchUrlString(url), + child: child, + ), ); } @@ -134,8 +137,8 @@ class CustomLinkPreviewWidget extends StatelessWidget { showTopBorder: false, text: LocaleKeys.document_plugins_urlPreview_convertToLink.tr(), leftIcon: const FlowySvg( - FlowySvgs.m_aa_link_s, - size: Size.square(20), + FlowySvgs.m_toolbar_link_m, + size: Size.square(18), ), onTap: () { context.pop(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart index d2c84fe456..61e5156060 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart @@ -1,15 +1,14 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - import 'package:appflowy/generated/flowy_svgs.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/link_preview/shared.dart'; -import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../image/custom_image_block_component/custom_image_block_component.dart'; @@ -50,8 +49,8 @@ class _LinkPreviewMenuState extends State { const HSpace(4), MenuBlockButton( tooltip: LocaleKeys.document_plugins_urlPreview_convertToLink.tr(), - iconData: FlowySvgs.m_aa_link_s, - onTap: () => convertUrlPreviewNodeToLink( + iconData: FlowySvgs.m_toolbar_link_m, + onTap: () async => convertUrlPreviewNodeToLink( context.read(), widget.node, ), @@ -65,7 +64,7 @@ class _LinkPreviewMenuState extends State { const _Divider(), MenuBlockButton( tooltip: LocaleKeys.button_delete.tr(), - iconData: FlowySvgs.delete_s, + iconData: FlowySvgs.trash_s, onTap: deleteLinkPreviewNode, ), const HSpace(4), @@ -78,9 +77,9 @@ class _LinkPreviewMenuState extends State { final url = widget.node.attributes[CustomImageBlockKeys.url]; if (url != null) { Clipboard.setData(ClipboardData(text: url)); - showSnackBarMessage( + showToastNotification( context, - LocaleKeys.document_plugins_urlPreview_copiedToPasteBoard.tr(), + message: LocaleKeys.document_plugins_urlPreview_copiedToPasteBoard.tr(), ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart index 11dae6075d..57564c4722 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart @@ -1,12 +1,25 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; -void convertUrlPreviewNodeToLink(EditorState editorState, Node node) { - assert(node.type == LinkPreviewBlockKeys.type); +Future convertUrlPreviewNodeToLink( + EditorState editorState, + Node node, +) async { + if (node.type != LinkPreviewBlockKeys.type) { + return; + } + final url = node.attributes[ImageBlockKeys.url]; + final delta = Delta() + ..insert( + url, + attributes: { + AppFlowyRichTextKeys.href: url, + }, + ); final transaction = editorState.transaction; transaction - ..insertNode(node.path, paragraphNode(text: url)) + ..insertNode(node.path, paragraphNode(delta: delta)) ..deleteNode(node); transaction.afterSelection = Selection.collapsed( Position( @@ -14,5 +27,5 @@ void convertUrlPreviewNodeToLink(EditorState editorState, Node node) { offset: url.length, ), ); - editorState.apply(transaction); + return editorState.apply(transaction); }