diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart index 08e3f4e760..6cc55460cc 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart @@ -240,15 +240,7 @@ class _LinkHoverTriggerState extends State { Future copyLink(BuildContext context) async { final href = widget.attribute.href ?? ''; - if (href.isEmpty) return; - await getIt() - .setData(ClipboardServiceData(plainText: href)); - if (context.mounted) { - showToastNotification( - context, - message: LocaleKeys.shareAction_copyLinkSuccess.tr(), - ); - } + await context.copyLink(href); hoverMenuController.close(); } @@ -621,3 +613,17 @@ enum LinkConvertMenuCommand { } } } + +extension LinkExtension on BuildContext { + Future copyLink(String link) async { + if (link.isEmpty) return; + await getIt() + .setData(ClipboardServiceData(plainText: link)); + if (mounted) { + showToastNotification( + this, + message: LocaleKeys.shareAction_copyLinkSuccess.tr(), + ); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart index 70fa56c57e..f2111a9576 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart @@ -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_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 { const LinkEmbedKeys._(); static const String previewType = 'preview_type'; static const String embed = 'embed'; + static const String align = 'align'; } Node linkEmbedNode({required String url}) => Node( @@ -14,3 +26,259 @@ Node linkEmbedNode({required String url}) => Node( 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 createState() => + LinkEmbedBlockComponentState(); +} + +class LinkEmbedBlockComponentState extends State + 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(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( + valueListenable: showActionsNotifier, + builder: (context, showActions, child) { + if (!showActions) return SizedBox.shrink(); + return LinkEmbedMenu( + editorState: context.read(), + 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 } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart new file mode 100644 index 0000000000..8b9ac122a8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart @@ -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 createState() => _LinkEmbedMenuState(); +} + +class _LinkEmbedMenuState extends State { + 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 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(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_widget.dart deleted file mode 100644 index 71cb8948fb..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_embed/link_embed_widget.dart +++ /dev/null @@ -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(); - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart new file mode 100644 index 0000000000..15794ee176 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart @@ -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> _listeners = >{}; + + Future start(String url) async { + final data = await _cache.get(url); + if (data != null) { + refreshLinkInfo(data); + } + await _getLinkInfo(url); + } + + Future _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 listener) { + _listeners.add(listener); + } + + void dispose() { + _listeners.clear(); + } +} + +class LinkInfo { + factory LinkInfo.fromJson(Map 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 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 get(String url) async { + final option = await getIt().getWithFormat( + url, + (value) => LinkInfo.fromJson(jsonDecode(value)), + ); + return option; + } + + Future set(String url, LinkInfo data) async { + await getIt().set( + url, + jsonEncode(data.toJson()), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart index b1b616e592..163b1f13e0 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart @@ -54,8 +54,8 @@ class CustomLinkPreviewWidget extends StatelessWidget { decoration: BoxDecoration( border: Border.all( color: isHovering - ? borderScheme.greyPrimaryHover - : borderScheme.greyPrimary, + ? borderScheme.greyTertiaryHover + : borderScheme.greyTertiary, ), borderRadius: BorderRadius.circular(16.0), ), @@ -177,7 +177,7 @@ class CustomLinkPreviewWidget extends StatelessWidget { ), child: Container( width: width, - color: fillScheme.primary, + color: fillScheme.quaternary, child: child, ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart index 0ec8a04e13..3772169331 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart @@ -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_plugins/appflowy_editor_plugins.dart'; import 'package:flutter/material.dart'; @@ -16,6 +17,18 @@ class CustomLinkPreviewBlockComponentBuilder extends BlockComponentBuilder { @override BlockComponentWidget build(BlockComponentContext blockComponentContext) { 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( key: node.key, node: node, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart index ffe8a277fd..52f7d34fc3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart @@ -34,6 +34,7 @@ class _CustomLinkPreviewMenuState extends State { final popoverController = PopoverController(); final buttonKey = GlobalKey(); bool closed = false; + bool selected = false; @override void dispose() { @@ -59,10 +60,14 @@ class _CustomLinkPreviewMenuState extends State { closed = false; widget.onMenuHided.call(); } + setState(() { + selected = false; + }); }, popupBuilder: (context) => buildMenu(), child: FlowyIconButton( key: buttonKey, + isSelected: selected, icon: FlowySvg(FlowySvgs.toolbar_more_m), onPressed: showPopover, ), @@ -161,6 +166,9 @@ class _CustomLinkPreviewMenuState extends State { widget.onMenuShowed.call(); keepEditorFocusNotifier.increase(); popoverController.show(); + setState(() { + selected = true; + }); } void closePopover() { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart index d826998b25..3cc2a89df3 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart @@ -227,7 +227,7 @@ enum PasteMenuType { embed, } -extension on PasteMenuType { +extension PasteMenuTypeExtension on PasteMenuType { String get title { switch (this) { case PasteMenuType.mention: diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart index 905bc90954..8b193c70fb 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/link_preview/shared.dart @@ -119,7 +119,8 @@ Future convertUrlToLinkPreview( linkEmbedNode(url: url) else linkPreviewNode(url: url), - paragraphNode(delta: Delta(operations: afterOperations)), + if (afterOperations.isNotEmpty) + paragraphNode(delta: Delta(operations: afterOperations)), ]); await editorState.apply(transaction); } @@ -176,10 +177,12 @@ Future convertLinkBlockToOtherLinkBlock( final insertedNode = []; final afterUrl = url ?? node.attributes[LinkPreviewBlockKeys.url] ?? ''; + final previewType = node.attributes[LinkEmbedKeys.previewType]; Node afterNode = node.copyWith( type: toType, attributes: { LinkPreviewBlockKeys.url: afterUrl, + LinkEmbedKeys.previewType: previewType, blockComponentBackgroundColor: node.attributes[blockComponentBackgroundColor], blockComponentTextDirection: node.attributes[blockComponentTextDirection], diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart index dc37cea9dd..a72c3ada53 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart @@ -1,28 +1,18 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:math'; - -import 'package:appflowy/core/config/kv.dart'; 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/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.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/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_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/style_widget/hover.dart'; import 'package:flutter/material.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_preview.dart'; @@ -55,11 +45,12 @@ class _MentionLinkBlockState extends State { final previewController = PopoverController(); LinkInfo? linkInfo; bool isHovering = false; - bool isPreviewShowing = false; + int previewFocusNum = 0; bool isPreviewHovering = false; bool showAtBottom = false; final key = GlobalKey(); + bool get isPreviewShowing => previewFocusNum > 0; String get url => widget.url; EditorState get editorState => widget.editorState; @@ -100,6 +91,7 @@ class _MentionLinkBlockState extends State { @override Widget build(BuildContext context) { return AppFlowyPopover( + key: ValueKey(showAtBottom), controller: previewController, direction: showAtBottom ? PopoverDirection.bottomWithLeftAligned @@ -107,11 +99,11 @@ class _MentionLinkBlockState extends State { offset: Offset(0, showAtBottom ? -20 : 20), onOpen: () { keepEditorFocusNotifier.increase(); - isPreviewShowing = true; + previewFocusNum++; }, onClose: () { keepEditorFocusNotifier.decrease(); - isPreviewShowing = false; + previewFocusNum--; }, decorationColor: Colors.transparent, popoverDecoration: BoxDecoration(), @@ -201,26 +193,8 @@ class _MentionLinkBlockState extends State { padding: const EdgeInsets.all(2.0), child: const CircularProgressIndicator(strokeWidth: 1), ); - } else if (status == _LoadingStatus.error) { - icon = defaultWidget; } else { - final faviconUrl = linkInfo?.faviconUrl; - 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, - ); - } - } + icon = linkInfo?.buildIconWidget() ?? defaultWidget; } return SizedBox( height: 20, @@ -234,14 +208,7 @@ class _MentionLinkBlockState extends State { Size getSizeFromKey() => box?.size ?? Size.zero; Future copyLink(BuildContext context) async { - await getIt() - .setData(ClipboardServiceData(plainText: url)); - if (context.mounted) { - showToastNotification( - context, - message: LocaleKeys.shareAction_copyLinkSuccess.tr(), - ); - } + await context.copyLink(url); previewController.close(); } @@ -260,6 +227,8 @@ class _MentionLinkBlockState extends State { await toUrl(); } else if (type == PasteMenuType.bookmark) { await toLinkPreview(); + } else if (type == PasteMenuType.embed) { + await toLinkPreview(previewType: LinkEmbedKeys.embed); } } @@ -277,7 +246,7 @@ class _MentionLinkBlockState extends State { await editorState.apply(transaction); } - Future toLinkPreview() async { + Future toLinkPreview({String? previewType}) async { final selection = Selection( start: Position(path: node.path, offset: index), end: Position(path: node.path, offset: index + 1), @@ -286,11 +255,8 @@ class _MentionLinkBlockState extends State { editorState, selection, 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) { @@ -320,14 +286,6 @@ class _MentionLinkBlockState extends State { }); } - void showPreview() { - if (isPreviewShowing || !mounted) { - return; - } - keepEditorFocusNotifier.increase(); - previewController.show(); - } - void onEnter(PointerEnterEvent e) { changeHovering(true); final location = box?.localToGlobal(Offset.zero) ?? Offset.zero; @@ -350,6 +308,13 @@ class _MentionLinkBlockState extends State { tryToDismissPreview(); } + void showPreview() { + if (!mounted) return; + keepEditorFocusNotifier.increase(); + previewController.show(); + previewFocusNum++; + } + BoxConstraints getConstraints() { final size = getSizeFromKey(); if (!readyForPreview) { @@ -365,124 +330,6 @@ class _MentionLinkBlockState extends State { } } -class LinkParser { - static final LinkInfoCache _cache = LinkInfoCache(); - final Set> _listeners = >{}; - - Future start(String url) async { - final data = await _cache.get(url); - if (data != null) { - refreshLinkInfo(data); - } - await _getLinkInfo(url); - } - - Future _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 listener) { - _listeners.add(listener); - } - - void dispose() { - _listeners.clear(); - } -} - -class LinkInfo { - factory LinkInfo.fromJson(Map 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 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 get(String url) async { - final option = await getIt().getWithFormat( - url, - (value) => LinkInfo.fromJson(jsonDecode(value)), - ); - return option; - } - - Future set(String url, LinkInfo data) async { - await getIt().set( - url, - jsonEncode(data.toJson()), - ); - } -} - enum _LoadingStatus { loading, idle, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart index 2a57a0090f..00082f127a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart @@ -1,6 +1,7 @@ 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_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_style.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/services.dart'; -import 'mention_link_block.dart'; - class MentionLinkPreview extends StatefulWidget { const MentionLinkPreview({ super.key, @@ -106,7 +105,7 @@ class _MentionLinkPreviewState extends State { height: 28, child: Row( children: [ - linkInfo.getIconWidget(size: Size.square(16)), + linkInfo.buildIconWidget(size: Size.square(16)), HSpace(6), Expanded( child: FlowyText( diff --git a/frontend/resources/flowy_icons/20x/embed_fullscreen.svg b/frontend/resources/flowy_icons/20x/embed_fullscreen.svg new file mode 100644 index 0000000000..b8b197fb13 --- /dev/null +++ b/frontend/resources/flowy_icons/20x/embed_fullscreen.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/40x/embed_error.svg b/frontend/resources/flowy_icons/40x/embed_error.svg new file mode 100644 index 0000000000..68196c7b7e --- /dev/null +++ b/frontend/resources/flowy_icons/40x/embed_error.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 734710cd73..1f7f714f5d 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2027,7 +2027,8 @@ "replace": "Replace", "reload": "Reload", "removeLink": "Remove Link", - "pasteHint": "Paste in https://..." + "pasteHint": "Paste in https://...", + "refuseConnect": "refued to connect." } } },