mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: add link preview block (#4137)
* feat: add link preview block * test: add integration test
This commit is contained in:
parent
d5b9063f78
commit
3f9b598493
@ -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 {
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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."
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user