mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-04-24 22:57:12 -04:00
fix: some link_preview launch review issues
This commit is contained in:
parent
2e295e6891
commit
3bc85c2423
12 changed files with 379 additions and 247 deletions
|
@ -5,6 +5,7 @@ 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_embed/link_embed_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
|
||||
|
@ -397,7 +398,7 @@ void main() {
|
|||
|
||||
Future<void> hoverAndConvert(
|
||||
WidgetTester tester,
|
||||
PasteMenuType command,
|
||||
LinkEmbedConvertCommand command,
|
||||
) async {
|
||||
final embed = find.byType(LinkEmbedBlockComponent);
|
||||
expect(embed, findsOneWidget);
|
||||
|
@ -425,7 +426,7 @@ void main() {
|
|||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsEmbed(tester, link);
|
||||
await hoverAndConvert(tester, PasteMenuType.mention);
|
||||
await hoverAndConvert(tester, LinkEmbedConvertCommand.toMention);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkMention(node, link);
|
||||
});
|
||||
|
@ -434,7 +435,7 @@ void main() {
|
|||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsEmbed(tester, link);
|
||||
await hoverAndConvert(tester, PasteMenuType.url);
|
||||
await hoverAndConvert(tester, LinkEmbedConvertCommand.toURL);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkUrl(node, link);
|
||||
});
|
||||
|
@ -444,7 +445,7 @@ void main() {
|
|||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsEmbed(tester, link);
|
||||
await hoverAndConvert(tester, PasteMenuType.bookmark);
|
||||
await hoverAndConvert(tester, LinkEmbedConvertCommand.toBookmark);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkBookmark(node, link);
|
||||
});
|
||||
|
|
|
@ -45,16 +45,18 @@ Future<bool> afLaunchUri(
|
|||
}
|
||||
|
||||
// try to launch the uri directly
|
||||
bool result;
|
||||
try {
|
||||
result = await launcher.launchUrl(
|
||||
uri,
|
||||
mode: mode,
|
||||
webOnlyWindowName: webOnlyWindowName,
|
||||
);
|
||||
} on PlatformException catch (e) {
|
||||
Log.error('Failed to open uri: $e');
|
||||
return false;
|
||||
bool result = await launcher.canLaunchUrl(uri);
|
||||
if (result) {
|
||||
try {
|
||||
result = await launcher.launchUrl(
|
||||
uri,
|
||||
mode: mode,
|
||||
webOnlyWindowName: webOnlyWindowName,
|
||||
);
|
||||
} on PlatformException catch (e) {
|
||||
Log.error('Failed to open uri: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// if the uri is not a valid url, try to launch it with http scheme
|
||||
|
|
|
@ -983,7 +983,6 @@ CustomLinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder(
|
|||
return const EdgeInsets.symmetric(vertical: 10);
|
||||
},
|
||||
),
|
||||
cache: LinkPreviewDataCache(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
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/callout/callout_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/default_selectable_mixin.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';
|
||||
|
@ -37,11 +40,12 @@ class LinkEmbedBlockComponent extends BlockComponentStatefulWidget {
|
|||
});
|
||||
|
||||
@override
|
||||
State<LinkEmbedBlockComponent> createState() =>
|
||||
DefaultSelectableMixinState<LinkEmbedBlockComponent> createState() =>
|
||||
LinkEmbedBlockComponentState();
|
||||
}
|
||||
|
||||
class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
||||
class LinkEmbedBlockComponentState
|
||||
extends DefaultSelectableMixinState<LinkEmbedBlockComponent>
|
||||
with BlockComponentConfigurable {
|
||||
@override
|
||||
BlockComponentConfiguration get configuration => widget.configuration;
|
||||
|
@ -51,7 +55,7 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
|||
|
||||
String get url => widget.node.attributes[LinkPreviewBlockKeys.url]!;
|
||||
|
||||
EmbedLoadingStatus status = EmbedLoadingStatus.loading;
|
||||
LinkLoadingStatus status = LinkLoadingStatus.loading;
|
||||
final parser = LinkParser();
|
||||
LinkInfo linkInfo = LinkInfo();
|
||||
|
||||
|
@ -62,13 +66,14 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
|||
void initState() {
|
||||
super.initState();
|
||||
parser.addLinkInfoListener((v) {
|
||||
final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (v.isEmpty() && linkInfo.isEmpty()) {
|
||||
status = EmbedLoadingStatus.error;
|
||||
} else {
|
||||
if (hasNewInfo) {
|
||||
linkInfo = v;
|
||||
status = EmbedLoadingStatus.idle;
|
||||
status = LinkLoadingStatus.idle;
|
||||
} else if (!hasOldInfo) {
|
||||
status = LinkLoadingStatus.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -98,7 +103,13 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
|||
},
|
||||
child: buildChild(context),
|
||||
);
|
||||
result = Padding(padding: padding, child: result);
|
||||
final parent = node.parent;
|
||||
EdgeInsets newPadding = padding;
|
||||
if (parent?.type == CalloutBlockKeys.type) {
|
||||
newPadding = padding.copyWith(right: padding.right + 10);
|
||||
}
|
||||
|
||||
result = Padding(padding: newPadding, child: result);
|
||||
|
||||
if (widget.showActions && widget.actionBuilder != null) {
|
||||
result = BlockComponentActionWrapper(
|
||||
|
@ -115,7 +126,7 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
|||
fillSceme = theme.fillColorScheme,
|
||||
borderScheme = theme.borderColorScheme;
|
||||
Widget child;
|
||||
final isIdle = status == EmbedLoadingStatus.idle;
|
||||
final isIdle = status == LinkLoadingStatus.idle;
|
||||
if (isIdle) {
|
||||
child = buildContent(context);
|
||||
} else {
|
||||
|
@ -123,6 +134,7 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
|||
}
|
||||
return Container(
|
||||
height: 450,
|
||||
key: widgetKey,
|
||||
decoration: BoxDecoration(
|
||||
color: isIdle ? Theme.of(context).cardColor : fillSceme.tertiaryHover,
|
||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||
|
@ -150,7 +162,7 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
|||
node: node,
|
||||
onReload: () {
|
||||
setState(() {
|
||||
status = EmbedLoadingStatus.loading;
|
||||
status = LinkLoadingStatus.loading;
|
||||
});
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted) parser.start(url);
|
||||
|
@ -185,43 +197,51 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
|||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowyText(
|
||||
linkInfo.siteName ?? '',
|
||||
color: textScheme.primary,
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () =>
|
||||
afLaunchUrlString(url, addingHttpSchemeWhenFailed: true),
|
||||
child: 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)),
|
||||
),
|
||||
VSpace(4),
|
||||
FlowyText.regular(
|
||||
url,
|
||||
color: textScheme.secondary,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
HSpace(12),
|
||||
Expanded(
|
||||
child: 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -230,7 +250,7 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
|||
|
||||
Widget buildErrorLoadingWidget(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context), textSceme = theme.textColorScheme;
|
||||
final isLoading = status == EmbedLoadingStatus.loading;
|
||||
final isLoading = status == LinkLoadingStatus.loading;
|
||||
return isLoading
|
||||
? Center(
|
||||
child: SizedBox.square(
|
||||
|
@ -264,7 +284,7 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
|||
),
|
||||
TextSpan(
|
||||
text: LocaleKeys
|
||||
.document_plugins_linkPreview_linkPreviewMenu_refuseConnect
|
||||
.document_plugins_linkPreview_linkPreviewMenu_unableToDisplay
|
||||
.tr(),
|
||||
style: TextStyle(
|
||||
color: textSceme.primary,
|
||||
|
@ -281,6 +301,10 @@ class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
|
|||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum EmbedLoadingStatus { loading, idle, error }
|
||||
@override
|
||||
Node get currentNode => node;
|
||||
|
||||
@override
|
||||
EdgeInsets get boxPadding => padding;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ 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';
|
||||
|
@ -94,14 +93,14 @@ class _LinkEmbedMenuState extends State<LinkEmbedMenu> {
|
|||
preferBelow: false,
|
||||
onPressed: () => copyLink(context),
|
||||
),
|
||||
buildTurnIntoBotton(),
|
||||
buildconvertBotton(),
|
||||
buildMoreOptionBotton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTurnIntoBotton() {
|
||||
Widget buildconvertBotton() {
|
||||
final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorTheme;
|
||||
return AppFlowyPopover(
|
||||
offset: Offset(0, 6),
|
||||
|
@ -117,22 +116,21 @@ class _LinkEmbedMenuState extends State<LinkEmbedMenu> {
|
|||
turnintoMenuNum--;
|
||||
checkToHideMenu();
|
||||
},
|
||||
popupBuilder: (context) => buildTurnIntoMenu(),
|
||||
popupBuilder: (context) => buildConvertMenu(),
|
||||
child: FlowyIconButton(
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.turninto_m,
|
||||
color: iconScheme.tertiary,
|
||||
),
|
||||
tooltipText: LocaleKeys.document_toolbar_turnInto.tr(),
|
||||
tooltipText: LocaleKeys.editor_convertTo.tr(),
|
||||
preferBelow: false,
|
||||
onPressed: showTurnIntoMenu,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTurnIntoMenu() {
|
||||
final types =
|
||||
PasteMenuType.values.where((e) => e != PasteMenuType.embed).toList();
|
||||
Widget buildConvertMenu() {
|
||||
final types = LinkEmbedConvertCommand.values;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: SeparatedColumn(
|
||||
|
@ -149,16 +147,16 @@ class _LinkEmbedMenuState extends State<LinkEmbedMenu> {
|
|||
figmaLineHeight: 20,
|
||||
),
|
||||
onTap: () {
|
||||
if (command == PasteMenuType.bookmark) {
|
||||
if (command == LinkEmbedConvertCommand.toBookmark) {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(node, {
|
||||
LinkPreviewBlockKeys.url: url,
|
||||
LinkEmbedKeys.previewType: '',
|
||||
});
|
||||
editorState.apply(transaction);
|
||||
} else if (command == PasteMenuType.mention) {
|
||||
} else if (command == LinkEmbedConvertCommand.toMention) {
|
||||
convertUrlPreviewNodeToMention(editorState, node);
|
||||
} else if (command == PasteMenuType.url) {
|
||||
} else if (command == LinkEmbedConvertCommand.toURL) {
|
||||
convertUrlPreviewNodeToLink(editorState, node);
|
||||
}
|
||||
},
|
||||
|
@ -330,3 +328,24 @@ enum LinkEmbedMenuCommand {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LinkEmbedConvertCommand {
|
||||
toMention,
|
||||
toURL,
|
||||
toBookmark;
|
||||
|
||||
String get title {
|
||||
switch (this) {
|
||||
case toMention:
|
||||
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion
|
||||
.tr();
|
||||
case toURL:
|
||||
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl
|
||||
.tr();
|
||||
case toBookmark:
|
||||
return LocaleKeys
|
||||
.document_plugins_linkPreview_linkPreviewMenu_toBookmark
|
||||
.tr();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -133,3 +133,9 @@ class LinkInfoCache {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum LinkLoadingStatus {
|
||||
loading,
|
||||
idle,
|
||||
error,
|
||||
}
|
||||
|
|
|
@ -3,8 +3,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
|
|||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_image.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
|
@ -14,6 +16,8 @@ import 'package:provider/provider.dart';
|
|||
import 'package:universal_platform/universal_platform.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
|
||||
import 'custom_link_parser.dart';
|
||||
|
||||
class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
const CustomLinkPreviewWidget({
|
||||
super.key,
|
||||
|
@ -23,7 +27,7 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
|||
this.description,
|
||||
this.imageUrl,
|
||||
this.isHovering = false,
|
||||
this.status = LinkPreviewStatus.loading,
|
||||
this.status = LinkLoadingStatus.loading,
|
||||
});
|
||||
|
||||
final Node node;
|
||||
|
@ -32,7 +36,7 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
|||
final String? imageUrl;
|
||||
final String url;
|
||||
final bool isHovering;
|
||||
final LinkPreviewStatus status;
|
||||
final LinkLoadingStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -46,6 +50,8 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
|||
.text
|
||||
.fontSize ??
|
||||
16.0;
|
||||
final isInDarkCallout = node.parent?.type == CalloutBlockKeys.type &&
|
||||
!Theme.of(context).isLightMode;
|
||||
final (fontSize, width) = UniversalPlatform.isDesktopOrWeb
|
||||
? (documentFontSize, 160.0)
|
||||
: (documentFontSize - 2, 120.0);
|
||||
|
@ -53,7 +59,7 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
|||
clipBehavior: Clip.hardEdge,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isHovering
|
||||
color: isHovering || isInDarkCallout
|
||||
? borderScheme.greyTertiaryHover
|
||||
: borderScheme.greyTertiary,
|
||||
),
|
||||
|
@ -68,42 +74,43 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
|||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 60, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
buildLoadingOrErrorWidget(),
|
||||
if (title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: FlowyText.medium(
|
||||
title!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: fontSize,
|
||||
color: textScheme.primary,
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
child: status != LinkLoadingStatus.idle
|
||||
? buildLoadingOrErrorWidget()
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: FlowyText.medium(
|
||||
title!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: fontSize,
|
||||
color: textScheme.primary,
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
),
|
||||
if (description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: FlowyText(
|
||||
description!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: fontSize - 4,
|
||||
figmaLineHeight: 16,
|
||||
color: textScheme.primary,
|
||||
),
|
||||
),
|
||||
FlowyText(
|
||||
url.toString(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: textScheme.secondary,
|
||||
fontSize: fontSize - 4,
|
||||
figmaLineHeight: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: FlowyText(
|
||||
description!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: fontSize - 4,
|
||||
figmaLineHeight: 16,
|
||||
color: textScheme.primary,
|
||||
),
|
||||
),
|
||||
FlowyText(
|
||||
url.toString(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: textScheme.secondary,
|
||||
fontSize: fontSize - 4,
|
||||
figmaLineHeight: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -184,26 +191,22 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
|||
}
|
||||
|
||||
Widget buildLoadingOrErrorWidget() {
|
||||
if (status == LinkPreviewStatus.loading) {
|
||||
return Expanded(
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
if (status == LinkLoadingStatus.loading) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
);
|
||||
} else if (status == LinkPreviewStatus.error) {
|
||||
return Expanded(
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
color: Colors.red,
|
||||
),
|
||||
} else if (status == LinkLoadingStatus.error) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -211,5 +214,3 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
|||
return SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
enum LinkPreviewStatus { loading, error, idle }
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.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_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'custom_link_preview.dart';
|
||||
import 'default_selectable_mixin.dart';
|
||||
import 'link_preview_menu.dart';
|
||||
|
||||
class CustomLinkPreviewBlockComponentBuilder extends BlockComponentBuilder {
|
||||
CustomLinkPreviewBlockComponentBuilder({
|
||||
super.configuration,
|
||||
this.cache,
|
||||
});
|
||||
|
||||
final LinkPreviewDataCacheInterface? cache;
|
||||
|
||||
@override
|
||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||
final node = blockComponentContext.node;
|
||||
|
@ -35,7 +35,6 @@ class CustomLinkPreviewBlockComponentBuilder extends BlockComponentBuilder {
|
|||
configuration: configuration,
|
||||
showActions: showActions(node),
|
||||
actionBuilder: (_, state) => actionBuilder(blockComponentContext, state),
|
||||
cache: cache,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -51,18 +50,15 @@ class CustomLinkPreviewBlockComponent extends BlockComponentStatefulWidget {
|
|||
super.showActions,
|
||||
super.actionBuilder,
|
||||
super.configuration = const BlockComponentConfiguration(),
|
||||
this.cache,
|
||||
});
|
||||
|
||||
final LinkPreviewDataCacheInterface? cache;
|
||||
|
||||
@override
|
||||
State<CustomLinkPreviewBlockComponent> createState() =>
|
||||
DefaultSelectableMixinState<CustomLinkPreviewBlockComponent> createState() =>
|
||||
CustomLinkPreviewBlockComponentState();
|
||||
}
|
||||
|
||||
class CustomLinkPreviewBlockComponentState
|
||||
extends State<CustomLinkPreviewBlockComponent>
|
||||
extends DefaultSelectableMixinState<CustomLinkPreviewBlockComponent>
|
||||
with BlockComponentConfigurable {
|
||||
@override
|
||||
BlockComponentConfiguration get configuration => widget.configuration;
|
||||
|
@ -72,8 +68,9 @@ class CustomLinkPreviewBlockComponentState
|
|||
|
||||
String get url => widget.node.attributes[LinkPreviewBlockKeys.url]!;
|
||||
|
||||
late final LinkPreviewParser parser;
|
||||
late Future<void> future;
|
||||
final parser = LinkParser();
|
||||
LinkLoadingStatus status = LinkLoadingStatus.loading;
|
||||
LinkInfo linkInfo = LinkInfo();
|
||||
|
||||
final showActionsNotifier = ValueNotifier<bool>(false);
|
||||
bool isMenuShowing = false, isHovering = false;
|
||||
|
@ -81,21 +78,26 @@ class CustomLinkPreviewBlockComponentState
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
parser = LinkPreviewParser(url: url, cache: widget.cache);
|
||||
future = parser.start();
|
||||
parser.addLinkInfoListener((v) {
|
||||
final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (hasNewInfo) {
|
||||
linkInfo = v;
|
||||
status = LinkLoadingStatus.idle;
|
||||
} else if (!hasOldInfo) {
|
||||
status = LinkLoadingStatus.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
parser.start(url);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(CustomLinkPreviewBlockComponent oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final url = widget.node.attributes[LinkPreviewBlockKeys.url]!;
|
||||
final oldUrl = oldWidget.node.attributes[LinkPreviewBlockKeys.url]!;
|
||||
if (url != oldUrl) {
|
||||
parser = LinkPreviewParser(url: url, cache: widget.cache);
|
||||
setState(() {
|
||||
future = parser.start();
|
||||
});
|
||||
}
|
||||
void dispose() {
|
||||
parser.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -114,91 +116,79 @@ class CustomLinkPreviewBlockComponentState
|
|||
},
|
||||
hitTestBehavior: HitTestBehavior.opaque,
|
||||
opaque: false,
|
||||
child: ValueListenableBuilder<bool>(
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: showActionsNotifier,
|
||||
builder: (context, showActions, child) {
|
||||
return FutureBuilder(
|
||||
future: future,
|
||||
builder: (context, snapshot) {
|
||||
Widget child;
|
||||
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
child = CustomLinkPreviewWidget(
|
||||
node: node,
|
||||
url: url,
|
||||
isHovering: showActions,
|
||||
);
|
||||
} else {
|
||||
final title = parser.getContent(LinkPreviewRegex.title);
|
||||
final description =
|
||||
parser.getContent(LinkPreviewRegex.description);
|
||||
final image = parser.getContent(LinkPreviewRegex.image);
|
||||
|
||||
if (title == null && description == null && image == null) {
|
||||
child = CustomLinkPreviewWidget(
|
||||
node: node,
|
||||
url: url,
|
||||
isHovering: showActions,
|
||||
status: LinkPreviewStatus.error,
|
||||
);
|
||||
} else {
|
||||
child = CustomLinkPreviewWidget(
|
||||
node: node,
|
||||
url: url,
|
||||
title: title,
|
||||
description: description,
|
||||
imageUrl: image,
|
||||
isHovering: showActions,
|
||||
status: LinkPreviewStatus.idle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
child = Padding(padding: padding, child: child);
|
||||
|
||||
if (widget.showActions && widget.actionBuilder != null) {
|
||||
child = BlockComponentActionWrapper(
|
||||
node: node,
|
||||
actionBuilder: widget.actionBuilder!,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
child = Stack(
|
||||
children: [
|
||||
child,
|
||||
if (showActions)
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: CustomLinkPreviewMenu(
|
||||
onMenuShowed: () {
|
||||
isMenuShowing = true;
|
||||
},
|
||||
onMenuHided: () {
|
||||
isMenuShowing = false;
|
||||
if (!isHovering && mounted) {
|
||||
showActionsNotifier.value = false;
|
||||
}
|
||||
},
|
||||
onReload: () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
future = parser.start();
|
||||
});
|
||||
}
|
||||
},
|
||||
node: node,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return child;
|
||||
},
|
||||
);
|
||||
return buildPreview(showActions);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildPreview(bool showActions) {
|
||||
Widget child = CustomLinkPreviewWidget(
|
||||
key: widgetKey,
|
||||
node: node,
|
||||
url: url,
|
||||
isHovering: showActions,
|
||||
title: linkInfo.siteName,
|
||||
description: linkInfo.description,
|
||||
imageUrl: linkInfo.imageUrl,
|
||||
status: status,
|
||||
);
|
||||
|
||||
if (widget.showActions && widget.actionBuilder != null) {
|
||||
child = BlockComponentActionWrapper(
|
||||
node: node,
|
||||
actionBuilder: widget.actionBuilder!,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
child = Stack(
|
||||
children: [
|
||||
child,
|
||||
if (showActions)
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: CustomLinkPreviewMenu(
|
||||
onMenuShowed: () {
|
||||
isMenuShowing = true;
|
||||
},
|
||||
onMenuHided: () {
|
||||
isMenuShowing = false;
|
||||
if (!isHovering && mounted) {
|
||||
showActionsNotifier.value = false;
|
||||
}
|
||||
},
|
||||
onReload: () {
|
||||
setState(() {
|
||||
status = LinkLoadingStatus.loading;
|
||||
});
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted) parser.start(url);
|
||||
});
|
||||
},
|
||||
node: node,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final parent = node.parent;
|
||||
EdgeInsets newPadding = padding;
|
||||
if (parent?.type == CalloutBlockKeys.type) {
|
||||
newPadding = padding.copyWith(right: padding.right + 10);
|
||||
}
|
||||
child = Padding(padding: newPadding, child: child);
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
@override
|
||||
Node get currentNode => node;
|
||||
|
||||
@override
|
||||
EdgeInsets get boxPadding => padding;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
abstract class DefaultSelectableMixinState<T extends StatefulWidget>
|
||||
extends State<T> with SelectableMixin {
|
||||
final widgetKey = GlobalKey();
|
||||
RenderBox? get _renderBox =>
|
||||
widgetKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
|
||||
Node get currentNode;
|
||||
|
||||
EdgeInsets get boxPadding => EdgeInsets.zero;
|
||||
|
||||
@override
|
||||
Position start() => Position(path: currentNode.path);
|
||||
|
||||
@override
|
||||
Position end() => Position(path: currentNode.path, offset: 1);
|
||||
|
||||
@override
|
||||
Position getPositionInOffset(Offset start) => end();
|
||||
|
||||
@override
|
||||
bool get shouldCursorBlink => false;
|
||||
|
||||
@override
|
||||
CursorStyle get cursorStyle => CursorStyle.cover;
|
||||
|
||||
@override
|
||||
Rect getBlockRect({
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) {
|
||||
final box = _renderBox;
|
||||
if (box is RenderBox) {
|
||||
return boxPadding.topLeft & box.size;
|
||||
}
|
||||
return Rect.zero;
|
||||
}
|
||||
|
||||
@override
|
||||
Rect? getCursorRectInPosition(
|
||||
Position position, {
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) {
|
||||
final rects = getRectsInSelection(Selection.collapsed(position));
|
||||
return rects.firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Rect> getRectsInSelection(
|
||||
Selection selection, {
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) {
|
||||
if (_renderBox == null) {
|
||||
return [];
|
||||
}
|
||||
final parentBox = context.findRenderObject();
|
||||
final box = widgetKey.currentContext?.findRenderObject();
|
||||
if (parentBox is RenderBox && box is RenderBox) {
|
||||
return [
|
||||
box.localToGlobal(Offset.zero, ancestor: parentBox) & box.size,
|
||||
];
|
||||
}
|
||||
return [Offset.zero & _renderBox!.size];
|
||||
}
|
||||
|
||||
@override
|
||||
Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
|
||||
path: currentNode.path,
|
||||
startOffset: 0,
|
||||
endOffset: 1,
|
||||
);
|
||||
|
||||
@override
|
||||
Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) =>
|
||||
_renderBox!.localToGlobal(offset);
|
||||
}
|
|
@ -28,11 +28,10 @@ class PasteAsMenuService {
|
|||
|
||||
void dismiss() {
|
||||
if (_menuEntry != null) {
|
||||
editorState.service.keyboardService?.enable();
|
||||
editorState.service.scrollService?.enable();
|
||||
keepEditorFocusNotifier.decrease();
|
||||
// editorState.service.scrollService?.enable();
|
||||
// editorState.service.keyboardService?.enable();
|
||||
}
|
||||
|
||||
_menuEntry?.remove();
|
||||
_menuEntry = null;
|
||||
}
|
||||
|
@ -58,6 +57,7 @@ class PasteAsMenuService {
|
|||
children: [
|
||||
ltrb.buildPositioned(
|
||||
child: PasteAsMenu(
|
||||
editorState: editorState,
|
||||
onSelect: (t) {
|
||||
final selection = editorState.selection;
|
||||
if (selection == null) return;
|
||||
|
@ -91,8 +91,9 @@ class PasteAsMenuService {
|
|||
|
||||
Overlay.of(context).insert(_menuEntry!);
|
||||
|
||||
editorState.service.keyboardService?.disable(showCursor: true);
|
||||
editorState.service.scrollService?.disable();
|
||||
keepEditorFocusNotifier.increase();
|
||||
// editorState.service.keyboardService?.disable(showCursor: true);
|
||||
// editorState.service.scrollService?.disable();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -101,9 +102,11 @@ class PasteAsMenu extends StatefulWidget {
|
|||
super.key,
|
||||
required this.onSelect,
|
||||
required this.onDismiss,
|
||||
required this.editorState,
|
||||
});
|
||||
final ValueChanged<PasteMenuType?> onSelect;
|
||||
final VoidCallback onDismiss;
|
||||
final EditorState editorState;
|
||||
|
||||
@override
|
||||
State<PasteAsMenu> createState() => _PasteAsMenuState();
|
||||
|
@ -113,18 +116,22 @@ class _PasteAsMenuState extends State<PasteAsMenu> {
|
|||
final focusNode = FocusNode(debugLabel: 'paste_as_menu');
|
||||
final ValueNotifier<int> selectedIndexNotifier = ValueNotifier(0);
|
||||
|
||||
EditorState get editorState => widget.editorState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => focusNode.requestFocus(),
|
||||
);
|
||||
editorState.selectionNotifier.addListener(dismiss);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
focusNode.dispose();
|
||||
selectedIndexNotifier.dispose();
|
||||
editorState.selectionNotifier.removeListener(dismiss);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
@ -201,8 +208,11 @@ class _PasteAsMenuState extends State<PasteAsMenu> {
|
|||
length = PasteMenuType.values.length;
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
onSelect(PasteMenuType.values[index]);
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
dismiss();
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.backspace) {
|
||||
dismiss();
|
||||
} else if ([LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowLeft]
|
||||
.contains(event.logicalKey)) {
|
||||
if (index == 0) {
|
||||
|
@ -211,6 +221,7 @@ class _PasteAsMenuState extends State<PasteAsMenu> {
|
|||
index--;
|
||||
}
|
||||
changeIndex(index);
|
||||
return KeyEventResult.handled;
|
||||
} else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowRight]
|
||||
.contains(event.logicalKey)) {
|
||||
if (index == length - 1) {
|
||||
|
@ -219,8 +230,9 @@ class _PasteAsMenuState extends State<PasteAsMenu> {
|
|||
index++;
|
||||
}
|
||||
changeIndex(index);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
void onSelect(PasteMenuType type) => widget.onSelect.call(type);
|
||||
|
|
|
@ -42,8 +42,8 @@ class MentionLinkBlock extends StatefulWidget {
|
|||
class _MentionLinkBlockState extends State<MentionLinkBlock> {
|
||||
final parser = LinkParser();
|
||||
_LoadingStatus status = _LoadingStatus.loading;
|
||||
final previewController = PopoverController();
|
||||
LinkInfo linkInfo = LinkInfo();
|
||||
final previewController = PopoverController();
|
||||
bool isHovering = false;
|
||||
int previewFocusNum = 0;
|
||||
bool isPreviewHovering = false;
|
||||
|
@ -67,13 +67,14 @@ class _MentionLinkBlockState extends State<MentionLinkBlock> {
|
|||
super.initState();
|
||||
|
||||
parser.addLinkInfoListener((v) {
|
||||
final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (v.isEmpty() && linkInfo.isEmpty()) {
|
||||
status = _LoadingStatus.error;
|
||||
} else {
|
||||
if (hasNewInfo) {
|
||||
linkInfo = v;
|
||||
status = _LoadingStatus.idle;
|
||||
} else if (!hasOldInfo) {
|
||||
status = _LoadingStatus.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -2038,7 +2038,7 @@
|
|||
"reload": "Reload",
|
||||
"removeLink": "Remove Link",
|
||||
"pasteHint": "Paste in https://...",
|
||||
"refuseConnect": "refued to connect."
|
||||
"unableToDisplay": "unable to display"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue