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 '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},
}
]);
},
);

View File

@ -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,

View File

@ -117,12 +117,14 @@ Future<bool> _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<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
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;
}

View File

@ -123,7 +123,10 @@ class CustomLinkPreviewWidget extends StatelessWidget {
node: node,
editorState: context.read<EditorState>(),
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();

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/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<LinkPreviewMenu> {
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<EditorState>(),
widget.node,
),
@ -65,7 +64,7 @@ class _LinkPreviewMenuState extends State<LinkPreviewMenu> {
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<LinkPreviewMenu> {
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(),
);
}
}

View File

@ -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<void> 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);
}