From 7b031b228eb91dce91803048be759fe6465d981b Mon Sep 17 00:00:00 2001 From: Lucas Date: Fri, 18 Oct 2024 17:13:38 +0800 Subject: [PATCH] fix: turn into issues (#6576) * fix: cover title issues * fix: the selection should be cleared if selecting child node * fix: exclude the blocks that are not supported in the 'turn into' types * fix: add logs * fix: floating toolbar ai status * test: selecting the parent should deselect all the child nodes as well * chore: 'Copy Link' to 'Copy link' * fix: select all and turn into block doesn't work on Windows * test: calculate turn into selection test * fix: option button tests --- .../document/document_option_action_test.dart | 33 +++ .../document/document_test_runner_1.dart | 2 - .../actions/block_action_option_cubit.dart | 35 +++ .../draggable_option_button.dart | 267 +----------------- .../draggable_option_button_feedback.dart | 266 +++++++++++++++++ .../drag_to_reorder/option_button.dart | 138 +++++++++ .../base/cover_title_command.dart | 8 +- .../mention/mention_page_block.dart | 4 + .../widgets/smart_edit_toolbar_item.dart | 34 +-- frontend/appflowy_flutter/pubspec.lock | 12 +- .../document/turn_into/turn_into_test.dart | 150 ++++++++++ frontend/resources/translations/en.json | 6 +- 12 files changed, 661 insertions(+), 294 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button_feedback.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/option_button.dart diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart index 59daccca8baf..cbc634cf0256 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_option_action_test.dart @@ -2,6 +2,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -143,5 +144,37 @@ void main() { ); } }); + + testWidgets( + 'selecting the parent should deselect all the child nodes as well', + (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + const name = 'Test Document'; + await tester.createNewPageWithNameUnderParent(name: name); + await tester.openPage(name); + + // create a nested list + // Item 1 + // Nested Item 1 + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText('Item 1'); + await tester.ime.insertCharacter('\n'); + await tester.simulateKeyEvent(LogicalKeyboardKey.tab); + await tester.ime.insertText('Nested Item 1'); + + // select the 'Nested Item 1' and then tap the option button of the 'Item 1' + final editorState = tester.editor.getCurrentEditorState(); + final selection = Selection.collapsed( + Position(path: [0, 0], offset: 1), + ); + editorState.selection = selection; + await tester.pumpAndSettle(); + expect(editorState.selection, selection); + await tester.editor.hoverAndClickOptionMenuButton([0]); + expect(editorState.selection, Selection.collapsed(Position(path: [0]))); + }, + ); }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_1.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_1.dart index 7cc7d9dc38d7..9efe02863e18 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_1.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_test_runner_1.dart @@ -8,7 +8,6 @@ import 'document_create_and_delete_test.dart' import 'document_inline_page_reference_test.dart' as document_inline_page_reference_test; import 'document_more_actions_test.dart' as document_more_actions_test; -import 'document_option_action_test.dart' as document_option_action_test; import 'document_shortcuts_test.dart' as document_shortcuts_test; import 'document_text_direction_test.dart' as document_text_direction_test; import 'document_with_cover_image_test.dart' as document_with_cover_image_test; @@ -40,7 +39,6 @@ void main() { document_codeblock_paste_test.main(); document_alignment_test.main(); document_text_direction_test.main(); - document_option_action_test.main(); document_with_image_block_test.main(); document_with_multi_image_block_test.main(); document_inline_page_reference_test.main(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart index a3065fb15336..263d750e7a97 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart @@ -252,6 +252,10 @@ class BlockActionOptionCubit extends Cubit { ); insertedNode.add(afterNode); insertedNode.addAll(node.children.map((e) => e.copyWith())); + } else if (!EditorOptionActionType.turnInto.supportTypes + .contains(node.type)) { + afterNode = node.copyWith(); + insertedNode.add(afterNode); } else { insertedNode.add(afterNode); } @@ -267,4 +271,35 @@ class BlockActionOptionCubit extends Cubit { return true; } + + Selection? calculateTurnIntoSelection( + Node selectedNode, + Selection? beforeSelection, + ) { + final path = selectedNode.path; + final selection = Selection.collapsed( + Position(path: path), + ); + + // if the previous selection is null or the start path is not in the same level as the current block path, + // then update the selection with the current block path + // for example,'|' means the selection, + // case 1: collapsed selection + // - bulleted item 1 + // - bulleted |item 2 + // when clicking the bulleted item 1, the bulleted item 1 path should be selected + // case 2: not collapsed selection + // - bulleted item 1 + // - bulleted |item 2 + // - bulleted |item 3 + // when clicking the bulleted item 1, the bulleted item 1 path should be selected + if (beforeSelection == null || + beforeSelection.start.path.length != path.length || + !path.inSelection(beforeSelection)) { + return selection; + } + // if the beforeSelection start with the current block, + // then updating the selection with the beforeSelection that may contains multiple blocks + return beforeSelection; + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart index 460f9d1cb2ce..21c1998a3bbd 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart @@ -1,21 +1,12 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/visual_drag_area.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; + +import 'draggable_option_button_feedback.dart'; +import 'option_button.dart'; // this flag is used to disable the tooltip of the block when it is dragged @visibleForTesting @@ -66,13 +57,13 @@ class _DraggableOptionButtonState extends State { onDragStarted: _onDragStart, onDragUpdate: _onDragUpdate, onDragEnd: _onDragEnd, - feedback: _OptionButtonFeedback( + feedback: DraggleOptionButtonFeedback( controller: widget.controller, editorState: widget.editorState, blockComponentContext: widget.blockComponentContext, blockComponentBuilder: widget.blockComponentBuilder, ), - child: _OptionButton( + child: OptionButton( isDragging: isDraggingAppFlowyEditorBlock, controller: widget.controller, editorState: widget.editorState, @@ -130,251 +121,3 @@ class _DraggableOptionButtonState extends State { }); } } - -class _OptionButtonFeedback extends StatefulWidget { - const _OptionButtonFeedback({ - required this.controller, - required this.editorState, - required this.blockComponentContext, - required this.blockComponentBuilder, - }); - - final PopoverController controller; - final EditorState editorState; - final BlockComponentContext blockComponentContext; - final Map blockComponentBuilder; - - @override - State<_OptionButtonFeedback> createState() => _OptionButtonFeedbackState(); -} - -class _OptionButtonFeedbackState extends State<_OptionButtonFeedback> { - late Node node; - late BlockComponentContext blockComponentContext; - - @override - void initState() { - super.initState(); - - _setupLockComponentContext(); - widget.blockComponentContext.node.addListener(_updateBlockComponentContext); - } - - @override - void dispose() { - widget.blockComponentContext.node - .removeListener(_updateBlockComponentContext); - - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final maxWidth = (widget.editorState.renderBox?.size.width ?? - MediaQuery.of(context).size.width) * - 0.8; - - return Opacity( - opacity: 0.7, - child: Material( - color: Colors.transparent, - child: Container( - constraints: BoxConstraints( - maxWidth: maxWidth, - ), - child: IntrinsicHeight( - child: Provider.value( - value: widget.editorState, - child: _buildBlock(), - ), - ), - ), - ), - ); - } - - Widget _buildBlock() { - final node = widget.blockComponentContext.node; - final builder = widget.blockComponentBuilder[node.type]; - if (builder == null) { - return const SizedBox.shrink(); - } - - const unsupportedRenderBlockTypes = [ - TableBlockKeys.type, - CustomImageBlockKeys.type, - MultiImageBlockKeys.type, - FileBlockKeys.type, - DatabaseBlockKeys.boardType, - DatabaseBlockKeys.calendarType, - DatabaseBlockKeys.gridType, - ]; - - if (unsupportedRenderBlockTypes.contains(node.type)) { - // unable to render table block without provider/context - // render a placeholder instead - return Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(8), - ), - child: FlowyText(node.type.replaceAll('_', ' ').capitalize()), - ); - } - - return IntrinsicHeight( - child: MultiProvider( - providers: [ - Provider.value(value: widget.editorState), - Provider.value(value: getIt()), - ], - child: builder.build(blockComponentContext), - ), - ); - } - - void _updateBlockComponentContext() { - setState(() => _setupLockComponentContext()); - } - - void _setupLockComponentContext() { - node = widget.blockComponentContext.node.copyWith(); - blockComponentContext = BlockComponentContext( - widget.blockComponentContext.buildContext, - node, - ); - } -} - -class _OptionButton extends StatefulWidget { - const _OptionButton({ - required this.controller, - required this.editorState, - required this.blockComponentContext, - required this.isDragging, - }); - - final PopoverController controller; - final EditorState editorState; - final BlockComponentContext blockComponentContext; - final ValueNotifier isDragging; - - @override - State<_OptionButton> createState() => _OptionButtonState(); -} - -const _interceptorKey = 'document_option_button_interceptor'; - -class _OptionButtonState extends State<_OptionButton> { - late final gestureInterceptor = SelectionGestureInterceptor( - key: _interceptorKey, - canTap: (details) => !_isTapInBounds(details.globalPosition), - ); - - // the selection will be cleared when tap the option button - // so we need to restore the selection after tap the option button - Selection? beforeSelection; - RenderBox? get renderBox => context.findRenderObject() as RenderBox?; - - @override - void initState() { - super.initState(); - - widget.editorState.service.selectionService.registerGestureInterceptor( - gestureInterceptor, - ); - } - - @override - void dispose() { - widget.editorState.service.selectionService.unregisterGestureInterceptor( - _interceptorKey, - ); - - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: widget.isDragging, - builder: (context, isDragging, child) { - return BlockActionButton( - svg: FlowySvgs.drag_element_s, - showTooltip: !isDragging, - richMessage: TextSpan( - children: [ - TextSpan( - text: LocaleKeys.document_plugins_optionAction_drag.tr(), - style: context.tooltipTextStyle(), - ), - TextSpan( - text: LocaleKeys.document_plugins_optionAction_toMove.tr(), - style: context.tooltipTextStyle(), - ), - const TextSpan(text: '\n'), - TextSpan( - text: LocaleKeys.document_plugins_optionAction_click.tr(), - style: context.tooltipTextStyle(), - ), - TextSpan( - text: LocaleKeys.document_plugins_optionAction_toOpenMenu.tr(), - style: context.tooltipTextStyle(), - ), - ], - ), - onPointerDown: () { - if (widget.editorState.selection != null) { - beforeSelection = widget.editorState.selection; - } - }, - onTap: () { - if (widget.editorState.selection != null) { - beforeSelection = widget.editorState.selection; - } - - widget.controller.show(); - - // update selection - _updateBlockSelection(); - }, - ); - }, - ); - } - - void _updateBlockSelection() { - if (beforeSelection == null) { - final path = widget.blockComponentContext.node.path; - final selection = Selection.collapsed( - Position(path: path), - ); - widget.editorState.updateSelectionWithReason( - selection, - customSelectionType: SelectionType.block, - ); - } else { - widget.editorState.updateSelectionWithReason( - beforeSelection!, - customSelectionType: SelectionType.block, - ); - } - } - - bool _isTapInBounds(Offset offset) { - if (renderBox == null) { - return false; - } - - final localPosition = renderBox!.globalToLocal(offset); - final result = renderBox!.paintBounds.contains(localPosition); - if (result) { - beforeSelection = widget.editorState.selection; - } else { - beforeSelection = null; - } - - return result; - } -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button_feedback.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button_feedback.dart new file mode 100644 index 000000000000..89a75558be93 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button_feedback.dart @@ -0,0 +1,266 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class DraggleOptionButtonFeedback extends StatefulWidget { + const DraggleOptionButtonFeedback({ + super.key, + required this.controller, + required this.editorState, + required this.blockComponentContext, + required this.blockComponentBuilder, + }); + + final PopoverController controller; + final EditorState editorState; + final BlockComponentContext blockComponentContext; + final Map blockComponentBuilder; + + @override + State createState() => + _DraggleOptionButtonFeedbackState(); +} + +class _DraggleOptionButtonFeedbackState + extends State { + late Node node; + late BlockComponentContext blockComponentContext; + + @override + void initState() { + super.initState(); + + _setupLockComponentContext(); + widget.blockComponentContext.node.addListener(_updateBlockComponentContext); + } + + @override + void dispose() { + widget.blockComponentContext.node + .removeListener(_updateBlockComponentContext); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final maxWidth = (widget.editorState.renderBox?.size.width ?? + MediaQuery.of(context).size.width) * + 0.8; + + return Opacity( + opacity: 0.7, + child: Material( + color: Colors.transparent, + child: Container( + constraints: BoxConstraints( + maxWidth: maxWidth, + ), + child: IntrinsicHeight( + child: Provider.value( + value: widget.editorState, + child: _buildBlock(), + ), + ), + ), + ), + ); + } + + Widget _buildBlock() { + final node = widget.blockComponentContext.node; + final builder = widget.blockComponentBuilder[node.type]; + if (builder == null) { + return const SizedBox.shrink(); + } + + const unsupportedRenderBlockTypes = [ + TableBlockKeys.type, + CustomImageBlockKeys.type, + MultiImageBlockKeys.type, + FileBlockKeys.type, + DatabaseBlockKeys.boardType, + DatabaseBlockKeys.calendarType, + DatabaseBlockKeys.gridType, + ]; + + if (unsupportedRenderBlockTypes.contains(node.type)) { + // unable to render table block without provider/context + // render a placeholder instead + return Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(8), + ), + child: FlowyText(node.type.replaceAll('_', ' ').capitalize()), + ); + } + + return IntrinsicHeight( + child: MultiProvider( + providers: [ + Provider.value(value: widget.editorState), + Provider.value(value: getIt()), + ], + child: builder.build(blockComponentContext), + ), + ); + } + + void _updateBlockComponentContext() { + setState(() => _setupLockComponentContext()); + } + + void _setupLockComponentContext() { + node = widget.blockComponentContext.node.copyWith(); + blockComponentContext = BlockComponentContext( + widget.blockComponentContext.buildContext, + node, + ); + } +} + +class _OptionButton extends StatefulWidget { + const _OptionButton({ + required this.controller, + required this.editorState, + required this.blockComponentContext, + required this.isDragging, + }); + + final PopoverController controller; + final EditorState editorState; + final BlockComponentContext blockComponentContext; + final ValueNotifier isDragging; + + @override + State<_OptionButton> createState() => _OptionButtonState(); +} + +const _interceptorKey = 'document_option_button_interceptor'; + +class _OptionButtonState extends State<_OptionButton> { + late final gestureInterceptor = SelectionGestureInterceptor( + key: _interceptorKey, + canTap: (details) => !_isTapInBounds(details.globalPosition), + ); + + // the selection will be cleared when tap the option button + // so we need to restore the selection after tap the option button + Selection? beforeSelection; + RenderBox? get renderBox => context.findRenderObject() as RenderBox?; + + @override + void initState() { + super.initState(); + + widget.editorState.service.selectionService.registerGestureInterceptor( + gestureInterceptor, + ); + } + + @override + void dispose() { + widget.editorState.service.selectionService.unregisterGestureInterceptor( + _interceptorKey, + ); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.isDragging, + builder: (context, isDragging, child) { + return BlockActionButton( + svg: FlowySvgs.drag_element_s, + showTooltip: !isDragging, + richMessage: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.document_plugins_optionAction_drag.tr(), + style: context.tooltipTextStyle(), + ), + TextSpan( + text: LocaleKeys.document_plugins_optionAction_toMove.tr(), + style: context.tooltipTextStyle(), + ), + const TextSpan(text: '\n'), + TextSpan( + text: LocaleKeys.document_plugins_optionAction_click.tr(), + style: context.tooltipTextStyle(), + ), + TextSpan( + text: LocaleKeys.document_plugins_optionAction_toOpenMenu.tr(), + style: context.tooltipTextStyle(), + ), + ], + ), + onPointerDown: () { + if (widget.editorState.selection != null) { + beforeSelection = widget.editorState.selection; + } + }, + onTap: () { + if (widget.editorState.selection != null) { + beforeSelection = widget.editorState.selection; + } + + widget.controller.show(); + + // update selection + _updateBlockSelection(); + }, + ); + }, + ); + } + + void _updateBlockSelection() { + if (beforeSelection == null) { + final path = widget.blockComponentContext.node.path; + final selection = Selection.collapsed( + Position(path: path), + ); + widget.editorState.updateSelectionWithReason( + selection, + customSelectionType: SelectionType.block, + ); + } else { + widget.editorState.updateSelectionWithReason( + beforeSelection!, + customSelectionType: SelectionType.block, + ); + } + } + + bool _isTapInBounds(Offset offset) { + if (renderBox == null) { + return false; + } + + final localPosition = renderBox!.globalToLocal(offset); + final result = renderBox!.paintBounds.contains(localPosition); + if (result) { + beforeSelection = widget.editorState.selection; + } else { + beforeSelection = null; + } + + return result; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/option_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/option_button.dart new file mode 100644 index 000000000000..2b093dcea3cf --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/option_button.dart @@ -0,0 +1,138 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_popover/appflowy_popover.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'; + +const _interceptorKey = 'document_option_button_interceptor'; + +class OptionButton extends StatefulWidget { + const OptionButton({ + super.key, + required this.controller, + required this.editorState, + required this.blockComponentContext, + required this.isDragging, + }); + + final PopoverController controller; + final EditorState editorState; + final BlockComponentContext blockComponentContext; + final ValueNotifier isDragging; + + @override + State createState() => _OptionButtonState(); +} + +class _OptionButtonState extends State { + late final gestureInterceptor = SelectionGestureInterceptor( + key: _interceptorKey, + canTap: (details) => !_isTapInBounds(details.globalPosition), + ); + + // the selection will be cleared when tap the option button + // so we need to restore the selection after tap the option button + Selection? beforeSelection; + RenderBox? get renderBox => context.findRenderObject() as RenderBox?; + + @override + void initState() { + super.initState(); + + widget.editorState.service.selectionService.registerGestureInterceptor( + gestureInterceptor, + ); + } + + @override + void dispose() { + widget.editorState.service.selectionService.unregisterGestureInterceptor( + _interceptorKey, + ); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.isDragging, + builder: (context, isDragging, child) { + return BlockActionButton( + svg: FlowySvgs.drag_element_s, + showTooltip: !isDragging, + richMessage: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.document_plugins_optionAction_drag.tr(), + style: context.tooltipTextStyle(), + ), + TextSpan( + text: LocaleKeys.document_plugins_optionAction_toMove.tr(), + style: context.tooltipTextStyle(), + ), + const TextSpan(text: '\n'), + TextSpan( + text: LocaleKeys.document_plugins_optionAction_click.tr(), + style: context.tooltipTextStyle(), + ), + TextSpan( + text: LocaleKeys.document_plugins_optionAction_toOpenMenu.tr(), + style: context.tooltipTextStyle(), + ), + ], + ), + onTap: () { + final selection = widget.editorState.selection; + if (selection != null) { + beforeSelection = selection.normalized; + } + + widget.controller.show(); + + // update selection + _updateBlockSelection(context); + }, + ); + }, + ); + } + + void _updateBlockSelection(BuildContext context) { + final cubit = context.read(); + final selection = cubit.calculateTurnIntoSelection( + widget.blockComponentContext.node, + beforeSelection, + ); + Log.info( + 'update block selection, beforeSelection: $beforeSelection, afterSelection: $selection', + ); + widget.editorState.updateSelectionWithReason( + selection, + customSelectionType: SelectionType.block, + ); + } + + bool _isTapInBounds(Offset offset) { + final renderBox = this.renderBox; + if (renderBox == null) { + return false; + } + + final localPosition = renderBox.globalToLocal(offset); + final result = renderBox.paintBounds.contains(localPosition); + if (result) { + beforeSelection = widget.editorState.selection?.normalized; + } else { + beforeSelection = null; + } + + return result; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/cover_title_command.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/cover_title_command.dart index ac518dc65ebb..20b4b7901ee9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/cover_title_command.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/cover_title_command.dart @@ -22,8 +22,8 @@ KeyEventResult _backspaceToTitle({ required EditorState editorState, }) { final coverTitleFocusNode = editorState.document.root.context - ?.read() - .coverTitleFocusNode; + ?.read() + ?.coverTitleFocusNode; if (coverTitleFocusNode == null) { return KeyEventResult.ignored; } @@ -108,8 +108,8 @@ KeyEventResult _arrowKeyToTitle({ required bool Function(Selection selection) checkSelection, }) { final coverTitleFocusNode = editorState.document.root.context - ?.read() - .coverTitleFocusNode; + ?.read() + ?.coverTitleFocusNode; if (coverTitleFocusNode == null) { return KeyEventResult.ignored; } 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 44ce1b30ae3d..271293ea289c 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 @@ -110,6 +110,7 @@ class _MentionPageBlockState extends State { if (UniversalPlatform.isMobile) { return _MobileMentionPageBlock( view: view, + content: state.blockContent, textStyle: widget.textStyle, handleTap: () => handleTap(view), handleDoubleTap: handleDoubleTap, @@ -367,6 +368,7 @@ class _NoAccessMentionPageBlock extends StatelessWidget { class _MobileMentionPageBlock extends StatelessWidget { const _MobileMentionPageBlock({ required this.view, + required this.content, required this.textStyle, required this.handleTap, required this.handleDoubleTap, @@ -374,6 +376,7 @@ class _MobileMentionPageBlock extends StatelessWidget { final TextStyle? textStyle; final ViewPB view; + final String content; final VoidCallback handleTap; final VoidCallback handleDoubleTap; @@ -385,6 +388,7 @@ class _MobileMentionPageBlock extends StatelessWidget { behavior: HitTestBehavior.opaque, child: _MentionPageBlockContent( view: view, + content: content, textStyle: textStyle, ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_toolbar_item.dart index 631b028583a5..9ace1035b2ca 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_toolbar_item.dart @@ -1,16 +1,16 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/smart_edit_action.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy/user/application/user_service.dart'; -import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.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'; const _kSmartEditToolbarItemId = 'appflowy.editor.smart_edit'; @@ -39,21 +39,12 @@ class SmartEditActionList extends StatefulWidget { } class _SmartEditActionListState extends State { - bool isAIEnabled = false; + bool isAIEnabled = true; @override void initState() { super.initState(); - - UserBackendService.getCurrentUserProfile().then((value) { - setState(() { - isAIEnabled = value.fold( - (userProfile) => - userProfile.authenticator == AuthenticatorPB.AppFlowyCloud, - (_) => false, - ); - }); - }); + isAIEnabled = _isAIEnabled(); } @override @@ -83,12 +74,13 @@ class _SmartEditActionListState extends State { ), onTap: () { if (isAIEnabled) { + keepEditorFocusNotifier.increase(); controller.show(); } else { - showSnackBarMessage( + showToastNotification( context, - LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), - showCancel: true, + message: + LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(), ); } }, @@ -161,4 +153,12 @@ class _SmartEditActionListState extends State { } return res; } + + bool _isAIEnabled() { + final documentContext = widget.editorState.document.root.context; + if (documentContext == null) { + return true; + } + return !documentContext.read().isLocalMode; + } } diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 980b44dd9133..fd7f833e8845 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -1535,10 +1535,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" plugin_platform_interface: dependency: "direct dev" description: @@ -1933,10 +1933,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" string_validator: dependency: "direct main" description: @@ -2238,10 +2238,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" watcher: dependency: transitive description: diff --git a/frontend/appflowy_flutter/test/unit_test/document/turn_into/turn_into_test.dart b/frontend/appflowy_flutter/test/unit_test/document/turn_into/turn_into_test.dart index ef79e9e64bbe..2cb78474ab76 100644 --- a/frontend/appflowy_flutter/test/unit_test/document/turn_into/turn_into_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/document/turn_into/turn_into_test.dart @@ -533,5 +533,155 @@ void main() { }, ); }); + + test('calculate selection when turn into', () { + // Example: + // - bulleted list item 1 + // - bulleted list item 1-1 + // - bulleted list item 1-2 + // - bulleted list item 2 + // - bulleted list item 2-1 + // - bulleted list item 2-2 + // - bulleted list item 3 + // - bulleted list item 3-1 + // - bulleted list item 3-2 + const text = 'bulleted list'; + const nestedText = 'nested bulleted list'; + final document = createDocument([ + bulletedListNode( + text: '$text 1', + children: [ + bulletedListNode(text: '$nestedText 1-1'), + bulletedListNode(text: '$nestedText 1-2'), + ], + ), + bulletedListNode( + text: '$text 2', + children: [ + bulletedListNode(text: '$nestedText 2-1'), + bulletedListNode(text: '$nestedText 2-2'), + ], + ), + bulletedListNode( + text: '$text 3', + children: [ + bulletedListNode(text: '$nestedText 3-1'), + bulletedListNode(text: '$nestedText 3-2'), + ], + ), + ]); + final editorState = EditorState(document: document); + final cubit = BlockActionOptionCubit( + editorState: editorState, + blockComponentBuilder: {}, + ); + + // case 1: collapsed selection and the selection is in the top level + // and tap the turn into button at the [0] + final selection1 = Selection.collapsed( + Position(path: [0], offset: 1), + ); + expect( + cubit.calculateTurnIntoSelection( + editorState.getNodeAtPath([0])!, + selection1, + ), + selection1, + ); + + // case 2: collapsed selection and the selection is in the nested level + // and tap the turn into button at the [0] + final selection2 = Selection.collapsed( + Position(path: [0, 0], offset: 1), + ); + expect( + cubit.calculateTurnIntoSelection( + editorState.getNodeAtPath([0])!, + selection2, + ), + Selection.collapsed(Position(path: [0])), + ); + + // case 3, collapsed selection and the selection is in the nested level + // and tap the turn into button at the [0, 0] + final selection3 = Selection.collapsed( + Position(path: [0, 0], offset: 1), + ); + expect( + cubit.calculateTurnIntoSelection( + editorState.getNodeAtPath([0, 0])!, + selection3, + ), + selection3, + ); + + // case 4, not collapsed selection and the selection is in the top level + // and tap the turn into button at the [0] + final selection4 = Selection( + start: Position(path: [0], offset: 1), + end: Position(path: [1], offset: 1), + ); + expect( + cubit.calculateTurnIntoSelection( + editorState.getNodeAtPath([0])!, + selection4, + ), + selection4, + ); + + // case 5, not collapsed selection and the selection is in the nested level + // and tap the turn into button at the [0] + final selection5 = Selection( + start: Position(path: [0, 0], offset: 1), + end: Position(path: [0, 1], offset: 1), + ); + expect( + cubit.calculateTurnIntoSelection( + editorState.getNodeAtPath([0])!, + selection5, + ), + Selection.collapsed(Position(path: [0])), + ); + + // case 6, not collapsed selection and the selection is in the nested level + // and tap the turn into button at the [0, 0] + final selection6 = Selection( + start: Position(path: [0, 0], offset: 1), + end: Position(path: [0, 1], offset: 1), + ); + expect( + cubit.calculateTurnIntoSelection( + editorState.getNodeAtPath([0])!, + selection6, + ), + Selection.collapsed(Position(path: [0])), + ); + + // case 7, multiple blocks selection, and tap the turn into button of one of the selected nodes + final selection7 = Selection( + start: Position(path: [0], offset: 1), + end: Position(path: [2], offset: 1), + ); + expect( + cubit.calculateTurnIntoSelection( + editorState.getNodeAtPath([1])!, + selection7, + ), + selection7, + ); + + // case 8, multiple blocks selection, and tap the turn into button of one of the non-selected nodes + final selection8 = Selection( + start: Position(path: [0], offset: 1), + end: Position(path: [1], offset: 1), + ); + expect( + cubit.calculateTurnIntoSelection( + editorState.getNodeAtPath([2])!, + selection8, + ), + Selection.collapsed(Position(path: [2])), + ); + }); }); } diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 7747d53cff55..c464f277213d 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -114,7 +114,7 @@ "html": "HTML", "clipboard": "Copy to clipboard", "csv": "CSV", - "copyLink": "Copy Link", + "copyLink": "Copy link", "publishToTheWeb": "Publish to Web", "publishToTheWebHint": "Create a website with AppFlowy", "publish": "Publish", @@ -162,7 +162,7 @@ "openNewTab": "Open in a new tab", "moveTo": "Move to", "addToFavorites": "Add to Favorites", - "copyLink": "Copy Link", + "copyLink": "Copy link", "changeIcon": "Change icon", "collapseAllPages": "Collapse all subpages" }, @@ -2713,4 +2713,4 @@ "refreshNote": "After successful upgrade, click to activate your new features.", "refresh": "here" } -} \ No newline at end of file +}