fix: some link_preview launch review issues

This commit is contained in:
Morn 2025-04-11 18:49:14 +08:00
parent 2e295e6891
commit 3bc85c2423
12 changed files with 379 additions and 247 deletions

View file

@ -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);
});

View file

@ -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

View file

@ -983,7 +983,6 @@ CustomLinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder(
return const EdgeInsets.symmetric(vertical: 10);
},
),
cache: LinkPreviewDataCache(),
);
}

View file

@ -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;
}

View file

@ -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();
}
}
}

View file

@ -133,3 +133,9 @@ class LinkInfoCache {
);
}
}
enum LinkLoadingStatus {
loading,
idle,
error,
}

View file

@ -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 }

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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);

View file

@ -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;
}
});
}

View file

@ -2038,7 +2038,7 @@
"reload": "Reload",
"removeLink": "Remove Link",
"pasteHint": "Paste in https://...",
"refuseConnect": "refued to connect."
"unableToDisplay": "unable to display"
}
}
},