mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-11-13 17:07:40 +00:00
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:
parent
eca495ce63
commit
46e45c3715
@ -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},
|
||||||
|
}
|
||||||
|
]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user