feat: add embed link preview

This commit is contained in:
Morn 2025-04-08 22:05:19 +08:00
parent 338c676c83
commit c5fe9fcc02
15 changed files with 809 additions and 221 deletions

View file

@ -240,15 +240,7 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
Future<void> copyLink(BuildContext context) async { Future<void> copyLink(BuildContext context) async {
final href = widget.attribute.href ?? ''; final href = widget.attribute.href ?? '';
if (href.isEmpty) return; await context.copyLink(href);
await getIt<ClipboardService>()
.setData(ClipboardServiceData(plainText: href));
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
hoverMenuController.close(); hoverMenuController.close();
} }
@ -621,3 +613,17 @@ enum LinkConvertMenuCommand {
} }
} }
} }
extension LinkExtension on BuildContext {
Future<void> copyLink(String link) async {
if (link.isEmpty) return;
await getIt<ClipboardService>()
.setData(ClipboardServiceData(plainText: link));
if (mounted) {
showToastNotification(
this,
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
}
}

View file

@ -1,10 +1,22 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart';
import 'package:appflowy/shared/appflowy_network_image.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:appflowy_ui/appflowy_ui.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_bloc/flutter_bloc.dart';
import 'link_embed_menu.dart';
class LinkEmbedKeys { class LinkEmbedKeys {
const LinkEmbedKeys._(); const LinkEmbedKeys._();
static const String previewType = 'preview_type'; static const String previewType = 'preview_type';
static const String embed = 'embed'; static const String embed = 'embed';
static const String align = 'align';
} }
Node linkEmbedNode({required String url}) => Node( Node linkEmbedNode({required String url}) => Node(
@ -14,3 +26,259 @@ Node linkEmbedNode({required String url}) => Node(
LinkEmbedKeys.previewType: LinkEmbedKeys.embed, LinkEmbedKeys.previewType: LinkEmbedKeys.embed,
}, },
); );
class LinkEmbedBlockComponent extends BlockComponentStatefulWidget {
const LinkEmbedBlockComponent({
super.key,
super.showActions,
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
required super.node,
});
@override
State<LinkEmbedBlockComponent> createState() =>
LinkEmbedBlockComponentState();
}
class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
with BlockComponentConfigurable {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
String get url => widget.node.attributes[LinkPreviewBlockKeys.url]!;
EmbedLoadingStatus status = EmbedLoadingStatus.loading;
final parser = LinkParser();
LinkInfo linkInfo = LinkInfo();
final showActionsNotifier = ValueNotifier<bool>(false);
bool isMenuShowing = false, isHovering = false;
@override
void initState() {
super.initState();
parser.addLinkInfoListener((v) {
if (mounted) {
setState(() {
linkInfo = v;
if (v.isEmpty()) {
status = EmbedLoadingStatus.error;
} else {
status = EmbedLoadingStatus.idle;
}
});
}
});
parser.start(url);
}
@override
void dispose() {
parser.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget result = MouseRegion(
onEnter: (_) {
isHovering = true;
showActionsNotifier.value = true;
},
onExit: (_) {
isHovering = false;
Future.delayed(const Duration(milliseconds: 200), () {
if (isMenuShowing || isHovering) return;
if (mounted) showActionsNotifier.value = false;
});
},
child: buildChild(context),
);
result = Padding(padding: padding, child: result);
if (widget.showActions && widget.actionBuilder != null) {
result = BlockComponentActionWrapper(
node: node,
actionBuilder: widget.actionBuilder!,
child: result,
);
}
return result;
}
Widget buildChild(BuildContext context) {
final theme = AppFlowyTheme.of(context),
fillSceme = theme.fillColorScheme,
borderScheme = theme.borderColorScheme;
Widget child;
final isIdle = status == EmbedLoadingStatus.idle;
if (isIdle) {
child = buildContent(context);
} else {
child = buildErrorLoadingWidget(context);
}
return Container(
height: 450,
decoration: BoxDecoration(
color: isIdle ? Theme.of(context).cardColor : fillSceme.tertiaryHover,
borderRadius: BorderRadius.all(Radius.circular(16)),
border: Border.all(color: borderScheme.greyTertiary),
),
child: Stack(
children: [
child,
buildMenu(context),
],
),
);
}
Widget buildMenu(BuildContext context) {
return Positioned(
top: 12,
right: 12,
child: ValueListenableBuilder<bool>(
valueListenable: showActionsNotifier,
builder: (context, showActions, child) {
if (!showActions) return SizedBox.shrink();
return LinkEmbedMenu(
editorState: context.read<EditorState>(),
node: node,
onReload: () {
setState(() {
status = EmbedLoadingStatus.loading;
});
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) parser.start(url);
});
},
onMenuShowed: () {
isMenuShowing = true;
},
onMenuHided: () {
isMenuShowing = false;
if (!isHovering && mounted) {
showActionsNotifier.value = false;
}
},
);
},
),
);
}
Widget buildContent(BuildContext context) {
final theme = AppFlowyTheme.of(context), textScheme = theme.textColorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
child: FlowyNetworkImage(
url: linkInfo.imageUrl ?? '',
width: double.infinity,
),
),
),
Container(
height: 64,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20),
child: Row(
children: [
SizedBox.square(
dimension: 40,
child: Center(
child: linkInfo.buildIconWidget(size: Size.square(32)),
),
),
HSpace(12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
linkInfo.siteName ?? '',
color: textScheme.primary,
fontSize: 14,
figmaLineHeight: 20,
fontWeight: FontWeight.w600,
overflow: TextOverflow.ellipsis,
),
VSpace(4),
FlowyText.regular(
url,
color: textScheme.secondary,
fontSize: 12,
figmaLineHeight: 16,
overflow: TextOverflow.ellipsis,
),
],
),
],
),
),
],
);
}
Widget buildErrorLoadingWidget(BuildContext context) {
final theme = AppFlowyTheme.of(context), textSceme = theme.textColorScheme;
final isLoading = status == EmbedLoadingStatus.loading;
return isLoading
? Center(
child: SizedBox.square(
dimension: 64,
child: CircularProgressIndicator.adaptive(),
),
)
: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SvgPicture.asset(
FlowySvgs.embed_error_xl.path,
),
VSpace(4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(
children: [
TextSpan(
text: '$url ',
style: TextStyle(
color: textSceme.primary,
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w700,
),
),
TextSpan(
text: LocaleKeys
.document_plugins_linkPreview_linkPreviewMenu_refuseConnect
.tr(),
style: TextStyle(
color: textSceme.primary,
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w400,
),
),
],
),
),
),
],
),
);
}
}
enum EmbedLoadingStatus { loading, idle, error }

View file

@ -0,0 +1,332 @@
import 'package:appflowy/core/helpers/url_launcher.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/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/widgets.dart';
import 'link_embed_block_component.dart';
class LinkEmbedMenu extends StatefulWidget {
const LinkEmbedMenu({
super.key,
required this.node,
required this.editorState,
required this.onMenuShowed,
required this.onMenuHided,
required this.onReload,
});
final Node node;
final EditorState editorState;
final VoidCallback onMenuShowed;
final VoidCallback onMenuHided;
final VoidCallback onReload;
@override
State<LinkEmbedMenu> createState() => _LinkEmbedMenuState();
}
class _LinkEmbedMenuState extends State<LinkEmbedMenu> {
final turnintoController = PopoverController();
final moreOptionController = PopoverController();
int turnintoMenuNum = 0, moreOptionNum = 0, alignMenuNum = 0;
final moreOptionButtonKey = GlobalKey();
bool get isTurnIntoShowing => turnintoMenuNum > 0;
bool get isMoreOptionShowing => moreOptionNum > 0;
bool get isAlignMenuShowing => alignMenuNum > 0;
Node get node => widget.node;
EditorState get editorState => widget.editorState;
String get url => node.attributes[LinkPreviewBlockKeys.url] ?? '';
@override
void dispose() {
super.dispose();
turnintoController.close();
moreOptionController.close();
widget.onMenuHided.call();
}
@override
Widget build(BuildContext context) {
return buildChild();
}
Widget buildChild() {
final theme = AppFlowyTheme.of(context),
iconScheme = theme.iconColorTheme,
fillScheme = theme.fillColorScheme;
return Container(
padding: EdgeInsets.all(4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: fillScheme.primaryAlpha80,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// FlowyIconButton(
// icon: FlowySvg(
// FlowySvgs.embed_fullscreen_m,
// color: iconScheme.tertiary,
// ),
// tooltipText: LocaleKeys.document_imageBlock_openFullScreen.tr(),
// preferBelow: false,
// onPressed: () {},
// ),
FlowyIconButton(
icon: FlowySvg(
FlowySvgs.toolbar_link_m,
color: iconScheme.tertiary,
),
tooltipText: LocaleKeys.editor_copyLink.tr(),
preferBelow: false,
onPressed: () => copyLink(context),
),
buildTurnIntoBotton(),
buildMoreOptionBotton(),
],
),
);
}
Widget buildTurnIntoBotton() {
final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorTheme;
return AppFlowyPopover(
offset: Offset(0, 6),
direction: PopoverDirection.bottomWithRightAligned,
margin: EdgeInsets.zero,
controller: turnintoController,
onOpen: () {
keepEditorFocusNotifier.increase();
turnintoMenuNum++;
},
onClose: () {
keepEditorFocusNotifier.decrease();
turnintoMenuNum--;
checkToHideMenu();
},
popupBuilder: (context) => buildTurnIntoMenu(),
child: FlowyIconButton(
icon: FlowySvg(
FlowySvgs.turninto_m,
color: iconScheme.tertiary,
),
tooltipText: LocaleKeys.document_toolbar_turnInto.tr(),
preferBelow: false,
onPressed: showTurnIntoMenu,
),
);
}
Widget buildTurnIntoMenu() {
final types =
PasteMenuType.values.where((e) => e != PasteMenuType.embed).toList();
return Padding(
padding: const EdgeInsets.all(8.0),
child: SeparatedColumn(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () => const VSpace(0.0),
children: List.generate(types.length, (index) {
final command = types[index];
return SizedBox(
height: 36,
child: FlowyButton(
text: FlowyText(
command.title,
fontWeight: FontWeight.w400,
figmaLineHeight: 20,
),
onTap: () {
if (command == PasteMenuType.bookmark) {
final transaction = editorState.transaction;
transaction.updateNode(node, {
LinkPreviewBlockKeys.url: url,
LinkEmbedKeys.previewType: '',
});
editorState.apply(transaction);
} else if (command == PasteMenuType.mention) {
convertUrlPreviewNodeToMention(editorState, node);
} else if (command == PasteMenuType.url) {
convertUrlPreviewNodeToLink(editorState, node);
}
},
),
);
}),
),
);
}
Widget buildMoreOptionBotton() {
final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorTheme;
return AppFlowyPopover(
offset: Offset(0, 6),
direction: PopoverDirection.bottomWithRightAligned,
margin: EdgeInsets.zero,
controller: moreOptionController,
onOpen: () {
keepEditorFocusNotifier.increase();
moreOptionNum++;
},
onClose: () {
keepEditorFocusNotifier.decrease();
moreOptionNum--;
checkToHideMenu();
},
popupBuilder: (context) => buildMoreOptionMenu(),
child: FlowyIconButton(
key: moreOptionButtonKey,
icon: FlowySvg(
FlowySvgs.toolbar_more_m,
color: iconScheme.tertiary,
),
tooltipText: LocaleKeys.document_toolbar_moreOptions.tr(),
preferBelow: false,
onPressed: showMoreOptionMenu,
),
);
}
Widget buildMoreOptionMenu() {
final types = LinkEmbedMenuCommand.values;
return Padding(
padding: const EdgeInsets.all(8.0),
child: SeparatedColumn(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () => const VSpace(0.0),
children: List.generate(types.length, (index) {
final command = types[index];
return SizedBox(
height: 36,
child: FlowyButton(
text: FlowyText(
command.title,
fontWeight: FontWeight.w400,
figmaLineHeight: 20,
),
onTap: () => onEmbedMenuCommand(command),
),
);
}),
),
);
}
void showTurnIntoMenu() {
keepEditorFocusNotifier.increase();
turnintoController.show();
checkToShowMenu();
turnintoMenuNum++;
if (isMoreOptionShowing) closeMoreOptionMenu();
}
void closeTurnIntoMenu() {
turnintoController.close();
checkToHideMenu();
}
void showMoreOptionMenu() {
keepEditorFocusNotifier.increase();
moreOptionController.show();
checkToShowMenu();
moreOptionNum++;
if (isTurnIntoShowing) closeTurnIntoMenu();
}
void closeMoreOptionMenu() {
moreOptionController.close();
checkToHideMenu();
}
void checkToHideMenu() {
Future.delayed(Duration(milliseconds: 200), () {
if (!mounted) return;
if (!isAlignMenuShowing && !isMoreOptionShowing && !isTurnIntoShowing) {
widget.onMenuHided.call();
}
});
}
void checkToShowMenu() {
if (!isAlignMenuShowing && !isMoreOptionShowing && !isTurnIntoShowing) {
widget.onMenuShowed.call();
}
}
Future<void> copyLink(BuildContext context) async {
await context.copyLink(url);
widget.onMenuHided.call();
}
void onEmbedMenuCommand(LinkEmbedMenuCommand command) {
switch (command) {
case LinkEmbedMenuCommand.openLink:
afLaunchUrlString(url, addingHttpSchemeWhenFailed: true);
break;
case LinkEmbedMenuCommand.replace:
final box = moreOptionButtonKey.currentContext?.findRenderObject()
as RenderBox?;
if (box == null) return;
final p = box.localToGlobal(Offset.zero);
showReplaceMenu(
context: context,
editorState: editorState,
node: node,
url: url,
ltrb: LTRB(left: p.dx - 330, top: p.dy),
onReplace: (url) async {
await convertLinkBlockToOtherLinkBlock(
editorState,
node,
node.type,
url: url,
);
},
);
break;
case LinkEmbedMenuCommand.reload:
widget.onReload.call();
break;
case LinkEmbedMenuCommand.removeLink:
removeUrlPreviewLink(editorState, node);
break;
}
closeMoreOptionMenu();
}
}
enum LinkEmbedMenuCommand {
openLink,
replace,
reload,
removeLink;
String get title {
switch (this) {
case openLink:
return LocaleKeys.editor_openLink.tr();
case replace:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_replace
.tr();
case reload:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_reload
.tr();
case removeLink:
return LocaleKeys
.document_plugins_linkPreview_linkPreviewMenu_removeLink
.tr();
}
}
}

View file

@ -1,29 +0,0 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
class LinkEmbedWidget extends StatelessWidget {
const LinkEmbedWidget({
super.key,
required this.node,
required this.url,
this.title,
this.description,
this.imageUrl,
this.isHovering = false,
this.status = LinkPreviewStatus.loading,
});
final Node node;
final String? title;
final String? description;
final String? imageUrl;
final String url;
final bool isHovering;
final LinkPreviewStatus status;
@override
Widget build(BuildContext context) {
return Container();
}
}

View file

@ -0,0 +1,130 @@
import 'dart:convert';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy/shared/appflowy_network_svg.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_backend/log.dart';
import 'package:favicon/favicon.dart';
import 'package:flutter/material.dart';
// ignore: depend_on_referenced_packages
import 'package:flutter_link_previewer/flutter_link_previewer.dart' hide Size;
class LinkParser {
static final LinkInfoCache _cache = LinkInfoCache();
final Set<ValueChanged<LinkInfo>> _listeners = <ValueChanged<LinkInfo>>{};
Future<void> start(String url) async {
final data = await _cache.get(url);
if (data != null) {
refreshLinkInfo(data);
}
await _getLinkInfo(url);
}
Future<LinkInfo?> _getLinkInfo(String url) async {
try {
final previewData = await getPreviewData(url);
final favicon = await FaviconFinder.getBest(url);
final linkInfo = LinkInfo(
siteName: previewData.title,
description: previewData.description,
imageUrl: previewData.image?.url,
faviconUrl: favicon?.url,
);
await _cache.set(url, linkInfo);
refreshLinkInfo(linkInfo);
return linkInfo;
} catch (e, s) {
Log.error('get link info error: ', e, s);
return null;
}
}
void refreshLinkInfo(LinkInfo info) {
for (final listener in _listeners) {
listener(info);
}
}
void addLinkInfoListener(ValueChanged<LinkInfo> listener) {
_listeners.add(listener);
}
void dispose() {
_listeners.clear();
}
}
class LinkInfo {
factory LinkInfo.fromJson(Map<String, dynamic> json) => LinkInfo(
siteName: json['siteName'],
description: json['description'],
imageUrl: json['imageUrl'],
faviconUrl: json['faviconUrl'],
);
LinkInfo({
this.siteName,
this.description,
this.imageUrl,
this.faviconUrl,
});
final String? siteName;
final String? description;
final String? imageUrl;
final String? faviconUrl;
Map<String, dynamic> toJson() => {
'siteName': siteName,
'description': description,
'imageUrl': imageUrl,
'faviconUrl': faviconUrl,
};
bool isEmpty() {
return siteName == null ||
description == null ||
imageUrl == null ||
faviconUrl == null;
}
Widget buildIconWidget({Size size = const Size.square(20.0)}) {
final iconUrl = faviconUrl;
if (iconUrl == null) {
return FlowySvg(FlowySvgs.toolbar_link_earth_m, size: size);
}
if (iconUrl.endsWith('.svg')) {
return FlowyNetworkSvg(
iconUrl,
height: size.height,
errorWidget: const FlowySvg(FlowySvgs.toolbar_link_earth_m),
);
}
return FlowyNetworkImage(
url: iconUrl,
fit: BoxFit.contain,
height: size.height,
errorWidgetBuilder: (context, error, stackTrace) =>
const FlowySvg(FlowySvgs.toolbar_link_earth_m),
);
}
}
class LinkInfoCache {
Future<LinkInfo?> get(String url) async {
final option = await getIt<KeyValueStorage>().getWithFormat<LinkInfo?>(
url,
(value) => LinkInfo.fromJson(jsonDecode(value)),
);
return option;
}
Future<void> set(String url, LinkInfo data) async {
await getIt<KeyValueStorage>().set(
url,
jsonEncode(data.toJson()),
);
}
}

View file

@ -54,8 +54,8 @@ class CustomLinkPreviewWidget extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(
color: isHovering color: isHovering
? borderScheme.greyPrimaryHover ? borderScheme.greyTertiaryHover
: borderScheme.greyPrimary, : borderScheme.greyTertiary,
), ),
borderRadius: BorderRadius.circular(16.0), borderRadius: BorderRadius.circular(16.0),
), ),
@ -177,7 +177,7 @@ class CustomLinkPreviewWidget extends StatelessWidget {
), ),
child: Container( child: Container(
width: width, width: width,
color: fillScheme.primary, color: fillScheme.quaternary,
child: child, child: child,
), ),
); );

View file

@ -1,3 +1,4 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.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:flutter/material.dart'; import 'package:flutter/material.dart';
@ -16,6 +17,18 @@ class CustomLinkPreviewBlockComponentBuilder extends BlockComponentBuilder {
@override @override
BlockComponentWidget build(BlockComponentContext blockComponentContext) { BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node; final node = blockComponentContext.node;
final isEmbed =
node.attributes[LinkEmbedKeys.previewType] == LinkEmbedKeys.embed;
if (isEmbed) {
return LinkEmbedBlockComponent(
key: node.key,
node: node,
configuration: configuration,
showActions: showActions(node),
actionBuilder: (_, state) =>
actionBuilder(blockComponentContext, state),
);
}
return CustomLinkPreviewBlockComponent( return CustomLinkPreviewBlockComponent(
key: node.key, key: node.key,
node: node, node: node,

View file

@ -34,6 +34,7 @@ class _CustomLinkPreviewMenuState extends State<CustomLinkPreviewMenu> {
final popoverController = PopoverController(); final popoverController = PopoverController();
final buttonKey = GlobalKey(); final buttonKey = GlobalKey();
bool closed = false; bool closed = false;
bool selected = false;
@override @override
void dispose() { void dispose() {
@ -59,10 +60,14 @@ class _CustomLinkPreviewMenuState extends State<CustomLinkPreviewMenu> {
closed = false; closed = false;
widget.onMenuHided.call(); widget.onMenuHided.call();
} }
setState(() {
selected = false;
});
}, },
popupBuilder: (context) => buildMenu(), popupBuilder: (context) => buildMenu(),
child: FlowyIconButton( child: FlowyIconButton(
key: buttonKey, key: buttonKey,
isSelected: selected,
icon: FlowySvg(FlowySvgs.toolbar_more_m), icon: FlowySvg(FlowySvgs.toolbar_more_m),
onPressed: showPopover, onPressed: showPopover,
), ),
@ -161,6 +166,9 @@ class _CustomLinkPreviewMenuState extends State<CustomLinkPreviewMenu> {
widget.onMenuShowed.call(); widget.onMenuShowed.call();
keepEditorFocusNotifier.increase(); keepEditorFocusNotifier.increase();
popoverController.show(); popoverController.show();
setState(() {
selected = true;
});
} }
void closePopover() { void closePopover() {

View file

@ -227,7 +227,7 @@ enum PasteMenuType {
embed, embed,
} }
extension on PasteMenuType { extension PasteMenuTypeExtension on PasteMenuType {
String get title { String get title {
switch (this) { switch (this) {
case PasteMenuType.mention: case PasteMenuType.mention:

View file

@ -119,6 +119,7 @@ Future<void> convertUrlToLinkPreview(
linkEmbedNode(url: url) linkEmbedNode(url: url)
else else
linkPreviewNode(url: url), linkPreviewNode(url: url),
if (afterOperations.isNotEmpty)
paragraphNode(delta: Delta(operations: afterOperations)), paragraphNode(delta: Delta(operations: afterOperations)),
]); ]);
await editorState.apply(transaction); await editorState.apply(transaction);
@ -176,10 +177,12 @@ Future<void> convertLinkBlockToOtherLinkBlock(
final insertedNode = <Node>[]; final insertedNode = <Node>[];
final afterUrl = url ?? node.attributes[LinkPreviewBlockKeys.url] ?? ''; final afterUrl = url ?? node.attributes[LinkPreviewBlockKeys.url] ?? '';
final previewType = node.attributes[LinkEmbedKeys.previewType];
Node afterNode = node.copyWith( Node afterNode = node.copyWith(
type: toType, type: toType,
attributes: { attributes: {
LinkPreviewBlockKeys.url: afterUrl, LinkPreviewBlockKeys.url: afterUrl,
LinkEmbedKeys.previewType: previewType,
blockComponentBackgroundColor: blockComponentBackgroundColor:
node.attributes[blockComponentBackgroundColor], node.attributes[blockComponentBackgroundColor],
blockComponentTextDirection: node.attributes[blockComponentTextDirection], blockComponentTextDirection: node.attributes[blockComponentTextDirection],

View file

@ -1,28 +1,18 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/core/helpers/url_launcher.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/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.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/shared/appflowy_network_svg.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_ui/appflowy_ui.dart'; import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:favicon/favicon.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
// ignore: depend_on_referenced_packages
import 'package:flutter_link_previewer/flutter_link_previewer.dart' hide Size;
import 'mention_link_error_preview.dart'; import 'mention_link_error_preview.dart';
import 'mention_link_preview.dart'; import 'mention_link_preview.dart';
@ -55,11 +45,12 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
final previewController = PopoverController(); final previewController = PopoverController();
LinkInfo? linkInfo; LinkInfo? linkInfo;
bool isHovering = false; bool isHovering = false;
bool isPreviewShowing = false; int previewFocusNum = 0;
bool isPreviewHovering = false; bool isPreviewHovering = false;
bool showAtBottom = false; bool showAtBottom = false;
final key = GlobalKey(); final key = GlobalKey();
bool get isPreviewShowing => previewFocusNum > 0;
String get url => widget.url; String get url => widget.url;
EditorState get editorState => widget.editorState; EditorState get editorState => widget.editorState;
@ -100,6 +91,7 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppFlowyPopover( return AppFlowyPopover(
key: ValueKey(showAtBottom),
controller: previewController, controller: previewController,
direction: showAtBottom direction: showAtBottom
? PopoverDirection.bottomWithLeftAligned ? PopoverDirection.bottomWithLeftAligned
@ -107,11 +99,11 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
offset: Offset(0, showAtBottom ? -20 : 20), offset: Offset(0, showAtBottom ? -20 : 20),
onOpen: () { onOpen: () {
keepEditorFocusNotifier.increase(); keepEditorFocusNotifier.increase();
isPreviewShowing = true; previewFocusNum++;
}, },
onClose: () { onClose: () {
keepEditorFocusNotifier.decrease(); keepEditorFocusNotifier.decrease();
isPreviewShowing = false; previewFocusNum--;
}, },
decorationColor: Colors.transparent, decorationColor: Colors.transparent,
popoverDecoration: BoxDecoration(), popoverDecoration: BoxDecoration(),
@ -201,26 +193,8 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
padding: const EdgeInsets.all(2.0), padding: const EdgeInsets.all(2.0),
child: const CircularProgressIndicator(strokeWidth: 1), child: const CircularProgressIndicator(strokeWidth: 1),
); );
} else if (status == _LoadingStatus.error) {
icon = defaultWidget;
} else { } else {
final faviconUrl = linkInfo?.faviconUrl; icon = linkInfo?.buildIconWidget() ?? defaultWidget;
if (faviconUrl != null) {
if (faviconUrl.endsWith('.svg')) {
icon = FlowyNetworkSvg(
faviconUrl,
height: 20,
errorWidget: defaultWidget,
);
} else {
icon = Image.network(
faviconUrl,
fit: BoxFit.contain,
height: 20,
errorBuilder: (context, error, stackTrace) => defaultWidget,
);
}
}
} }
return SizedBox( return SizedBox(
height: 20, height: 20,
@ -234,14 +208,7 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
Size getSizeFromKey() => box?.size ?? Size.zero; Size getSizeFromKey() => box?.size ?? Size.zero;
Future<void> copyLink(BuildContext context) async { Future<void> copyLink(BuildContext context) async {
await getIt<ClipboardService>() await context.copyLink(url);
.setData(ClipboardServiceData(plainText: url));
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
previewController.close(); previewController.close();
} }
@ -260,6 +227,8 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
await toUrl(); await toUrl();
} else if (type == PasteMenuType.bookmark) { } else if (type == PasteMenuType.bookmark) {
await toLinkPreview(); await toLinkPreview();
} else if (type == PasteMenuType.embed) {
await toLinkPreview(previewType: LinkEmbedKeys.embed);
} }
} }
@ -277,7 +246,7 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
await editorState.apply(transaction); await editorState.apply(transaction);
} }
Future<void> toLinkPreview() async { Future<void> toLinkPreview({String? previewType}) async {
final selection = Selection( final selection = Selection(
start: Position(path: node.path, offset: index), start: Position(path: node.path, offset: index),
end: Position(path: node.path, offset: index + 1), end: Position(path: node.path, offset: index + 1),
@ -286,11 +255,8 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
editorState, editorState,
selection, selection,
url, url,
previewType: previewType,
); );
// final transaction = editorState.transaction
// ..deleteText(node, index, 1)
// ..insertNode(node.path, linkPreviewNode(url: url));
// await editorState.apply(transaction);
} }
void changeHovering(bool hovering) { void changeHovering(bool hovering) {
@ -320,14 +286,6 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
}); });
} }
void showPreview() {
if (isPreviewShowing || !mounted) {
return;
}
keepEditorFocusNotifier.increase();
previewController.show();
}
void onEnter(PointerEnterEvent e) { void onEnter(PointerEnterEvent e) {
changeHovering(true); changeHovering(true);
final location = box?.localToGlobal(Offset.zero) ?? Offset.zero; final location = box?.localToGlobal(Offset.zero) ?? Offset.zero;
@ -350,6 +308,13 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
tryToDismissPreview(); tryToDismissPreview();
} }
void showPreview() {
if (!mounted) return;
keepEditorFocusNotifier.increase();
previewController.show();
previewFocusNum++;
}
BoxConstraints getConstraints() { BoxConstraints getConstraints() {
final size = getSizeFromKey(); final size = getSizeFromKey();
if (!readyForPreview) { if (!readyForPreview) {
@ -365,124 +330,6 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
} }
} }
class LinkParser {
static final LinkInfoCache _cache = LinkInfoCache();
final Set<ValueChanged<LinkInfo>> _listeners = <ValueChanged<LinkInfo>>{};
Future<void> start(String url) async {
final data = await _cache.get(url);
if (data != null) {
refreshLinkInfo(data);
}
await _getLinkInfo(url);
}
Future<LinkInfo?> _getLinkInfo(String url) async {
try {
final previewData = await getPreviewData(url);
final favicon = await FaviconFinder.getBest(url);
final linkInfo = LinkInfo(
siteName: previewData.title,
description: previewData.description,
imageUrl: previewData.image?.url,
faviconUrl: favicon?.url,
);
await _cache.set(url, linkInfo);
refreshLinkInfo(linkInfo);
return linkInfo;
} catch (e, s) {
Log.error('get link info error: ', e, s);
return null;
}
}
void refreshLinkInfo(LinkInfo info) {
for (final listener in _listeners) {
listener(info);
}
}
void addLinkInfoListener(ValueChanged<LinkInfo> listener) {
_listeners.add(listener);
}
void dispose() {
_listeners.clear();
}
}
class LinkInfo {
factory LinkInfo.fromJson(Map<String, dynamic> json) => LinkInfo(
siteName: json['siteName'],
description: json['description'],
imageUrl: json['imageUrl'],
faviconUrl: json['faviconUrl'],
);
LinkInfo({
this.siteName,
this.description,
this.imageUrl,
this.faviconUrl,
});
final String? siteName;
final String? description;
final String? imageUrl;
final String? faviconUrl;
Map<String, dynamic> toJson() => {
'siteName': siteName,
'description': description,
'imageUrl': imageUrl,
'faviconUrl': faviconUrl,
};
bool isEmpty() {
return siteName == null ||
description == null ||
imageUrl == null ||
faviconUrl == null;
}
Widget getIconWidget({Size size = const Size.square(20.0)}) {
if (faviconUrl == null) {
return FlowySvg(FlowySvgs.toolbar_link_earth_m, size: size);
}
if (faviconUrl!.endsWith('.svg')) {
return FlowyNetworkSvg(
faviconUrl!,
height: size.height,
errorWidget: const FlowySvg(FlowySvgs.toolbar_link_earth_m),
);
}
return Image.network(
faviconUrl!,
fit: BoxFit.contain,
height: size.height,
errorBuilder: (context, error, stackTrace) =>
const FlowySvg(FlowySvgs.toolbar_link_earth_m),
);
}
}
class LinkInfoCache {
Future<LinkInfo?> get(String url) async {
final option = await getIt<KeyValueStorage>().getWithFormat<LinkInfo?>(
url,
(value) => LinkInfo.fromJson(jsonDecode(value)),
);
return option;
}
Future<void> set(String url, LinkInfo data) async {
await getIt<KeyValueStorage>().set(
url,
jsonEncode(data.toJson()),
);
}
}
enum _LoadingStatus { enum _LoadingStatus {
loading, loading,
idle, idle,

View file

@ -1,6 +1,7 @@
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/desktop_toolbar/link/link_create_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/shared/appflowy_network_image.dart'; import 'package:appflowy/shared/appflowy_network_image.dart';
@ -11,8 +12,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'mention_link_block.dart';
class MentionLinkPreview extends StatefulWidget { class MentionLinkPreview extends StatefulWidget {
const MentionLinkPreview({ const MentionLinkPreview({
super.key, super.key,
@ -106,7 +105,7 @@ class _MentionLinkPreviewState extends State<MentionLinkPreview> {
height: 28, height: 28,
child: Row( child: Row(
children: [ children: [
linkInfo.getIconWidget(size: Size.square(16)), linkInfo.buildIconWidget(size: Size.square(16)),
HSpace(6), HSpace(6),
Expanded( Expanded(
child: FlowyText( child: FlowyText(

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.36379 11.6362L4 16M4 16H7.65133M4 16V12.3487M11.6362 8.36379L16 4M16 4H12.3487M16 4V7.65133" stroke="#B5BBD3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 274 B

View file

@ -0,0 +1,7 @@
<svg width="64" height="64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18 8a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4h28a4 4 0 0 0 4-4V20L38 8H18Z" fill="#fff"/>
<rect x="24" y="24" width="3" height="6" rx="1.5" fill="#D3D8E1"/>
<rect x="37" y="24" width="3" height="6" rx="1.5" fill="#D3D8E1"/>
<path d="m38 8 12 12H40a2 2 0 0 1-2-2V8Z" fill="#D3D8E1"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38 40a6 6 0 0 0-12 0h12Z" fill="#D3D8E1"/>
</svg>

After

Width:  |  Height:  |  Size: 519 B

View file

@ -2027,7 +2027,8 @@
"replace": "Replace", "replace": "Replace",
"reload": "Reload", "reload": "Reload",
"removeLink": "Remove Link", "removeLink": "Remove Link",
"pasteHint": "Paste in https://..." "pasteHint": "Paste in https://...",
"refuseConnect": "refued to connect."
} }
} }
}, },