mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-04-24 22:57:12 -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 {
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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(
|
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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -119,7 +119,8 @@ Future<void> convertUrlToLinkPreview(
|
||||||
linkEmbedNode(url: url)
|
linkEmbedNode(url: url)
|
||||||
else
|
else
|
||||||
linkPreviewNode(url: url),
|
linkPreviewNode(url: url),
|
||||||
paragraphNode(delta: Delta(operations: afterOperations)),
|
if (afterOperations.isNotEmpty)
|
||||||
|
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],
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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(
|
||||||
|
|
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",
|
"replace": "Replace",
|
||||||
"reload": "Reload",
|
"reload": "Reload",
|
||||||
"removeLink": "Remove Link",
|
"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