mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-04-24 14:47:13 -04:00
feat: add embed link preview
This commit is contained in:
parent
338c676c83
commit
c5fe9fcc02
15 changed files with 809 additions and 221 deletions
|
@ -240,15 +240,7 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
|||
|
||||
Future<void> copyLink(BuildContext context) async {
|
||||
final href = widget.attribute.href ?? '';
|
||||
if (href.isEmpty) return;
|
||||
await getIt<ClipboardService>()
|
||||
.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<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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<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 }
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -34,6 +34,7 @@ class _CustomLinkPreviewMenuState extends State<CustomLinkPreviewMenu> {
|
|||
final popoverController = PopoverController();
|
||||
final buttonKey = GlobalKey();
|
||||
bool closed = false;
|
||||
bool selected = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
@ -59,10 +60,14 @@ class _CustomLinkPreviewMenuState extends State<CustomLinkPreviewMenu> {
|
|||
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<CustomLinkPreviewMenu> {
|
|||
widget.onMenuShowed.call();
|
||||
keepEditorFocusNotifier.increase();
|
||||
popoverController.show();
|
||||
setState(() {
|
||||
selected = true;
|
||||
});
|
||||
}
|
||||
|
||||
void closePopover() {
|
||||
|
|
|
@ -227,7 +227,7 @@ enum PasteMenuType {
|
|||
embed,
|
||||
}
|
||||
|
||||
extension on PasteMenuType {
|
||||
extension PasteMenuTypeExtension on PasteMenuType {
|
||||
String get title {
|
||||
switch (this) {
|
||||
case PasteMenuType.mention:
|
||||
|
|
|
@ -119,7 +119,8 @@ Future<void> 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<void> convertLinkBlockToOtherLinkBlock(
|
|||
final insertedNode = <Node>[];
|
||||
|
||||
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],
|
||||
|
|
|
@ -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<MentionLinkBlock> {
|
|||
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<MentionLinkBlock> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppFlowyPopover(
|
||||
key: ValueKey(showAtBottom),
|
||||
controller: previewController,
|
||||
direction: showAtBottom
|
||||
? PopoverDirection.bottomWithLeftAligned
|
||||
|
@ -107,11 +99,11 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
|
|||
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<MentionLinkBlock> {
|
|||
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<MentionLinkBlock> {
|
|||
Size getSizeFromKey() => box?.size ?? Size.zero;
|
||||
|
||||
Future<void> copyLink(BuildContext context) async {
|
||||
await getIt<ClipboardService>()
|
||||
.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<MentionLinkBlock> {
|
|||
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<MentionLinkBlock> {
|
|||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
Future<void> toLinkPreview() async {
|
||||
Future<void> 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<MentionLinkBlock> {
|
|||
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<MentionLinkBlock> {
|
|||
});
|
||||
}
|
||||
|
||||
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<MentionLinkBlock> {
|
|||
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<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 {
|
||||
loading,
|
||||
idle,
|
||||
|
|
|
@ -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<MentionLinkPreview> {
|
|||
height: 28,
|
||||
child: Row(
|
||||
children: [
|
||||
linkInfo.getIconWidget(size: Size.square(16)),
|
||||
linkInfo.buildIconWidget(size: Size.square(16)),
|
||||
HSpace(6),
|
||||
Expanded(
|
||||
child: FlowyText(
|
||||
|
|
3
frontend/resources/flowy_icons/20x/embed_fullscreen.svg
Normal file
3
frontend/resources/flowy_icons/20x/embed_fullscreen.svg
Normal 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 |
7
frontend/resources/flowy_icons/40x/embed_error.svg
Normal file
7
frontend/resources/flowy_icons/40x/embed_error.svg
Normal 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 |
|
@ -2027,7 +2027,8 @@
|
|||
"replace": "Replace",
|
||||
"reload": "Reload",
|
||||
"removeLink": "Remove Link",
|
||||
"pasteHint": "Paste in https://..."
|
||||
"pasteHint": "Paste in https://...",
|
||||
"refuseConnect": "refued to connect."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue