From 6f031d0c7e1ab2efee52e41f2ee4de3e41e476b7 Mon Sep 17 00:00:00 2001 From: Morn Date: Mon, 24 Mar 2025 09:55:23 +0800 Subject: [PATCH] feat: revamp toolbar link (#7578) * feat: revamp toolbar link * fix: some review issues * chore: add integration test for toolbar link --- .../document/document_toolbar_test.dart | 151 ++++++ .../document/presentation/editor_page.dart | 3 +- .../desktop_floating_toolbar.dart | 18 +- .../link/link_create_menu.dart | 164 +++++++ .../desktop_toolbar/link/link_edit_menu.dart | 429 ++++++++++++++++++ .../desktop_toolbar/link/link_hover_menu.dart | 322 +++++++++++++ .../link/link_search_text_field.dart | 326 +++++++++++++ .../desktop_toolbar/link/link_styles.dart | 34 ++ .../desktop_toolbar/toolbar_cubit.dart | 17 + .../header/emoji_icon_widget.dart | 3 + .../mention/mention_page_block.dart | 12 +- .../custom_link_toolbar_item.dart | 171 ++++++- .../document/presentation/editor_style.dart | 69 ++- frontend/appflowy_flutter/macos/Podfile.lock | 46 +- .../src/flowy_overlay/appflowy_popover.dart | 14 +- frontend/appflowy_flutter/pubspec.lock | 4 +- frontend/appflowy_flutter/pubspec.yaml | 2 +- .../flowy_icons/20x/toolbar_link_earth.svg | 3 + .../flowy_icons/20x/toolbar_link_edit.svg | 3 + .../flowy_icons/20x/toolbar_link_unlink.svg | 3 + frontend/resources/translations/en.json | 7 +- 21 files changed, 1750 insertions(+), 51 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart create mode 100644 frontend/resources/flowy_icons/20x/toolbar_link_earth.svg create mode 100644 frontend/resources/flowy_icons/20x/toolbar_link_edit.svg create mode 100644 frontend/resources/flowy_icons/20x/toolbar_link_unlink.svg diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart index abfee17d8e..aaecd0ff10 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_toolbar_test.dart @@ -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().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); + }); + }); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index 09685aef5c..f445e698aa 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -443,8 +443,9 @@ class _AppFlowyEditorPageState extends State 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, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart index 4932eb8274..2cc9d700e2 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart @@ -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 createState() => _DesktopFloatingToolbarState(); @@ -35,11 +40,14 @@ class _DesktopFloatingToolbarState extends State { @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( + create: (_) => ToolbarCubit(widget.onDismiss), + child: Positioned( + left: position!.left, + top: position!.top, + right: position!.right, + child: widget.child, + ), ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart new file mode 100644 index 0000000000..4d3ef71cce --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart @@ -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 createState() => _LinkCreateMenuState(); +} + +class _LinkCreateMenuState extends State { + 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() + ?.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), + ), + ], + ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart new file mode 100644 index 0000000000..e10b46411b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart @@ -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 onApply; + final VoidCallback onRemoveLink; + final VoidCallback onDismiss; + + @override + State createState() => _LinkEditMenuState(); +} + +class _LinkEditMenuState extends State { + ValueChanged 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 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 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}; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart new file mode 100644 index 0000000000..e5950b5e79 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart @@ -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 createState() => _LinkHoverTriggerState(); +} + +class _LinkHoverTriggerState extends State { + 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 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 copyLink() async { + final href = widget.attribute.href ?? ''; + if (href.isEmpty) return; + await getIt() + .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), + ), + ), + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart new file mode 100644 index 0000000000..4fcd9e3b8b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_search_text_field.dart @@ -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 searchedViews = []; + final List 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? onPageLinkSelected, + double width = 320.0, + }) { + return onSearchResult( + 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? 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({ + required ValueGetter onLink, + required ValueGetter onRecentViews, + required ValueGetter onSearchViews, + required ValueGetter 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 searchRecentViews() async { + final recentService = getIt(); + 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 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(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart new file mode 100644 index 0000000000..ee63269dfc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart @@ -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, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart new file mode 100644 index 0000000000..8e114bf4c8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart @@ -0,0 +1,17 @@ +import 'dart:ui'; + +import 'package:bloc/bloc.dart'; + +class ToolbarCubit extends Cubit { + ToolbarCubit(this.onDismissCallback) : super(ToolbarState._()); + + final VoidCallback onDismissCallback; + + void dismiss() { + onDismissCallback.call(); + } +} + +class ToolbarState { + const ToolbarState._(); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart index 33333a3e92..91ae1af354 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart @@ -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 createState() => _RawEmojiIconWidgetState(); @@ -115,6 +117,7 @@ class _RawEmojiIconWidgetState extends State { emoji: widget.emoji.emoji, fontSize: widget.emojiSize, textAlign: TextAlign.justify, + lineHeight: widget.lineHeight, ); case FlowyIconType.icon: IconsData iconData = diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart index 398d158e9a..23c05b89eb 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart @@ -118,7 +118,7 @@ class _MentionPageBlockState extends State { view: view, content: state.blockContent, textStyle: widget.textStyle, - handleTap: () => _handleTap( + handleTap: () => handleMentionBlockTap( context, widget.editorState, view, @@ -138,7 +138,7 @@ class _MentionPageBlockState extends State { 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 { 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 { 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 _handleTap( +Future handleMentionBlockTap( BuildContext context, EditorState editorState, ViewPB view, { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart index c7fa1a34cc..355e7b3b7a 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart @@ -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(); 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; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index 4b4b2d135a..c602ade2c4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -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().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 _buildTextSpanOverlay( + BuildContext context, + Node node, + SelectableMixin delegate, + ) { + final delta = node.delta; + if (delta == null) { + return []; + } + final widgets = []; + final textInserts = delta.whereType(); + int index = 0; + final editorState = context.read(); + 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; + } } diff --git a/frontend/appflowy_flutter/macos/Podfile.lock b/frontend/appflowy_flutter/macos/Podfile.lock index b4a1a3d20d..30ee626f09 100644 --- a/frontend/appflowy_flutter/macos/Podfile.lock +++ b/frontend/appflowy_flutter/macos/Podfile.lock @@ -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 diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart index 9d25672622..0ef500d967 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart @@ -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, ), diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 4d580a4fd9..dcaefebed4 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -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" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 25ce68a289..19e30fa4a2 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -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: diff --git a/frontend/resources/flowy_icons/20x/toolbar_link_earth.svg b/frontend/resources/flowy_icons/20x/toolbar_link_earth.svg new file mode 100644 index 0000000000..57cb67da9a --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_link_earth.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_link_edit.svg b/frontend/resources/flowy_icons/20x/toolbar_link_edit.svg new file mode 100644 index 0000000000..fc8765fa5b --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_link_edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/flowy_icons/20x/toolbar_link_unlink.svg b/frontend/resources/flowy_icons/20x/toolbar_link_unlink.svg new file mode 100644 index 0000000000..e1061b914a --- /dev/null +++ b/frontend/resources/flowy_icons/20x/toolbar_link_unlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 7d8cba8b13..2c4dfa4371 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -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",