feat: keep link format when converting preview block to text (#6435)

* feat: keep link format when converting preview block to text

* test: add test

* fix: flutter analyze

* feat: ctrl/cmd+z to revert the link prevew op

* test: add test

* chore: update toast style
This commit is contained in:
Lucas 2024-10-01 12:53:44 +08:00 committed by GitHub
parent eca495ce63
commit 46e45c3715
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 144 additions and 26 deletions

View File

@ -1,12 +1,17 @@
import 'dart:io'; 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/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/startup/startup.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:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'package:universal_platform/universal_platform.dart';
import '../../shared/util.dart'; import '../../shared/util.dart';
@ -311,13 +316,75 @@ void main() {
'auto convert url to link preview block', 'auto convert url to link preview block',
(tester) async { (tester) async {
const url = 'https://appflowy.io'; 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 // the second one is the paragraph node
expect(editorState.document.root.children.length, 2); expect(editorState.document.root.children.length, 2);
final node = editorState.getNodeAtPath([0])!; final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type); expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url); 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},
}
]);
}, },
); );

View File

@ -49,7 +49,13 @@ class BlockActionBottomSheet extends StatelessWidget {
FlowyOptionTile.text( FlowyOptionTile.text(
showTopBorder: false, showTopBorder: false,
text: LocaleKeys.button_duplicate.tr(), 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), onTap: () => onAction(BlockActionBottomSheetType.duplicate),
), ),
@ -59,7 +65,8 @@ class BlockActionBottomSheet extends StatelessWidget {
showTopBorder: false, showTopBorder: false,
text: LocaleKeys.button_delete.tr(), text: LocaleKeys.button_delete.tr(),
leftIcon: FlowySvg( leftIcon: FlowySvg(
FlowySvgs.m_delete_s, FlowySvgs.trash_s,
size: const Size.square(18),
color: Theme.of(context).colorScheme.error, color: Theme.of(context).colorScheme.error,
), ),
textColor: Theme.of(context).colorScheme.error, textColor: Theme.of(context).colorScheme.error,

View File

@ -117,12 +117,14 @@ Future<bool> _pasteAsLinkPreview(
// 1. the url should contains a protocol // 1. the url should contains a protocol
// 2. the url should not be an image url // 2. the url should not be an image url
if (text == null || if (text == null ||
!isURL(text, {'require_protocol': true}) || text.isImageUrl() ||
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
// and at the start of the current line
if (selection == null || if (selection == null ||
!selection.isCollapsed || !selection.isCollapsed ||
selection.startIndex != 0) { selection.startIndex != 0) {
@ -130,18 +132,45 @@ 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
// 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;
} }
final transaction = editorState.transaction; // 1. insert the text with link format
transaction.insertNode( // 2. convert it the link preview node
selection.start.path, final textTransaction = editorState.transaction;
linkPreviewNode(url: text), 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; return true;
} }

View File

@ -123,7 +123,10 @@ class CustomLinkPreviewWidget extends StatelessWidget {
node: node, node: node,
editorState: context.read<EditorState>(), editorState: context.read<EditorState>(),
extendActionWidgets: _buildExtendActionWidgets(context), extendActionWidgets: _buildExtendActionWidgets(context),
child: child, child: GestureDetector(
onTap: () => afLaunchUrlString(url),
child: child,
),
); );
} }
@ -134,8 +137,8 @@ class CustomLinkPreviewWidget extends StatelessWidget {
showTopBorder: false, showTopBorder: false,
text: LocaleKeys.document_plugins_urlPreview_convertToLink.tr(), text: LocaleKeys.document_plugins_urlPreview_convertToLink.tr(),
leftIcon: const FlowySvg( leftIcon: const FlowySvg(
FlowySvgs.m_aa_link_s, FlowySvgs.m_toolbar_link_m,
size: Size.square(20), size: Size.square(18),
), ),
onTap: () { onTap: () {
context.pop(); context.pop();

View File

@ -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/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.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/block_menu/block_menu_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.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/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.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 'package:provider/provider.dart';
import '../image/custom_image_block_component/custom_image_block_component.dart'; import '../image/custom_image_block_component/custom_image_block_component.dart';
@ -50,8 +49,8 @@ class _LinkPreviewMenuState extends State<LinkPreviewMenu> {
const HSpace(4), const HSpace(4),
MenuBlockButton( MenuBlockButton(
tooltip: LocaleKeys.document_plugins_urlPreview_convertToLink.tr(), tooltip: LocaleKeys.document_plugins_urlPreview_convertToLink.tr(),
iconData: FlowySvgs.m_aa_link_s, iconData: FlowySvgs.m_toolbar_link_m,
onTap: () => convertUrlPreviewNodeToLink( onTap: () async => convertUrlPreviewNodeToLink(
context.read<EditorState>(), context.read<EditorState>(),
widget.node, widget.node,
), ),
@ -65,7 +64,7 @@ class _LinkPreviewMenuState extends State<LinkPreviewMenu> {
const _Divider(), const _Divider(),
MenuBlockButton( MenuBlockButton(
tooltip: LocaleKeys.button_delete.tr(), tooltip: LocaleKeys.button_delete.tr(),
iconData: FlowySvgs.delete_s, iconData: FlowySvgs.trash_s,
onTap: deleteLinkPreviewNode, onTap: deleteLinkPreviewNode,
), ),
const HSpace(4), const HSpace(4),
@ -78,9 +77,9 @@ class _LinkPreviewMenuState extends State<LinkPreviewMenu> {
final url = widget.node.attributes[CustomImageBlockKeys.url]; final url = widget.node.attributes[CustomImageBlockKeys.url];
if (url != null) { if (url != null) {
Clipboard.setData(ClipboardData(text: url)); Clipboard.setData(ClipboardData(text: url));
showSnackBarMessage( showToastNotification(
context, context,
LocaleKeys.document_plugins_urlPreview_copiedToPasteBoard.tr(), message: LocaleKeys.document_plugins_urlPreview_copiedToPasteBoard.tr(),
); );
} }
} }

View File

@ -1,12 +1,25 @@
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';
void convertUrlPreviewNodeToLink(EditorState editorState, Node node) { Future<void> convertUrlPreviewNodeToLink(
assert(node.type == LinkPreviewBlockKeys.type); EditorState editorState,
Node node,
) async {
if (node.type != LinkPreviewBlockKeys.type) {
return;
}
final url = node.attributes[ImageBlockKeys.url]; final url = node.attributes[ImageBlockKeys.url];
final delta = Delta()
..insert(
url,
attributes: {
AppFlowyRichTextKeys.href: url,
},
);
final transaction = editorState.transaction; final transaction = editorState.transaction;
transaction transaction
..insertNode(node.path, paragraphNode(text: url)) ..insertNode(node.path, paragraphNode(delta: delta))
..deleteNode(node); ..deleteNode(node);
transaction.afterSelection = Selection.collapsed( transaction.afterSelection = Selection.collapsed(
Position( Position(
@ -14,5 +27,5 @@ void convertUrlPreviewNodeToLink(EditorState editorState, Node node) {
offset: url.length, offset: url.length,
), ),
); );
editorState.apply(transaction); return editorState.apply(transaction);
} }