feat: revamp toolbar link (#7578)

* feat: revamp toolbar link

* fix: some review issues

* chore: add integration test for toolbar link
This commit is contained in:
Morn 2025-03-24 09:55:23 +08:00 committed by GitHub
parent 44c9d572c8
commit 6f031d0c7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1750 additions and 51 deletions

View File

@ -1,10 +1,19 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.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_edit_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@ -176,4 +185,146 @@ void main() {
);
});
});
group('document toolbar: link', () {
String? getLinkFromNode(Node node) {
for (final insert in node.delta!) {
final link = insert.attributes?.href;
if (link != null) return link;
}
return null;
}
bool isPageLink(Node node) {
for (final insert in node.delta!) {
final isPage = insert.attributes?.isPage;
if (isPage == true) return true;
}
return false;
}
String getNodeText(Node node) {
for (final insert in node.delta!) {
if (insert is TextInsert) return insert.text;
}
return '';
}
testWidgets('insert link and remove link', (tester) async {
const text = 'insert link', link = 'https://test.appflowy.cloud';
await prepareForToolbar(tester, text);
final toolbar = find.byType(DesktopFloatingToolbar);
expect(toolbar, findsOneWidget);
/// tap link button to show CreateLinkMenu
final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
await tester.tapButton(linkButton);
final createLinkMenu = find.byType(LinkCreateMenu);
expect(createLinkMenu, findsOneWidget);
/// test esc to close
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
expect(toolbar, findsNothing);
/// show toolbar again
await selectText(tester, text);
await tester.tapButton(linkButton);
/// insert link
final textField = find.descendant(
of: createLinkMenu,
matching: find.byType(TextFormField),
);
await tester.enterText(textField, link);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
Node node = tester.editor.getNodeAtPath([0]);
expect(getLinkFromNode(node), link);
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
/// hover link
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
final hoverMenu = find.byType(LinkHoverMenu);
expect(hoverMenu, findsOneWidget);
/// copy link
final copyButton = find.descendant(
of: hoverMenu,
matching: find.byFlowySvg(FlowySvgs.toolbar_link_m),
);
await tester.tapButton(copyButton);
final clipboardContent = await getIt<ClipboardService>().getData();
final plainText = clipboardContent.plainText;
expect(plainText, link);
/// remove link
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m));
node = tester.editor.getNodeAtPath([0]);
expect(getLinkFromNode(node), null);
});
testWidgets('insert link and edit link', (tester) async {
const text = 'edit link',
link = 'https://test.appflowy.cloud',
afterText = '$text after';
await prepareForToolbar(tester, text);
/// tap link button to show CreateLinkMenu
final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
await tester.tapButton(linkButton);
/// search for page and select it
final textField = find.descendant(
of: find.byType(LinkCreateMenu),
matching: find.byType(TextFormField),
);
await tester.enterText(textField, gettingStarted);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
Node node = tester.editor.getNodeAtPath([0]);
expect(isPageLink(node), true);
expect(getLinkFromNode(node) == link, false);
/// hover link
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
/// click edit button to show LinkEditMenu
final editButton = find.byFlowySvg(FlowySvgs.toolbar_link_edit_m);
await tester.tapButton(editButton);
final linkEditMenu = find.byType(LinkEditMenu);
expect(linkEditMenu, findsOneWidget);
/// change the link text
final titleField = find.descendant(
of: linkEditMenu,
matching: find.byType(TextFormField),
);
await tester.enterText(titleField, afterText);
await tester.pumpAndSettle();
await tester.tapButton(
find.descendant(of: linkEditMenu, matching: find.text(gettingStarted)),
);
final linkField = find.ancestor(
of: find.text(LocaleKeys.document_toolbar_linkInputHint.tr()),
matching: find.byType(TextFormField),
);
await tester.enterText(linkField, link);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
/// apply the change
final applyButton =
find.text(LocaleKeys.settings_appearance_documentSettings_apply.tr());
await tester.tapButton(applyButton);
node = tester.editor.getNodeAtPath([0]);
expect(isPageLink(node), false);
expect(getLinkFromNode(node), link);
expect(getNodeText(node), afterText);
});
});
}

View File

@ -443,8 +443,9 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
color: Theme.of(context).cardColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
),
toolbarBuilder: (context, child) => DesktopFloatingToolbar(
toolbarBuilder: (context, child, onDismiss) => DesktopFloatingToolbar(
editorState: editorState,
onDismiss: onDismiss,
child: child,
),
placeHolderBuilder: (_) => customPlaceholderItem,

View File

@ -1,15 +1,20 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'toolbar_cubit.dart';
class DesktopFloatingToolbar extends StatefulWidget {
const DesktopFloatingToolbar({
super.key,
required this.editorState,
required this.child,
required this.onDismiss,
});
final EditorState editorState;
final Widget child;
final VoidCallback onDismiss;
@override
State<DesktopFloatingToolbar> createState() => _DesktopFloatingToolbarState();
@ -35,11 +40,14 @@ class _DesktopFloatingToolbarState extends State<DesktopFloatingToolbar> {
@override
Widget build(BuildContext context) {
if (position == null) return Container();
return Positioned(
left: position!.left,
top: position!.top,
right: position!.right,
child: widget.child,
return BlocProvider<ToolbarCubit>(
create: (_) => ToolbarCubit(widget.onDismiss),
child: Positioned(
left: position!.left,
top: position!.top,
right: position!.right,
child: widget.child,
),
);
}

View File

@ -0,0 +1,164 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.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_search_text_field.dart';
class LinkCreateMenu extends StatefulWidget {
const LinkCreateMenu({
super.key,
required this.editorState,
required this.onSubmitted,
required this.onDismiss,
required this.alignment,
});
final EditorState editorState;
final void Function(String link, bool isPage) onSubmitted;
final VoidCallback onDismiss;
final LinkMenuAlignment alignment;
@override
State<LinkCreateMenu> createState() => _LinkCreateMenuState();
}
class _LinkCreateMenuState extends State<LinkCreateMenu> {
late LinkSearchTextField searchTextField = LinkSearchTextField(
onEnter: () {
searchTextField.onSearchResult(
onLink: () => onSubmittedLink(),
onRecentViews: () =>
onSubmittedPageLink(searchTextField.currentRecentView),
onSearchViews: () =>
onSubmittedPageLink(searchTextField.currentSearchedView),
onEmpty: () {},
);
},
onEscape: widget.onDismiss,
onDataRefresh: () {
if (mounted) setState(() {});
},
);
bool get isButtonEnable => searchText.isNotEmpty;
String get searchText => searchTextField.searchText;
bool get showAtTop => widget.alignment.isTop;
@override
void initState() {
super.initState();
searchTextField.requestFocus();
searchTextField.searchRecentViews();
}
@override
void dispose() {
searchTextField.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: 320,
child: Column(
children: showAtTop
? [
searchTextField.buildResultContainer(
margin: EdgeInsets.only(bottom: 2),
context: context,
onLinkSelected: onSubmittedLink,
onPageLinkSelected: onSubmittedPageLink,
),
buildSearchContainer(),
]
: [
buildSearchContainer(),
searchTextField.buildResultContainer(
margin: EdgeInsets.only(top: 2),
context: context,
onLinkSelected: onSubmittedLink,
onPageLinkSelected: onSubmittedPageLink,
),
],
),
);
}
Widget buildSearchContainer() {
return Container(
width: 320,
height: 48,
decoration: buildToolbarLinkDecoration(context),
padding: EdgeInsets.all(8),
child: ValueListenableBuilder(
valueListenable: searchTextField.textEditingController,
builder: (context, _, __) {
return Row(
children: [
Expanded(child: searchTextField.buildTextField()),
HSpace(8),
FlowyTextButton(
LocaleKeys.document_toolbar_insert.tr(),
mainAxisAlignment: MainAxisAlignment.center,
padding: EdgeInsets.zero,
constraints: BoxConstraints(maxWidth: 72, minHeight: 32),
fontSize: 14,
fontColor:
isButtonEnable ? Colors.white : LinkStyle.textTertiary,
fillColor: isButtonEnable
? LinkStyle.fillThemeThick
: LinkStyle.borderColor,
hoverColor: LinkStyle.fillThemeThick,
lineHeight: 20 / 14,
fontWeight: FontWeight.w600,
onPressed: isButtonEnable ? () => onSubmittedLink() : null,
),
],
);
},
),
);
}
void onSubmittedLink() => widget.onSubmitted(searchText, false);
void onSubmittedPageLink(ViewPB view) async {
final workspaceId = context
.read<UserWorkspaceBloc?>()
?.state
.currentWorkspace
?.workspaceId ??
'';
final link = ShareConstants.buildShareUrl(
workspaceId: workspaceId,
viewId: view.id,
);
widget.onSubmitted(link, true);
}
}
ShapeDecoration buildToolbarLinkDecoration(BuildContext context) =>
ShapeDecoration(
color: Theme.of(context).cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
shadows: [
const BoxShadow(
color: LinkStyle.shadowMedium,
blurRadius: 24,
offset: Offset(0, 4),
),
],
);

View File

@ -0,0 +1,429 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'link_create_menu.dart';
import 'link_search_text_field.dart';
import 'link_styles.dart';
class LinkEditMenu extends StatefulWidget {
const LinkEditMenu({
super.key,
required this.linkInfo,
required this.onDismiss,
required this.onApply,
required this.onRemoveLink,
});
final LinkInfo linkInfo;
final ValueChanged<LinkInfo> onApply;
final VoidCallback onRemoveLink;
final VoidCallback onDismiss;
@override
State<LinkEditMenu> createState() => _LinkEditMenuState();
}
class _LinkEditMenuState extends State<LinkEditMenu> {
ValueChanged<LinkInfo> get onApply => widget.onApply;
VoidCallback get onRemoveLink => widget.onRemoveLink;
VoidCallback get onDismiss => widget.onDismiss;
late TextEditingController linkNameController =
TextEditingController(text: linkInfo.name);
final textFocusNode = FocusNode();
late LinkInfo linkInfo = widget.linkInfo;
late LinkSearchTextField searchTextField;
bool isShowingSearchResult = false;
ViewPB? currentView;
bool get enableApply =>
linkInfo.link.isNotEmpty && linkNameController.text.isNotEmpty;
@override
void initState() {
super.initState();
final isPageLink = linkInfo.isPage;
if (isPageLink) getPageView();
searchTextField = LinkSearchTextField(
initialSearchText: isPageLink ? '' : linkInfo.link,
onEnter: () {
searchTextField.onSearchResult(
onLink: onLinkSelected,
onRecentViews: () =>
onPageSelected(searchTextField.currentRecentView),
onSearchViews: () =>
onPageSelected(searchTextField.currentSearchedView),
onEmpty: () {
searchTextField.unfocus();
},
);
},
onEscape: () {
if (isShowingSearchResult) {
hideSearchResult();
} else {
onDismiss();
}
},
onDataRefresh: () {
if (mounted) setState(() {});
},
)..searchRecentViews();
}
@override
void dispose() {
linkNameController.dispose();
textFocusNode.dispose();
searchTextField.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final showingRecent =
searchTextField.showingRecent && isShowingSearchResult;
return GestureDetector(
onTap: onDismiss,
child: Container(
width: 400,
height: 250 + (showingRecent ? 32 : 0),
color: Colors.white.withAlpha(1),
child: Stack(
children: [
GestureDetector(
onTap: hideSearchResult,
child: Container(
width: 400,
height: 192,
decoration: buildToolbarLinkDecoration(context),
padding: EdgeInsets.fromLTRB(20, 16, 20, 16),
),
),
Positioned(
top: 16,
left: 20,
child: FlowyText.semibold(
LocaleKeys.document_toolbar_pageOrURL.tr(),
color: LinkStyle.textTertiary,
fontSize: 12,
figmaLineHeight: 16,
),
),
Positioned(
top: 80,
left: 20,
child: FlowyText.semibold(
LocaleKeys.document_toolbar_linkName.tr(),
color: LinkStyle.textTertiary,
fontSize: 12,
figmaLineHeight: 16,
),
),
Positioned(
top: 152,
left: 20,
child: buildButtons(),
),
Positioned(
top: 108,
left: 20,
child: buildNameTextField(),
),
Positioned(
top: 36,
left: 20,
child: buildLinkField(),
),
],
),
),
);
}
Widget buildLinkField() {
final showPageView = linkInfo.isPage && !isShowingSearchResult;
if (showPageView) return buildPageView();
if (!isShowingSearchResult) return buildLinkView();
return SizedBox(
width: 360,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 360,
height: 32,
child: searchTextField.buildTextField(
autofocus: true,
),
),
VSpace(6),
searchTextField.buildResultContainer(
context: context,
width: 360,
onPageLinkSelected: onPageSelected,
onLinkSelected: onLinkSelected,
),
],
),
);
}
Widget buildButtons() {
return GestureDetector(
onTap: hideSearchResult,
child: SizedBox(
width: 360,
height: 32,
child: Row(
children: [
FlowyIconButton(
icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m),
width: 32,
height: 32,
tooltipText: LocaleKeys.editor_removeLink.tr(),
preferBelow: false,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
border: Border.all(color: LinkStyle.borderColor),
),
onPressed: onRemoveLink,
),
Spacer(),
DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
border: Border.all(color: LinkStyle.borderColor),
),
child: FlowyTextButton(
LocaleKeys.button_cancel.tr(),
padding: EdgeInsets.zero,
mainAxisAlignment: MainAxisAlignment.center,
constraints: BoxConstraints(maxWidth: 78, minHeight: 32),
fontSize: 14,
lineHeight: 20 / 14,
fontColor: LinkStyle.textPrimary,
fillColor: Colors.transparent,
fontWeight: FontWeight.w400,
onPressed: onDismiss,
),
),
HSpace(12),
ValueListenableBuilder(
valueListenable: linkNameController,
builder: (context, _, __) {
return FlowyTextButton(
LocaleKeys.settings_appearance_documentSettings_apply.tr(),
padding: EdgeInsets.zero,
mainAxisAlignment: MainAxisAlignment.center,
constraints: BoxConstraints(maxWidth: 78, minHeight: 32),
fontSize: 14,
lineHeight: 20 / 14,
hoverColor: LinkStyle.fillThemeThick.withAlpha(200),
fontColor:
enableApply ? Colors.white : LinkStyle.textTertiary,
fillColor: enableApply
? LinkStyle.fillThemeThick
: LinkStyle.borderColor,
fontWeight: FontWeight.w400,
onPressed:
enableApply ? () => widget.onApply.call(linkInfo) : null,
);
},
),
],
),
),
);
}
Widget buildNameTextField() {
return SizedBox(
width: 360,
height: 32,
child: TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
focusNode: textFocusNode,
textAlign: TextAlign.left,
controller: linkNameController,
style: TextStyle(
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w400,
),
onChanged: (text) {
linkInfo = LinkInfo(
name: text,
link: linkInfo.link,
isPage: linkInfo.isPage,
);
},
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
LocaleKeys.document_toolbar_linkNameHint.tr(),
),
),
);
}
Widget buildPageView() {
late Widget child;
final view = currentView;
if (view == null) {
child = Center(
child: SizedBox.fromSize(
size: Size(10, 10),
child: CircularProgressIndicator(),
),
);
} else {
child = GestureDetector(
onTap: showSearchResult,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
height: 32,
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
searchTextField.buildIcon(view),
HSpace(8),
Flexible(
child: FlowyText.regular(
view.name,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);
}
return Container(
width: 360,
height: 32,
decoration: buildDecoration(),
child: child,
);
}
Widget buildLinkView() {
return Container(
width: 360,
height: 32,
decoration: buildDecoration(),
child: GestureDetector(
onTap: showSearchResult,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Padding(
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
FlowySvg(FlowySvgs.toolbar_link_earth_m),
HSpace(8),
Flexible(
child: FlowyText.regular(
linkInfo.link,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
),
),
],
),
),
),
),
);
}
Future<void> getPageView() async {
if (!linkInfo.isPage) return;
final link = linkInfo.link;
final viewId = link.split('/').lastOrNull ?? '';
final (view, isInTrash, isDeleted) =
await ViewBackendService.getMentionPageStatus(viewId);
if (mounted) {
setState(() {
currentView = view;
});
}
}
void showSearchResult() {
setState(() {
if (linkInfo.isPage) searchTextField.updateText('');
isShowingSearchResult = true;
searchTextField.requestFocus();
});
}
void hideSearchResult() {
setState(() {
isShowingSearchResult = false;
searchTextField.unfocus();
textFocusNode.unfocus();
});
}
void onLinkSelected() {
if (mounted) {
linkInfo = LinkInfo(
name: linkInfo.name,
link: searchTextField.searchText,
);
hideSearchResult();
}
}
Future<void> onPageSelected(ViewPB view) async {
currentView = view;
final link = ShareConstants.buildShareUrl(
workspaceId: await UserBackendService.getCurrentWorkspace().fold(
(s) => s.id,
(f) => '',
),
viewId: view.id,
);
linkInfo = LinkInfo(
name: linkInfo.name,
link: link,
isPage: true,
);
searchTextField.updateText(linkInfo.link);
if (mounted) {
setState(() {
isShowingSearchResult = false;
searchTextField.unfocus();
});
}
}
BoxDecoration buildDecoration() => BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: LinkStyle.borderColor),
);
}
class LinkInfo {
LinkInfo({this.isPage = false, required this.name, required this.link});
final bool isPage;
final String name;
final String link;
Attributes toAttribute() =>
{AppFlowyRichTextKeys.href: link, kIsPageLink: isPage};
}

View File

@ -0,0 +1,322 @@
import 'dart:math';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_editor/appflowy_editor.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/services.dart';
import 'link_create_menu.dart';
import 'link_edit_menu.dart';
import 'link_styles.dart';
class LinkHoverTrigger extends StatefulWidget {
const LinkHoverTrigger({
super.key,
required this.editorState,
required this.selection,
required this.node,
required this.attribute,
required this.size,
this.delayToShow = const Duration(milliseconds: 50),
this.delayToHide = const Duration(milliseconds: 300),
});
final EditorState editorState;
final Selection selection;
final Node node;
final Attributes attribute;
final Size size;
final Duration delayToShow;
final Duration delayToHide;
@override
State<LinkHoverTrigger> createState() => _LinkHoverTriggerState();
}
class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
final hoverMenuController = PopoverController();
final editMenuController = PopoverController();
bool isHoverMenuShowing = false;
bool isHoverMenuHovering = false;
bool isHoverTriggerHovering = false;
Size get size => widget.size;
EditorState get editorState => widget.editorState;
Selection get selection => widget.selection;
Attributes get attribute => widget.attribute;
@override
void dispose() {
hoverMenuController.close();
editMenuController.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (v) {
isHoverTriggerHovering = true;
Future.delayed(widget.delayToShow, () {
if (isHoverTriggerHovering && !isHoverMenuShowing) {
showLinkHoverMenu();
}
});
},
onExit: (v) {
isHoverTriggerHovering = false;
tryToDismissLinkHoverMenu();
},
child: buildHoverPopover(
buildEditPopover(
Container(
color: Colors.black.withAlpha(1),
width: size.width,
height: size.height,
),
),
),
);
}
Widget buildHoverPopover(Widget child) {
return AppFlowyPopover(
controller: hoverMenuController,
direction: PopoverDirection.topWithLeftAligned,
offset: Offset(0, size.height),
onOpen: () {
keepEditorFocusNotifier.increase();
isHoverMenuShowing = true;
},
onClose: () {
keepEditorFocusNotifier.decrease();
isHoverMenuShowing = false;
},
margin: EdgeInsets.zero,
constraints: BoxConstraints(
maxWidth: max(320, size.width),
maxHeight: 48 + size.height,
),
decorationColor: Colors.transparent,
popoverDecoration: BoxDecoration(),
popupBuilder: (context) => LinkHoverMenu(
attribute: widget.attribute,
triggerSize: size,
onEnter: (_) {
isHoverMenuHovering = true;
},
onExit: (_) {
isHoverMenuHovering = false;
tryToDismissLinkHoverMenu();
},
onOpenLink: openLink,
onCopyLink: copyLink,
onEditLink: showLinkEditMenu,
onRemoveLink: () => removeLink(editorState, selection, true),
),
child: child,
);
}
Widget buildEditPopover(Widget child) {
final href = attribute.href ?? '',
isPage = attribute.isPage,
title = editorState.getTextInSelection(selection).join();
return AppFlowyPopover(
controller: editMenuController,
direction: PopoverDirection.bottomWithLeftAligned,
offset: Offset(0, 0),
onOpen: () => keepEditorFocusNotifier.increase(),
onClose: () => keepEditorFocusNotifier.decrease(),
margin: EdgeInsets.zero,
asBarrier: true,
decorationColor: Colors.transparent,
popoverDecoration: BoxDecoration(),
constraints: BoxConstraints(
maxWidth: 400,
minHeight: 282,
),
popupBuilder: (context) => LinkEditMenu(
linkInfo: LinkInfo(name: title, link: href, isPage: isPage),
onDismiss: () => editMenuController.close(),
onApply: (info) async {
final transaction = editorState.transaction;
transaction.replaceText(
widget.node,
selection.startIndex,
selection.length,
info.name,
attributes: info.toAttribute(),
);
editMenuController.close();
await editorState.apply(transaction);
},
onRemoveLink: () => removeLink(editorState, selection, true),
),
child: child,
);
}
void showLinkHoverMenu() {
if (isHoverMenuShowing) return;
keepEditorFocusNotifier.increase();
hoverMenuController.show();
}
void showLinkEditMenu() {
keepEditorFocusNotifier.increase();
hoverMenuController.close();
editMenuController.show();
}
void tryToDismissLinkHoverMenu() {
Future.delayed(widget.delayToHide, () {
if (isHoverMenuHovering || isHoverTriggerHovering) {
return;
}
hoverMenuController.close();
});
}
Future<void> openLink() async {
final href = widget.attribute.href ?? '', isPage = widget.attribute.isPage;
if (isPage) {
final viewId = href.split('/').lastOrNull ?? '';
if (viewId.isEmpty) {
await afLaunchUrlString(href);
} else {
final (view, isInTrash, isDeleted) =
await ViewBackendService.getMentionPageStatus(viewId);
if (view != null) {
await handleMentionBlockTap(context, widget.editorState, view);
}
}
} else {
await afLaunchUrlString(href);
}
}
Future<void> copyLink() async {
final href = widget.attribute.href ?? '';
if (href.isEmpty) return;
await getIt<ClipboardService>()
.setData(ClipboardServiceData(plainText: href));
hoverMenuController.close();
}
}
class LinkHoverMenu extends StatelessWidget {
const LinkHoverMenu({
super.key,
required this.attribute,
required this.onEnter,
required this.onExit,
required this.triggerSize,
required this.onCopyLink,
required this.onOpenLink,
required this.onEditLink,
required this.onRemoveLink,
});
final Attributes attribute;
final PointerEnterEventListener onEnter;
final PointerExitEventListener onExit;
final Size triggerSize;
final VoidCallback onCopyLink;
final VoidCallback onOpenLink;
final VoidCallback onEditLink;
final VoidCallback onRemoveLink;
@override
Widget build(BuildContext context) {
final href = attribute.href ?? '';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MouseRegion(
onEnter: onEnter,
onExit: onExit,
child: SizedBox(
width: max(320, triggerSize.width),
height: 48,
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: 320,
height: 48,
decoration: buildToolbarLinkDecoration(context),
padding: EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: FlowyText.regular(
href,
fontSize: 14,
figmaLineHeight: 20,
overflow: TextOverflow.ellipsis,
),
),
Container(
height: 20,
width: 1,
color: LinkStyle.borderColor,
margin: EdgeInsets.symmetric(horizontal: 6),
),
FlowyIconButton(
icon: FlowySvg(FlowySvgs.toolbar_link_m),
tooltipText: LocaleKeys.editor_copyLink.tr(),
width: 36,
height: 32,
onPressed: onCopyLink,
),
FlowyIconButton(
icon: FlowySvg(FlowySvgs.toolbar_link_edit_m),
tooltipText: LocaleKeys.editor_editLink.tr(),
width: 36,
height: 32,
onPressed: onEditLink,
),
FlowyIconButton(
icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m),
tooltipText: LocaleKeys.editor_removeLink.tr(),
width: 36,
height: 32,
onPressed: onRemoveLink,
),
],
),
),
),
),
),
MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: onEnter,
onExit: onExit,
child: GestureDetector(
onTap: onOpenLink,
child: Container(
width: triggerSize.width,
height: triggerSize.height,
color: Colors.black.withAlpha(1),
),
),
),
],
);
}
}

View File

@ -0,0 +1,326 @@
import 'dart:math';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/list_extension.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/recent/cached_recent_service.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.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/services.dart';
import 'link_create_menu.dart';
import 'link_styles.dart';
class LinkSearchTextField {
LinkSearchTextField({
this.onEscape,
this.onEnter,
this.onDataRefresh,
String? initialSearchText,
}) : textEditingController = TextEditingController(
text: initialSearchText ?? '',
);
final TextEditingController textEditingController;
final ItemScrollController searchController = ItemScrollController();
late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent);
final List<ViewPB> searchedViews = [];
final List<ViewPB> recentViews = [];
int selectedIndex = 0;
final VoidCallback? onEscape;
final VoidCallback? onEnter;
final VoidCallback? onDataRefresh;
String get searchText => textEditingController.text;
bool get isButtonEnable => searchText.isNotEmpty;
bool get showingRecent => searchText.isEmpty && recentViews.isNotEmpty;
ViewPB get currentSearchedView => searchedViews[selectedIndex];
ViewPB get currentRecentView => recentViews[selectedIndex];
void dispose() {
textEditingController.dispose();
focusNode.dispose();
searchedViews.clear();
recentViews.clear();
}
Widget buildTextField({bool autofocus = false}) {
return TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
autofocus: autofocus,
focusNode: focusNode,
textAlign: TextAlign.left,
controller: textEditingController,
style: TextStyle(
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w400,
),
onChanged: (text) {
if (text.isEmpty) {
searchedViews.clear();
selectedIndex = 0;
onDataRefresh?.call();
} else {
searchViews(text);
}
},
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
LocaleKeys.document_toolbar_linkInputHint.tr(),
),
);
}
Widget buildResultContainer({
EdgeInsetsGeometry? margin,
required BuildContext context,
VoidCallback? onLinkSelected,
ValueChanged<ViewPB>? onPageLinkSelected,
double width = 320.0,
}) {
return onSearchResult<Widget>(
onEmpty: () => SizedBox.shrink(),
onLink: () => Container(
height: 48,
width: width,
padding: EdgeInsets.all(8),
margin: margin,
decoration: buildToolbarLinkDecoration(context),
child: FlowyButton(
leftIcon: FlowySvg(FlowySvgs.toolbar_link_earth_m),
isSelected: true,
text: FlowyText.regular(
searchText,
overflow: TextOverflow.ellipsis,
fontSize: 14,
figmaLineHeight: 20,
),
onTap: onLinkSelected,
),
),
onRecentViews: () => Container(
width: width,
height: recentViews.length.clamp(1, 5) * 32.0 + 48,
margin: margin,
padding: EdgeInsets.all(8),
decoration: buildToolbarLinkDecoration(context),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 32,
padding: EdgeInsets.all(8),
child: FlowyText.semibold(
LocaleKeys.inlineActions_recentPages.tr(),
color: LinkStyle.textTertiary,
fontSize: 12,
figmaLineHeight: 16,
),
),
Flexible(
child: ListView.builder(
itemBuilder: (context, index) {
final currentView = recentViews[index];
return buildPageItem(
currentView,
index == selectedIndex,
onPageLinkSelected,
);
},
itemCount: recentViews.length,
),
),
],
),
),
onSearchViews: () => Container(
width: width,
height: searchedViews.length.clamp(1, 5) * 32.0 + 16,
margin: margin,
decoration: buildToolbarLinkDecoration(context),
child: ScrollablePositionedList.builder(
padding: EdgeInsets.all(8),
physics: const ClampingScrollPhysics(),
shrinkWrap: true,
itemCount: searchedViews.length,
itemScrollController: searchController,
initialScrollIndex: max(0, selectedIndex),
itemBuilder: (context, index) {
final currentView = searchedViews[index];
return buildPageItem(
currentView,
index == selectedIndex,
onPageLinkSelected,
);
},
),
),
);
}
Widget buildPageItem(
ViewPB view,
bool isSelected,
ValueChanged<ViewPB>? onSubmittedPageLink,
) {
return SizedBox(
height: 32,
child: FlowyButton(
isSelected: isSelected,
leftIcon: buildIcon(view),
text: FlowyText.regular(
view.name,
overflow: TextOverflow.ellipsis,
fontSize: 14,
figmaLineHeight: 20,
),
onTap: () => onSubmittedPageLink?.call(view),
),
);
}
Widget buildIcon(ViewPB view) {
if (view.icon.value.isEmpty) return view.defaultIcon(size: Size(20, 20));
final iconData = view.icon.toEmojiIconData();
return RawEmojiIconWidget(
emoji: iconData,
emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20,
lineHeight: 1,
);
}
void requestFocus() => focusNode.requestFocus();
void unfocus() => focusNode.unfocus();
void updateText(String text) => textEditingController.text = text;
T onSearchResult<T>({
required ValueGetter<T> onLink,
required ValueGetter<T> onRecentViews,
required ValueGetter<T> onSearchViews,
required ValueGetter<T> onEmpty,
}) {
if (searchedViews.isEmpty && recentViews.isEmpty && searchText.isEmpty) {
return onEmpty.call();
}
if (searchedViews.isEmpty && searchText.isNotEmpty) {
return onLink.call();
}
if (searchedViews.isEmpty) return onRecentViews.call();
return onSearchViews.call();
}
KeyEventResult onKeyEvent(FocusNode node, KeyEvent key) {
if (key is! KeyDownEvent) return KeyEventResult.ignored;
int index = selectedIndex;
if (key.logicalKey == LogicalKeyboardKey.escape) {
onEscape?.call();
return KeyEventResult.handled;
} else if (key.logicalKey == LogicalKeyboardKey.arrowUp) {
index = onSearchResult(
onLink: () => 0,
onRecentViews: () {
int result = index - 1;
if (result < 0) result = recentViews.length - 1;
return result;
},
onSearchViews: () {
int result = index - 1;
if (result < 0) result = searchedViews.length - 1;
searchController.scrollTo(
index: result,
alignment: 0.5,
duration: const Duration(milliseconds: 300),
);
return result;
},
onEmpty: () => 0,
);
refreshIndex(index);
return KeyEventResult.handled;
} else if (key.logicalKey == LogicalKeyboardKey.arrowDown) {
index = onSearchResult(
onLink: () => 0,
onRecentViews: () {
int result = index + 1;
if (result >= recentViews.length) result = 0;
return result;
},
onSearchViews: () {
int result = index + 1;
if (result >= searchedViews.length) result = 0;
searchController.scrollTo(
index: result,
alignment: 0.5,
duration: const Duration(milliseconds: 300),
);
return result;
},
onEmpty: () => 0,
);
refreshIndex(index);
return KeyEventResult.handled;
} else if (key.logicalKey == LogicalKeyboardKey.enter) {
onEnter?.call();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
Future<void> searchRecentViews() async {
final recentService = getIt<CachedRecentService>();
final sectionViews = await recentService.recentViews();
final views = sectionViews
.unique((e) => e.item.id)
.map((e) => e.item)
.take(5)
.toList();
recentViews.clear();
recentViews.addAll(views);
selectedIndex = 0;
onDataRefresh?.call();
}
Future<void> searchViews(String search) async {
final viewResult = await ViewBackendService.getAllViews();
final allViews = viewResult
.toNullable()
?.items
.where(
(view) =>
view.name.toLowerCase().contains(search.toLowerCase()) ||
(view.name.isEmpty && search.isEmpty) ||
(view.name.isEmpty &&
LocaleKeys.menuAppHeader_defaultNewPageName
.tr()
.toLowerCase()
.contains(search.toLowerCase())),
)
.take(10)
.toList();
searchedViews.clear();
searchedViews.addAll(allViews ?? []);
selectedIndex = 0;
onDataRefresh?.call();
}
void refreshIndex(int index) {
selectedIndex = index;
onDataRefresh?.call();
}
}

View File

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
class LinkStyle {
static const borderColor = Color(0xFFE8ECF3);
static const textTertiary = Color(0xFF99A1A8);
static const fillThemeThick = Color(0xFF00B5FF);
static const shadowMedium = Color(0x1F22251F);
static const textPrimary = Color(0xFF1F2329);
static InputDecoration buildLinkTextFieldInputDecoration(String hintText) {
const border = OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8.0)),
borderSide: BorderSide(color: LinkStyle.borderColor),
);
final enableBorder = border.copyWith(
borderSide: BorderSide(color: LinkStyle.fillThemeThick),
);
const hintStyle = TextStyle(
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w400,
color: LinkStyle.textTertiary,
);
return InputDecoration(
hintText: hintText,
hintStyle: hintStyle,
contentPadding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
isDense: true,
border: border,
enabledBorder: border,
focusedBorder: enableBorder,
);
}
}

View File

@ -0,0 +1,17 @@
import 'dart:ui';
import 'package:bloc/bloc.dart';
class ToolbarCubit extends Cubit<ToolbarState> {
ToolbarCubit(this.onDismissCallback) : super(ToolbarState._());
final VoidCallback onDismissCallback;
void dismiss() {
onDismissCallback.call();
}
}
class ToolbarState {
const ToolbarState._();
}

View File

@ -71,11 +71,13 @@ class RawEmojiIconWidget extends StatefulWidget {
required this.emoji,
required this.emojiSize,
this.enableColor = true,
this.lineHeight,
});
final EmojiIconData emoji;
final double emojiSize;
final bool enableColor;
final double? lineHeight;
@override
State<RawEmojiIconWidget> createState() => _RawEmojiIconWidgetState();
@ -115,6 +117,7 @@ class _RawEmojiIconWidgetState extends State<RawEmojiIconWidget> {
emoji: widget.emoji.emoji,
fontSize: widget.emojiSize,
textAlign: TextAlign.justify,
lineHeight: widget.lineHeight,
);
case FlowyIconType.icon:
IconsData iconData =

View File

@ -118,7 +118,7 @@ class _MentionPageBlockState extends State<MentionPageBlock> {
view: view,
content: state.blockContent,
textStyle: widget.textStyle,
handleTap: () => _handleTap(
handleTap: () => handleMentionBlockTap(
context,
widget.editorState,
view,
@ -138,7 +138,7 @@ class _MentionPageBlockState extends State<MentionPageBlock> {
content: state.blockContent,
textStyle: widget.textStyle,
showTrashHint: state.isInTrash,
handleTap: () => _handleTap(
handleTap: () => handleMentionBlockTap(
context,
widget.editorState,
view,
@ -221,7 +221,8 @@ class _MentionSubPageBlockState extends State<MentionSubPageBlock> {
view: view,
showTrashHint: state.isInTrash,
textStyle: widget.textStyle,
handleTap: () => _handleTap(context, widget.editorState, view),
handleTap: () =>
handleMentionBlockTap(context, widget.editorState, view),
isChildPage: true,
content: '',
handleDoubleTap: () => _handleDoubleTap(
@ -239,7 +240,8 @@ class _MentionSubPageBlockState extends State<MentionSubPageBlock> {
content: null,
textStyle: widget.textStyle,
isChildPage: true,
handleTap: () => _handleTap(context, widget.editorState, view),
handleTap: () =>
handleMentionBlockTap(context, widget.editorState, view),
);
}
},
@ -321,7 +323,7 @@ Path? _findNodePathByBlockId(EditorState editorState, String blockId) {
return null;
}
Future<void> _handleTap(
Future<void> handleMentionBlockTap(
BuildContext context,
EditorState editorState,
ViewPB view, {

View File

@ -1,11 +1,16 @@
import 'package:appflowy/generated/flowy_svgs.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/toolbar_cubit.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'toolbar_id_enum.dart';
const kIsPageLink = 'is_page_link';
final customLinkItem = ToolbarItem(
id: ToolbarId.link.id,
group: 4,
@ -22,6 +27,7 @@ final customLinkItem = ToolbarItem(
final hoverColor = isHref
? highlightColor
: EditorStyleCustomizer.toolbarHoverColor(context);
final toolbarCubit = context.read<ToolbarCubit?>();
final child = FlowyIconButton(
width: 36,
@ -33,7 +39,14 @@ final customLinkItem = ToolbarItem(
size: Size.square(20.0),
color: Theme.of(context).iconTheme.color,
),
onPressed: () => showLinkMenu(context, editorState, selection, isHref),
onPressed: () {
toolbarCubit?.dismiss();
if (isHref) {
removeLink(editorState, selection, isHref);
} else {
_showLinkMenu(context, editorState, selection, isHref);
}
},
);
if (tooltipBuilder != null) {
@ -48,3 +61,159 @@ final customLinkItem = ToolbarItem(
return child;
},
);
void removeLink(
EditorState editorState,
Selection selection,
bool isHref,
) {
if (!isHref) return;
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
final index = selection.normalized.startIndex;
final length = selection.length;
final transaction = editorState.transaction
..formatText(
node,
index,
length,
{
BuiltInAttributeKey.href: null,
kIsPageLink: null,
},
);
editorState.apply(transaction);
}
void _showLinkMenu(
BuildContext context,
EditorState editorState,
Selection selection,
bool isHref,
) {
final (left, top, right, bottom, alignment) = _getPosition(editorState);
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
OverlayEntry? overlay;
void dismissOverlay() {
keepEditorFocusNotifier.decrease();
overlay?.remove();
overlay = null;
}
keepEditorFocusNotifier.increase();
overlay = FullScreenOverlayEntry(
top: top,
bottom: bottom,
left: left,
right: right,
dismissCallback: () => keepEditorFocusNotifier.decrease(),
builder: (context) {
return LinkCreateMenu(
alignment: alignment,
editorState: editorState,
onSubmitted: (link, isPage) async {
await editorState.formatDelta(selection, {
BuiltInAttributeKey.href: link,
kIsPageLink: isPage,
});
dismissOverlay();
},
onDismiss: dismissOverlay,
);
},
).build();
Overlay.of(context, rootOverlay: true).insert(overlay!);
}
extension AttributeExtension on Attributes {
bool get isPage {
if (this[kIsPageLink] is bool) {
return this[kIsPageLink];
}
return false;
}
}
// get a proper position for link menu
(
double? left,
double? top,
double? right,
double? bottom,
LinkMenuAlignment alignment,
) _getPosition(
EditorState editorState,
) {
final rect = editorState.selectionRects().first;
const menuHeight = 222.0, menuWidth = 320.0;
double? left, right, top, bottom;
LinkMenuAlignment alignment = LinkMenuAlignment.topLeft;
final editorOffset = editorState.renderBox!.localToGlobal(Offset.zero),
editorSize = editorState.renderBox!.size;
final editorBottom = editorSize.height + editorOffset.dy,
editorRight = editorSize.width + editorOffset.dx;
final overflowBottom = rect.bottom + menuHeight > editorBottom,
overflowTop = rect.top - menuHeight < 0,
overflowLeft = rect.left - menuWidth < 0,
overflowRight = rect.right + menuWidth > editorRight;
if (overflowTop && !overflowBottom) {
/// show at bottom
top = rect.bottom;
} else if (overflowBottom && !overflowTop) {
/// show at top
bottom = editorBottom - rect.top;
} else if (!overflowTop && !overflowBottom) {
/// show at bottom
top = rect.bottom;
} else {
top = 0;
}
if (overflowLeft && !overflowRight) {
/// show at right
left = rect.left;
} else if (overflowRight && !overflowLeft) {
/// show at left
right = editorRight - rect.right;
} else if (!overflowLeft && !overflowRight) {
/// show at right
left = rect.left;
} else {
left = 0;
}
if (left != null && top != null) {
alignment = LinkMenuAlignment.bottomRight;
} else if (left != null && bottom != null) {
alignment = LinkMenuAlignment.topRight;
} else if (right != null && top != null) {
alignment = LinkMenuAlignment.bottomLeft;
} else if (right != null && bottom != null) {
alignment = LinkMenuAlignment.topLeft;
}
return (left, top, right, bottom, alignment);
}
enum LinkMenuAlignment {
topLeft,
topRight,
bottomLeft,
bottomRight,
}
extension LinkMenuAlignmentExtension on LinkMenuAlignment {
bool get isTop =>
this == LinkMenuAlignment.topLeft || this == LinkMenuAlignment.topRight;
}

View File

@ -28,6 +28,7 @@ import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:universal_platform/universal_platform.dart';
import 'editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'editor_plugins/toolbar_item/more_option_toolbar_item.dart';
class EditorStyleCustomizer {
@ -134,6 +135,7 @@ class EditorStyleCustomizer {
textSpanDecorator: customizeAttributeDecorator,
textScaleFactor:
context.watch<AppearanceSettingsCubit>().state.textScaleFactor,
textSpanOverlayBuilder: _buildTextSpanOverlay,
);
}
@ -468,14 +470,15 @@ class EditorStyleCustomizer {
);
}
return defaultTextSpanDecoratorForAttribute(
context,
node,
index,
text,
before,
after,
);
if (href != null) {
return TextSpan(
style: before.style,
text: text.text,
mouseCursor: SystemMouseCursors.click,
);
} else {
return before;
}
}
Widget buildToolbarItemTooltip(
@ -590,4 +593,54 @@ class EditorStyleCustomizer {
_ => style,
};
}
List<Widget> _buildTextSpanOverlay(
BuildContext context,
Node node,
SelectableMixin delegate,
) {
final delta = node.delta;
if (delta == null) {
return [];
}
final widgets = <Widget>[];
final textInserts = delta.whereType<TextInsert>();
int index = 0;
final editorState = context.read<EditorState>();
for (final textInsert in textInserts) {
if (textInsert.attributes?.href != null) {
final nodeSelection = Selection(
start: Position(path: node.path, offset: index),
end: Position(
path: node.path,
offset: index + textInsert.length,
),
);
final rectList = delegate.getRectsInSelection(nodeSelection);
if (rectList.isNotEmpty) {
for (final rect in rectList) {
widgets.add(
Positioned(
left: rect.left,
top: rect.top,
child: SizedBox(
width: rect.width,
height: rect.height,
child: LinkHoverTrigger(
editorState: editorState,
selection: nodeSelection,
attribute: textInsert.attributes!,
node: node,
size: rect.size,
),
),
),
);
}
}
}
index += textInsert.length;
}
return widgets;
}
}

View File

@ -144,34 +144,34 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a
appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9
auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d
bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00
connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38
app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468
appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7
auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118
bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9
connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5
desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43
device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041
file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31
flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c
irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189
url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c
PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823

View File

@ -25,6 +25,7 @@ class AppFlowyPopover extends StatelessWidget {
this.skipTraversal = false,
this.decorationColor,
this.borderRadius,
this.popoverDecoration,
this.animationDuration = const Duration(),
this.slideDistance = 5.0,
this.beginScaleFactor = 0.9,
@ -56,6 +57,7 @@ class AppFlowyPopover extends StatelessWidget {
final double endScaleFactor;
final double beginOpacity;
final double endOpacity;
final Decoration? popoverDecoration;
/// The widget that will be used to trigger the popover.
///
@ -102,6 +104,7 @@ class AppFlowyPopover extends StatelessWidget {
popupBuilder: (context) => _PopoverContainer(
constraints: constraints,
margin: margin,
decoration: popoverDecoration,
decorationColor: decorationColor,
borderRadius: borderRadius,
child: popupBuilder(context),
@ -116,6 +119,7 @@ class _PopoverContainer extends StatelessWidget {
const _PopoverContainer({
this.decorationColor,
this.borderRadius,
this.decoration,
required this.child,
required this.margin,
required this.constraints,
@ -126,6 +130,7 @@ class _PopoverContainer extends StatelessWidget {
final EdgeInsets margin;
final Color? decorationColor;
final BorderRadius? borderRadius;
final Decoration? decoration;
@override
Widget build(BuildContext context) {
@ -133,10 +138,11 @@ class _PopoverContainer extends StatelessWidget {
type: MaterialType.transparency,
child: Container(
padding: margin,
decoration: context.getPopoverDecoration(
color: decorationColor,
borderRadius: borderRadius,
),
decoration: decoration ??
context.getPopoverDecoration(
color: decorationColor,
borderRadius: borderRadius,
),
constraints: constraints,
child: child,
),

View File

@ -90,8 +90,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "5070212"
resolved-ref: "5070212ee0f02182a8acdd760b4d7b42264baec4"
ref: "50f9724"
resolved-ref: "50f9724190ee47eac3d7dbe323a3ce39d19ea883"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "5.1.0"

View File

@ -180,7 +180,7 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "5070212"
ref: "50f9724"
appflowy_editor_plugins:
git:

View 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="M10 17C13.866 17 17 13.866 17 10C17 6.13401 13.866 3 10 3M10 17C6.13401 17 3 13.866 3 10C3 6.13401 6.13401 3 10 3M10 17C11.6569 17 13 13.866 13 10C13 6.13401 11.6569 3 10 3M10 17C8.34315 17 7 13.866 7 10C7 6.13401 8.34315 3 10 3M3 10.025H17" stroke="#1F2329"/>
</svg>

After

Width:  |  Height:  |  Size: 373 B

View 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="M9.91797 5.7627C10.2674 7.92417 12.0879 9.57661 14.3473 9.79589M11.0285 4.62701L4.35601 11.4325C4.10407 11.691 3.86025 12.2 3.81149 12.5524L3.51078 15.0898C3.40513 16.0061 4.08782 16.6326 5.03057 16.476L7.64754 16.0453C8.01326 15.9826 8.52527 15.7242 8.77722 15.4579L15.4497 8.65237C16.6037 7.47766 17.1239 6.13848 15.3278 4.50171C13.5398 2.8806 12.1825 3.45229 11.0285 4.62701Z" stroke="#1F2329" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 581 B

View 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="M15.1609 13.2392L17.0001 13.2666M13.2393 15.1608L13.2668 17M4.83926 6.76085L3.00009 6.73337M6.76086 4.83921L6.73339 3M8.60003 6.73337L10.2262 5.10712C11.5149 3.81844 13.6042 3.81844 14.8929 5.10712C16.1815 6.3958 16.1815 8.48516 14.8929 9.77383L13.2667 11.4001M11.4 13.2668L9.7738 14.893C8.48515 16.1817 6.39584 16.1817 5.10718 14.893C3.81853 13.6043 3.81853 11.515 5.10718 10.2263L6.73339 8.60006" stroke="#1F2329" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 553 B

View File

@ -2120,7 +2120,12 @@
"inlineCode": "Inline code",
"suggestions": "Suggestions",
"turnInto": "Turn into",
"equation": "Equation"
"equation": "Equation",
"insert": "Insert",
"linkInputHint": "Paste link or search pages",
"pageOrURL": "Page or URL",
"linkName": "Link Name",
"linkNameHint": "Input link name"
},
"errorBlock": {
"theBlockIsNotSupported": "Unable to parse the block content",