mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-11-01 10:33:29 +00:00
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:
parent
44c9d572c8
commit
6f031d0c7e
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
@ -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};
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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._();
|
||||
}
|
||||
@ -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 =
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 |
3
frontend/resources/flowy_icons/20x/toolbar_link_edit.svg
Normal file
3
frontend/resources/flowy_icons/20x/toolbar_link_edit.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="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 |
@ -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 |
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user