feat: add link preview block (#4137)

* feat: add link preview block

* test: add integration test
This commit is contained in:
Lucas.Xu 2023-12-13 14:21:38 +07:00 committed by GitHub
parent d5b9063f78
commit 3f9b598493
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 385 additions and 4 deletions

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.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:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@ -276,6 +277,22 @@ void main() {
},
);
});
testWidgets(
'auto convert url to link preview block',
(widgetTester) async {
const url = 'https://appflowy.io';
await widgetTester.pasteContent(
plainText: url,
(editorState) {
expect(editorState.document.root.children.length, 2);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
},
);
},
);
}
extension on WidgetTester {

View File

@ -4,6 +4,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/cust
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
@ -186,6 +187,28 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
),
),
),
LinkPreviewBlockKeys.type: LinkPreviewBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (_) => const EdgeInsets.symmetric(vertical: 10),
),
cache: LinkPreviewDataCache(),
showMenu: true,
menuBuilder: (context, node, state) => Positioned(
top: 10,
right: 0,
child: LinkPreviewMenu(
node: node,
state: state,
),
),
builder: (context, node, url, title, description, imageUrl) =>
CustomLinkPreviewWidget(
url: url,
title: title,
description: description,
imageUrl: imageUrl,
),
),
errorBlockComponentBuilderKey: ErrorBlockComponentBuilder(
configuration: configuration,
),

View File

@ -6,7 +6,9 @@ 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/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:flutter/material.dart';
import 'package:string_validator/string_validator.dart';
/// Paste.
///
@ -37,6 +39,12 @@ CommandShortcutEventHandler _pasteCommandHandler = (editorState) {
final plainText = data.plainText;
final image = data.image;
// paste as link preview
final result = await _pasteAsLinkPreview(editorState, plainText);
if (result) {
return;
}
// Order:
// 1. in app json format
// 2. html
@ -75,3 +83,35 @@ CommandShortcutEventHandler _pasteCommandHandler = (editorState) {
return KeyEventResult.handled;
};
Future<bool> _pasteAsLinkPreview(
EditorState editorState,
String? text,
) async {
if (text == null || !isURL(text)) {
return false;
}
final selection = editorState.selection;
if (selection == null ||
!selection.isCollapsed ||
selection.startIndex != 0) {
return false;
}
final node = editorState.getNodeAtPath(selection.start.path);
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),
);
await editorState.apply(transaction);
return true;
}

View File

@ -0,0 +1,91 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher_string.dart';
class CustomLinkPreviewWidget extends StatelessWidget {
const CustomLinkPreviewWidget({
super.key,
required this.url,
this.title,
this.description,
this.imageUrl,
});
final String? title;
final String? description;
final String? imageUrl;
final String url;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => launchUrlString(url),
child: Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.onSurface,
),
borderRadius: BorderRadius.circular(
6.0,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (imageUrl != null)
ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(6.0),
bottomLeft: Radius.circular(6.0),
),
child: CachedNetworkImage(
imageUrl: imageUrl!,
width: 180,
height: 120,
fit: BoxFit.cover,
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (title != null)
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: FlowyText.medium(
title!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
fontSize: 16.0,
),
),
if (description != null)
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: FlowyText(
description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
FlowyText(
url.toString(),
overflow: TextOverflow.ellipsis,
maxLines: 2,
color: Theme.of(context).hintColor,
),
],
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,25 @@
import 'dart:convert';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
class LinkPreviewDataCache implements LinkPreviewDataCacheInterface {
@override
Future<LinkPreviewData?> get(String url) async {
final option =
await getIt<KeyValueStorage>().getWithFormat<LinkPreviewData?>(
url,
(value) => LinkPreviewData.fromJson(jsonDecode(value)),
);
return option.fold(() => null, (a) => a);
}
@override
Future<void> set(String url, LinkPreviewData data) async {
await getIt<KeyValueStorage>().set(
url,
jsonEncode(data.toJson()),
);
}
}

View File

@ -0,0 +1,131 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/home/toast.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';
class LinkPreviewMenu extends StatefulWidget {
const LinkPreviewMenu({
super.key,
required this.node,
required this.state,
});
final Node node;
final LinkPreviewBlockComponentState state;
@override
State<LinkPreviewMenu> createState() => _LinkPreviewMenuState();
}
class _LinkPreviewMenuState extends State<LinkPreviewMenu> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
height: 32,
decoration: BoxDecoration(
color: theme.cardColor,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(4.0),
),
child: Row(
children: [
const HSpace(4),
_CopyLinkButton(
onTap: copyImageLink,
),
const _Divider(),
_DeleteButton(
onTap: deleteLinkPreviewNode,
),
const HSpace(4),
],
),
);
}
void copyImageLink() {
final url = widget.node.attributes[ImageBlockKeys.url];
if (url != null) {
Clipboard.setData(ClipboardData(text: url));
showSnackBarMessage(
context,
LocaleKeys.document_plugins_urlPreview_copiedToPasteBoard.tr(),
);
}
}
Future<void> deleteLinkPreviewNode() async {
final node = widget.node;
final editorState = context.read<EditorState>();
final transaction = editorState.transaction;
transaction.deleteNode(node);
transaction.afterSelection = null;
await editorState.apply(transaction);
}
}
class _CopyLinkButton extends StatelessWidget {
const _CopyLinkButton({
required this.onTap,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: const FlowySvg(
FlowySvgs.copy_s,
size: Size.square(16),
),
);
}
}
class _DeleteButton extends StatelessWidget {
const _DeleteButton({
required this.onTap,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: const FlowySvg(
FlowySvgs.delete_s,
size: Size.square(16),
),
);
}
}
class _Divider extends StatelessWidget {
const _Divider();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Container(
width: 1,
color: Colors.grey,
),
);
}
}

View File

@ -24,6 +24,9 @@ export 'image/image_selection_menu.dart';
export 'image/mobile_image_toolbar_item.dart';
export 'inline_math_equation/inline_math_equation.dart';
export 'inline_math_equation/inline_math_equation_toolbar_item.dart';
export 'link_preview/custom_link_preview.dart';
export 'link_preview/link_preview_cache.dart';
export 'link_preview/link_preview_menu.dart';
export 'math_equation/math_equation_block_component.dart';
export 'math_equation/mobile_math_equation_toolbar_item.dart';
export 'mobile_toolbar_item/mobile_add_block_toolbar_item.dart';

View File

@ -59,6 +59,15 @@ packages:
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "2.1.0"
appflowy_editor_plugins:
dependency: "direct main"
description:
path: "."
ref: "6c49fea"
resolved-ref: "6c49feabd65d87ab6ab5c6942df3e85fa5add98d"
url: "https://github.com/LucasXu0/appflowy_editor_plugins"
source: git
version: "0.0.1"
appflowy_popover:
dependency: "direct main"
description:
@ -563,6 +572,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.3.1"
flutter_chat_types:
dependency: transitive
description:
name: flutter_chat_types
sha256: e285b588f6d19d907feb1f6d912deaf22e223656769c34093b64e1c59b094fb9
url: "https://pub.dev"
source: hosted
version: "3.6.2"
flutter_colorpicker:
dependency: "direct main"
description:
@ -585,6 +602,22 @@ packages:
url: "https://github.com/LucasXu0/emoji_mart.git"
source: git
version: "1.0.2"
flutter_link_previewer:
dependency: transitive
description:
name: flutter_link_previewer
sha256: "007069e60f42419fb59872beb7a3cc3ea21e9f1bdff5d40239f376fa62ca9f20"
url: "https://pub.dev"
source: hosted
version: "3.2.2"
flutter_linkify:
dependency: transitive
description:
name: flutter_linkify
sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_lints:
dependency: "direct dev"
description:
@ -994,6 +1027,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.0"
linkify:
dependency: transitive
description:
name: linkify
sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
lint:
dependency: transitive
description:
@ -1892,10 +1933,10 @@ packages:
dependency: "direct main"
description:
name: url_launcher
sha256: b1c9e98774adf8820c96fbc7ae3601231d324a7d5ebd8babe27b6dfac91357ba
sha256: e9aa5ea75c84cf46b3db4eea212523591211c3cf2e13099ee4ec147f54201c86
url: "https://pub.dev"
source: hosted
version: "6.2.1"
version: "6.2.2"
url_launcher_android:
dependency: transitive
description:

View File

@ -45,9 +45,10 @@ dependencies:
url: https://github.com/AppFlowy-IO/appflowy-board.git
ref: 93a1b70
appflowy_editor:
appflowy_editor_plugins:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "e6b1336"
url: https://github.com/LucasXu0/appflowy_editor_plugins
ref: "6c49fea"
appflowy_popover:
path: packages/appflowy_popover
@ -156,11 +157,17 @@ dependency_overrides:
git:
url: https://github.com/LucasXu0/app_links
ref: c64ce17
url_protocol:
git:
url: https://github.com/LucasXu0/flutter_url_protocol.git
commit: 77a8420
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "e6b1336"
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your

View File

@ -726,6 +726,9 @@
"copiedToPasteBoard": "The image link has been copied to the clipboard",
"addAnImage": "Add an image"
},
"urlPreview": {
"copiedToPasteBoard": "The link has been copied to the clipboard"
},
"outline": {
"addHeadingToCreateOutline": "Add headings to create a table of contents."
},