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 cfea4381e0..4f768feef3 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 @@ -1,3 +1,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_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -7,9 +11,23 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // +, ... button beside the block component. - group('document with option action button', () { - testWidgets( - 'click + to add a block after current selection, and click + and option key to add a block before current selection', + group('block option action:', () { + Future turnIntoBlock( + WidgetTester tester, + Path path, { + required String menuText, + required String afterType, + }) async { + await tester.editor.openTurnIntoMenu(path); + await tester.tapButton( + find.findTextInFlowyText(menuText), + ); + final node = tester.editor.getCurrentEditorState().getNodeAtPath(path); + expect(node?.type, afterType); + } + + testWidgets('''click + to add a block after current selection, + and click + and option key to add a block before current selection''', (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); @@ -40,5 +58,44 @@ void main() { expect(editorState.getNodeAtPath([0])?.delta?.toPlainText(), isEmpty); expect(editorState.getNodeAtPath([2])?.delta?.toPlainText(), isEmpty); }); + + testWidgets('turn into', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + const name = 'Test Document'; + await tester.createNewPageWithNameUnderParent(name: name); + await tester.openPage(name); + + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText('turn into'); + + // click the block option button to convert it to another blocks + final values = { + LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type, + LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type, + LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type, + LocaleKeys.document_slashMenu_name_bulletedList.tr(): + BulletedListBlockKeys.type, + LocaleKeys.document_slashMenu_name_numberedList.tr(): + NumberedListBlockKeys.type, + LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type, + LocaleKeys.document_slashMenu_name_todoList.tr(): + TodoListBlockKeys.type, + LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type, + LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type, + }; + + for (final value in values.entries) { + final menuText = value.key; + final afterType = value.value; + await turnIntoBlock( + tester, + [0], + menuText: menuText, + afterType: afterType, + ); + } + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_outline_block_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_outline_block_test.dart index fc6d0f86a6..02281baebb 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_outline_block_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_outline_block_test.dart @@ -1,9 +1,6 @@ import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -182,13 +179,9 @@ Future hoverAndClickDepthOptionAction( List path, int level, ) async { - await tester.editor.hoverAndClickOptionMenuButton([3]); - await tester.tap(find.byType(AppFlowyPopover).hitTestable().last); - await tester.pumpAndSettle(); - - // Find a total of 4 HoverButtons under the [BlockOptionButton], - // in addition to 3 HoverButtons under the [DepthOptionAction] - (child of BlockOptionButton) - await tester.tap(find.byType(HoverButton).hitTestable().at(3 + level)); + await tester.editor.openDepthMenu(path); + final type = OptionDepthType.fromLevel(level); + await tester.tapButton(find.findTextInFlowyText(type.description)); await tester.pumpAndSettle(); } diff --git a/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart b/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart index 5de5b73217..3b38ed04a1 100644 --- a/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/document_test_operations.dart @@ -6,6 +6,7 @@ import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/draggable_option_button.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_title.dart'; @@ -15,6 +16,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/uplo import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart'; import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart'; import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -275,10 +277,32 @@ class EditorOperations { widget.blockComponentContext.node.path.equals(path), ), ); + await tester.pumpUntilFound(find.byType(PopoverActionList)); }, ); } + /// open the turn into menu + Future openTurnIntoMenu(Path path) async { + await hoverAndClickOptionMenuButton(path); + await tester.tapButton( + find.findTextInFlowyText( + LocaleKeys.document_plugins_optionAction_turnInto.tr(), + ), + ); + await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu)); + } + + Future openDepthMenu(Path path) async { + await hoverAndClickOptionMenuButton(path); + await tester.tapButton( + find.findTextInFlowyText( + LocaleKeys.document_plugins_optionAction_depth.tr(), + ), + ); + await tester.pumpUntilFound(find.byType(DepthOptionMenu)); + } + /// Drag block /// /// [offset] is the offset to move the block. diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart index eaa8878c38..6263a57f2f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -18,6 +18,48 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; +enum EditorOptionActionType { + turnInto, + color, + align, + depth; + + Set get supportTypes { + switch (this) { + case EditorOptionActionType.turnInto: + return { + ParagraphBlockKeys.type, + HeadingBlockKeys.type, + QuoteBlockKeys.type, + CalloutBlockKeys.type, + BulletedListBlockKeys.type, + NumberedListBlockKeys.type, + TodoListBlockKeys.type, + }; + case EditorOptionActionType.color: + return { + ParagraphBlockKeys.type, + HeadingBlockKeys.type, + BulletedListBlockKeys.type, + NumberedListBlockKeys.type, + QuoteBlockKeys.type, + TodoListBlockKeys.type, + CalloutBlockKeys.type, + OutlineBlockKeys.type, + ToggleListBlockKeys.type, + }; + case EditorOptionActionType.align: + return { + ImageBlockKeys.type, + }; + case EditorOptionActionType.depth: + return { + OutlineBlockKeys.type, + }; + } + } +} + Map getEditorBuilderMap({ required BuildContext context, required EditorState editorState, @@ -285,30 +327,21 @@ Map getEditorBuilderMap({ } final builder = entry.value; - // customize the action builder. - final supportColorBuilderTypes = [ - ParagraphBlockKeys.type, - HeadingBlockKeys.type, - BulletedListBlockKeys.type, - NumberedListBlockKeys.type, - QuoteBlockKeys.type, - TodoListBlockKeys.type, - CalloutBlockKeys.type, - OutlineBlockKeys.type, - ToggleListBlockKeys.type, - ]; - - final supportAlignBuilderType = [ImageBlockKeys.type]; - final supportDepthBuilderType = [OutlineBlockKeys.type]; final colorAction = [OptionAction.divider, OptionAction.color]; final alignAction = [OptionAction.divider, OptionAction.align]; final depthAction = [OptionAction.depth]; + final turnIntoAction = [OptionAction.turnInto]; final List actions = [ ...standardActions, - if (supportColorBuilderTypes.contains(entry.key)) ...colorAction, - if (supportAlignBuilderType.contains(entry.key)) ...alignAction, - if (supportDepthBuilderType.contains(entry.key)) ...depthAction, + if (EditorOptionActionType.turnInto.supportTypes.contains(entry.key)) + ...turnIntoAction, + if (EditorOptionActionType.color.supportTypes.contains(entry.key)) + ...colorAction, + if (EditorOptionActionType.align.supportTypes.contains(entry.key)) + ...alignAction, + if (EditorOptionActionType.depth.supportTypes.contains(entry.key)) + ...depthAction, ]; if (UniversalPlatform.isDesktop) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_list.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_list.dart index 2718aafd63..bb8fe611e0 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_list.dart @@ -1,6 +1,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_add_button.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart index 152cb06be4..908a80e5bd 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_button.dart @@ -1,29 +1,15 @@ -import 'dart:async'; - -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/actions/option_action.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart'; -import 'package:appflowy/plugins/shared/share/constants.dart'; -import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy/workspace/application/view/prelude.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; -import 'package:appflowy_backend/dispatch/dispatch.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/style_widget/snap_bar.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:toastification/toastification.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'drag_to_reorder/draggable_option_button.dart'; -class BlockOptionButton extends StatefulWidget { +class BlockOptionButton extends StatelessWidget { const BlockOptionButton({ super.key, required this.blockComponentContext, @@ -40,286 +26,92 @@ class BlockOptionButton extends StatefulWidget { final Map blockComponentBuilder; @override - State createState() => _BlockOptionButtonState(); -} + Widget build(BuildContext context) { + final direction = + context.read().state.layoutDirection == + LayoutDirection.rtlLayout + ? PopoverDirection.rightWithCenterAligned + : PopoverDirection.leftWithCenterAligned; + return BlocProvider( + create: (context) => BlockActionOptionCubit( + editorState: editorState, + blockComponentBuilder: blockComponentBuilder, + ), + child: BlocBuilder( + builder: (context, _) => PopoverActionList( + actions: _buildPopoverActions(context), + popoverMutex: PopoverMutex(), + animationDuration: Durations.short3, + slideDistance: 5, + beginScaleFactor: 1.0, + beginOpacity: 0.8, + direction: direction, + onPopupBuilder: _onPopoverBuilder, + onClosed: () => _onPopoverClosed(context), + onSelected: (action, controller) => _onActionSelected( + context, + action, + controller, + ), + buildChild: (controller) => DraggableOptionButton( + controller: controller, + editorState: editorState, + blockComponentContext: blockComponentContext, + blockComponentBuilder: blockComponentBuilder, + ), + ), + ), + ); + } -class _BlockOptionButtonState extends State { - late final List popoverActions; - - @override - void initState() { - super.initState(); - - popoverActions = widget.actions.map((e) { + List _buildPopoverActions(BuildContext context) { + return actions.map((e) { switch (e) { case OptionAction.divider: return DividerOptionAction(); case OptionAction.color: - return ColorOptionAction(editorState: widget.editorState); + return ColorOptionAction(editorState: editorState); case OptionAction.align: - return AlignOptionAction(editorState: widget.editorState); + return AlignOptionAction(editorState: editorState); case OptionAction.depth: - return DepthOptionAction(editorState: widget.editorState); + return DepthOptionAction(editorState: editorState); + case OptionAction.turnInto: + return TurnIntoOptionAction( + editorState: editorState, + blockComponentBuilder: blockComponentBuilder, + ); default: return OptionActionWrapper(e); } }).toList(); } - @override - Widget build(BuildContext context) { - return PopoverActionList( - popoverMutex: PopoverMutex(), - actions: popoverActions, - direction: - context.read().state.layoutDirection == - LayoutDirection.rtlLayout - ? PopoverDirection.rightWithCenterAligned - : PopoverDirection.leftWithCenterAligned, - onPopupBuilder: () { - keepEditorFocusNotifier.increase(); - widget.blockComponentState.alwaysShowActions = true; - }, - onClosed: () { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (!mounted) { - return; - } - widget.editorState.selectionType = null; - widget.editorState.selection = null; - widget.blockComponentState.alwaysShowActions = false; - keepEditorFocusNotifier.decrease(); - }); - }, - onSelected: (action, controller) { - if (action is OptionActionWrapper) { - _onSelectAction(context, action.inner); - controller.close(); - } - }, - buildChild: (controller) => DraggableOptionButton( - controller: controller, - editorState: widget.editorState, - blockComponentContext: widget.blockComponentContext, - blockComponentBuilder: widget.blockComponentBuilder, - ), - ); + void _onPopoverBuilder() { + keepEditorFocusNotifier.increase(); + blockComponentState.alwaysShowActions = true; } - Future _onSelectAction( - BuildContext context, - OptionAction action, - ) async { - final node = widget.blockComponentContext.node; - final transaction = widget.editorState.transaction; - switch (action) { - case OptionAction.delete: - transaction.deleteNode(node); - break; - case OptionAction.duplicate: - await _duplicateBlock(context, transaction, node); - break; - case OptionAction.turnInto: - break; - case OptionAction.moveUp: - transaction.moveNode(node.path.previous, node); - break; - case OptionAction.moveDown: - transaction.moveNode(node.path.next.next, node); - break; - case OptionAction.copyLinkToBlock: - await _copyLinkToBlock(context, node); - break; - case OptionAction.align: - case OptionAction.color: - case OptionAction.divider: - case OptionAction.depth: - throw UnimplementedError(); - } - - await widget.editorState.apply(transaction); + void _onPopoverClosed(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + editorState.selectionType = null; + editorState.selection = null; + blockComponentState.alwaysShowActions = false; + }); } - Future _duplicateBlock( + void _onActionSelected( BuildContext context, - Transaction transaction, - Node node, - ) async { - // 1. verify the node integrity - final type = node.type; - final builder = widget.editorState.renderer.blockComponentBuilder(type); - - if (builder == null) { - Log.error('Block type $type is not supported'); + PopoverAction action, + PopoverController controller, + ) { + if (action is! OptionActionWrapper) { return; } - final valid = builder.validate(node); - if (!valid) { - Log.error('Block type $type is not valid'); - } - - // 2. duplicate the node - // the _copyBlock will fix the table block - Node newNode = _copyBlock(context, node); - - // 3. if the node is sub page, duplicate the view - if (node.type == SubPageBlockKeys.type) { - final viewId = await _handleDuplicateSubPage(context, node); - if (viewId == null) { - return; - } - - newNode = newNode.copyWith(attributes: {SubPageBlockKeys.viewId: viewId}); - } - - // 4. insert the node to the next of the current node - transaction.insertNode(node.path.next, newNode); - } - - Node _copyBlock(BuildContext context, Node node) { - Node copiedNode = node.copyWith(); - - final type = node.type; - final builder = widget.editorState.renderer.blockComponentBuilder(type); - - if (builder == null) { - Log.error('Block type $type is not supported'); - } else { - final valid = builder.validate(node); - if (!valid) { - Log.error('Block type $type is not valid'); - if (node.type == TableBlockKeys.type) { - copiedNode = _fixTableBlock(node); - } - } - } - - return copiedNode; - } - - Node _fixTableBlock(Node node) { - if (node.type != TableBlockKeys.type) { - return node; - } - - // the table node should contains colsLen and rowsLen - final colsLen = node.attributes[TableBlockKeys.colsLen]; - final rowsLen = node.attributes[TableBlockKeys.rowsLen]; - if (colsLen == null || rowsLen == null) { - return node; - } - - final newChildren = []; - final children = node.children; - - // based on the colsLen and rowsLen, iterate the children and fix the data - for (var i = 0; i < rowsLen; i++) { - for (var j = 0; j < colsLen; j++) { - final cell = children - .where( - (n) => - n.attributes[TableCellBlockKeys.rowPosition] == i && - n.attributes[TableCellBlockKeys.colPosition] == j, - ) - .firstOrNull; - if (cell != null) { - newChildren.add(cell.copyWith()); - } else { - newChildren.add( - tableCellNode('', i, j), - ); - } - } - } - - return node.copyWith( - children: newChildren, - attributes: { - ...node.attributes, - TableBlockKeys.colsLen: colsLen, - TableBlockKeys.rowsLen: rowsLen, - }, - ); - } - - Future _copyLinkToBlock(BuildContext context, Node node) async { - final viewId = context.read().documentId; - - final workspace = await FolderEventReadCurrentWorkspace().send(); - final workspaceId = workspace.fold( - (l) => l.id, - (r) => '', - ); - - if (workspaceId.isEmpty || viewId.isEmpty) { - Log.error('Failed to get workspace id: $workspaceId or view id: $viewId'); - if (context.mounted) { - showToastNotification( - context, - message: LocaleKeys.shareAction_copyLinkToBlockFailed.tr(), - type: ToastificationType.error, + context.read().handleAction( + action.inner, + blockComponentContext.node, ); - } - return; - } - - final link = ShareConstants.buildShareUrl( - workspaceId: workspaceId, - viewId: viewId, - blockId: node.id, - ); - await getIt().setData( - ClipboardServiceData(plainText: link), - ); - - if (context.mounted) { - showToastNotification( - context, - message: LocaleKeys.shareAction_copyLinkToBlockSuccess.tr(), - ); - } - } - - /// Handles duplicating a SubPage. - /// - /// If the duplication fails for any reason, this method will return false, and inserting - /// the duplicate node should be aborted. - /// - Future _handleDuplicateSubPage( - BuildContext context, - Node node, - ) async { - final viewId = node.attributes[SubPageBlockKeys.viewId]; - if (viewId == null) { - return null; - } - - final view = (await ViewBackendService.getView(viewId)).toNullable(); - if (view == null) { - return null; - } - - final result = await ViewBackendService.duplicate( - view: view, - openAfterDuplicate: false, - includeChildren: true, - parentViewId: view.parentViewId, - syncAfterDuplicate: true, - ); - - return result.fold( - (view) => view.id, - (error) { - Log.error(error); - if (context.mounted) { - showSnapBar( - context, - LocaleKeys.document_plugins_subPage_errors_failedDuplicatePage.tr(), - ); - } - return null; - }, - ); + controller.close(); } } 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 new file mode 100644 index 0000000000..2bcb3ae430 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart @@ -0,0 +1,250 @@ +import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart'; +import 'package:appflowy/plugins/shared/share/constants.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/view/prelude.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class BlockActionOptionState {} + +class BlockActionOptionCubit extends Cubit { + BlockActionOptionCubit({ + required this.editorState, + required this.blockComponentBuilder, + }) : super(BlockActionOptionState()); + + final EditorState editorState; + final Map blockComponentBuilder; + + Future handleAction(OptionAction action, Node node) async { + final transaction = editorState.transaction; + switch (action) { + case OptionAction.delete: + transaction.deleteNode(node); + break; + case OptionAction.duplicate: + await _duplicateBlock(transaction, node); + break; + case OptionAction.moveUp: + transaction.moveNode(node.path.previous, node); + break; + case OptionAction.moveDown: + transaction.moveNode(node.path.next.next, node); + break; + case OptionAction.copyLinkToBlock: + await _copyLinkToBlock(node); + break; + case OptionAction.align: + case OptionAction.color: + case OptionAction.divider: + case OptionAction.depth: + case OptionAction.turnInto: + throw UnimplementedError(); + } + + await editorState.apply(transaction); + } + + Future _duplicateBlock(Transaction transaction, Node node) async { + final type = node.type; + final builder = editorState.renderer.blockComponentBuilder(type); + + if (builder == null) { + Log.error('Block type $type is not supported'); + return; + } + + final valid = builder.validate(node); + if (!valid) { + Log.error('Block type $type is not valid'); + } + + Node newNode = _copyBlock(node); + + if (node.type == SubPageBlockKeys.type) { + final viewId = await _handleDuplicateSubPage(node); + if (viewId == null) { + return; + } + + newNode = newNode.copyWith(attributes: {SubPageBlockKeys.viewId: viewId}); + } + + transaction.insertNode(node.path.next, newNode); + } + + Node _copyBlock(Node node) { + Node copiedNode = node.copyWith(); + + final type = node.type; + final builder = editorState.renderer.blockComponentBuilder(type); + + if (builder == null) { + Log.error('Block type $type is not supported'); + } else { + final valid = builder.validate(node); + if (!valid) { + Log.error('Block type $type is not valid'); + if (node.type == TableBlockKeys.type) { + copiedNode = _fixTableBlock(node); + } + } + } + + return copiedNode; + } + + Node _fixTableBlock(Node node) { + if (node.type != TableBlockKeys.type) { + return node; + } + + // the table node should contains colsLen and rowsLen + final colsLen = node.attributes[TableBlockKeys.colsLen]; + final rowsLen = node.attributes[TableBlockKeys.rowsLen]; + if (colsLen == null || rowsLen == null) { + return node; + } + + final newChildren = []; + final children = node.children; + + // based on the colsLen and rowsLen, iterate the children and fix the data + for (var i = 0; i < rowsLen; i++) { + for (var j = 0; j < colsLen; j++) { + final cell = children + .where( + (n) => + n.attributes[TableCellBlockKeys.rowPosition] == i && + n.attributes[TableCellBlockKeys.colPosition] == j, + ) + .firstOrNull; + if (cell != null) { + newChildren.add(cell.copyWith()); + } else { + newChildren.add( + tableCellNode('', i, j), + ); + } + } + } + + return node.copyWith( + children: newChildren, + attributes: { + ...node.attributes, + TableBlockKeys.colsLen: colsLen, + TableBlockKeys.rowsLen: rowsLen, + }, + ); + } + + Future _copyLinkToBlock(Node node) async { + final viewId = getIt().documentId; + + final workspace = await FolderEventReadCurrentWorkspace().send(); + final workspaceId = workspace.fold( + (l) => l.id, + (r) => '', + ); + + if (workspaceId.isEmpty || viewId.isEmpty) { + Log.error('Failed to get workspace id: $workspaceId or view id: $viewId'); + emit(BlockActionOptionState()); // Emit a new state to trigger UI update + return; + } + + final link = ShareConstants.buildShareUrl( + workspaceId: workspaceId, + viewId: viewId, + blockId: node.id, + ); + await getIt().setData( + ClipboardServiceData(plainText: link), + ); + + emit(BlockActionOptionState()); // Emit a new state to trigger UI update + } + + Future _handleDuplicateSubPage(Node node) async { + final viewId = node.attributes[SubPageBlockKeys.viewId]; + if (viewId == null) { + return null; + } + + final view = (await ViewBackendService.getView(viewId)).toNullable(); + if (view == null) { + return null; + } + + final result = await ViewBackendService.duplicate( + view: view, + openAfterDuplicate: false, + includeChildren: true, + parentViewId: view.parentViewId, + syncAfterDuplicate: true, + ); + + return result.fold( + (view) => view.id, + (error) { + Log.error(error); + emit(BlockActionOptionState()); // Emit a new state to trigger UI update + return null; + }, + ); + } + + Future turnIntoBlock( + String type, + Node node, { + int? level, + }) async { + final toType = type; + + Log.info( + 'Turn into block: from ${node.type} to $type', + ); + + if (type == node.type && type != HeadingBlockKeys.type) { + Log.info('Block type is the same'); + return false; + } + + Node afterNode = node.copyWith( + type: type, + attributes: { + if (toType == HeadingBlockKeys.type) HeadingBlockKeys.level: level, + if (toType == TodoListBlockKeys.type) TodoListBlockKeys.checked: false, + blockComponentBackgroundColor: + node.attributes[blockComponentBackgroundColor], + blockComponentTextDirection: + node.attributes[blockComponentTextDirection], + blockComponentDelta: (node.delta ?? Delta()).toJson(), + }, + ); + final insertedNode = []; + // heading block and callout block should not have children + if ([HeadingBlockKeys.type, CalloutBlockKeys.type].contains(toType)) { + afterNode = afterNode.copyWith( + children: [], + ); + insertedNode.addAll(node.children.map((e) => e.copyWith())); + } + + final transaction = editorState.transaction; + transaction.insertNodes(node.path, [ + afterNode, + ...insertedNode, + ]); + transaction.deleteNode(node); + await editorState.apply(transaction); + + return true; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/align_option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/align_option_action.dart new file mode 100644 index 0000000000..1c84f5256a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/align_option_action.dart @@ -0,0 +1,152 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:styled_widget/styled_widget.dart'; + +enum OptionAlignType { + left, + center, + right; + + static OptionAlignType fromString(String? value) { + switch (value) { + case 'left': + return OptionAlignType.left; + case 'center': + return OptionAlignType.center; + case 'right': + return OptionAlignType.right; + default: + return OptionAlignType.center; + } + } + + FlowySvgData get svg { + switch (this) { + case OptionAlignType.left: + return FlowySvgs.align_left_s; + case OptionAlignType.center: + return FlowySvgs.align_center_s; + case OptionAlignType.right: + return FlowySvgs.align_right_s; + } + } + + String get description { + switch (this) { + case OptionAlignType.left: + return LocaleKeys.document_plugins_optionAction_left.tr(); + case OptionAlignType.center: + return LocaleKeys.document_plugins_optionAction_center.tr(); + case OptionAlignType.right: + return LocaleKeys.document_plugins_optionAction_right.tr(); + } + } +} + +class AlignOptionAction extends PopoverActionCell { + AlignOptionAction({ + required this.editorState, + }); + + final EditorState editorState; + + @override + Widget? leftIcon(Color iconColor) { + return FlowySvg( + align.svg, + size: const Size.square(12), + ).padding(all: 2.0); + } + + @override + String get name { + return LocaleKeys.document_plugins_optionAction_align.tr(); + } + + @override + PopoverActionCellBuilder get builder => + (context, parentController, controller) { + final selection = editorState.selection?.normalized; + if (selection == null) { + return const SizedBox.shrink(); + } + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null) { + return const SizedBox.shrink(); + } + final children = buildAlignOptions(context, (align) async { + await onAlignChanged(align); + controller.close(); + parentController.close(); + }); + return IntrinsicHeight( + child: IntrinsicWidth( + child: Column( + children: children, + ), + ), + ); + }; + + List buildAlignOptions( + BuildContext context, + void Function(OptionAlignType) onTap, + ) { + return OptionAlignType.values.map((e) => OptionAlignWrapper(e)).map((e) { + final leftIcon = e.leftIcon(Theme.of(context).colorScheme.onSurface); + final rightIcon = e.rightIcon(Theme.of(context).colorScheme.onSurface); + return HoverButton( + onTap: () => onTap(e.inner), + itemHeight: ActionListSizes.itemHeight, + leftIcon: leftIcon, + name: e.name, + rightIcon: rightIcon, + ); + }).toList(); + } + + OptionAlignType get align { + final selection = editorState.selection; + if (selection == null) { + return OptionAlignType.center; + } + final node = editorState.getNodeAtPath(selection.start.path); + final align = node?.attributes[blockComponentAlign]; + return OptionAlignType.fromString(align); + } + + Future onAlignChanged(OptionAlignType align) async { + if (align == this.align) { + return; + } + final selection = editorState.selection; + if (selection == null) { + return; + } + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null) { + return; + } + final transaction = editorState.transaction; + transaction.updateNode(node, { + blockComponentAlign: align.name, + }); + await editorState.apply(transaction); + } +} + +class OptionAlignWrapper extends ActionCell { + OptionAlignWrapper(this.inner); + + final OptionAlignType inner; + + @override + Widget? leftIcon(Color iconColor) => FlowySvg(inner.svg); + + @override + String get name => inner.description; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/color_option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/color_option_action.dart new file mode 100644 index 0000000000..d114a871e1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/color_option_action.dart @@ -0,0 +1,125 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.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/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +const optionActionColorDefaultColor = 'appflowy_theme_default_color'; + +class ColorOptionAction extends CustomActionCell { + ColorOptionAction({ + required this.editorState, + }); + + final EditorState editorState; + final PopoverController innerController = PopoverController(); + + @override + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { + return AppFlowyPopover( + asBarrier: true, + controller: innerController, + mutex: mutex, + popupBuilder: (context) => _buildColorOptionMenu( + context, + controller, + ), + direction: PopoverDirection.rightWithCenterAligned, + offset: const Offset(10, 0), + animationDuration: Durations.short3, + beginScaleFactor: 1.0, + beginOpacity: 0.8, + child: HoverButton( + itemHeight: ActionListSizes.itemHeight, + leftIcon: const FlowySvg( + FlowySvgs.color_format_m, + size: Size.square(15), + ), + name: LocaleKeys.document_plugins_optionAction_color.tr(), + onTap: () { + innerController.show(); + }, + ), + ); + } + + Widget _buildColorOptionMenu( + BuildContext context, + PopoverController controller, + ) { + final selection = editorState.selection?.normalized; + if (selection == null) { + return const SizedBox.shrink(); + } + + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null) { + return const SizedBox.shrink(); + } + + return _buildColorOptions(context, node, controller); + } + + Widget _buildColorOptions( + BuildContext context, + Node node, + PopoverController controller, + ) { + final selection = editorState.selection?.normalized; + if (selection == null) { + return const SizedBox.shrink(); + } + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null) { + return const SizedBox.shrink(); + } + final bgColor = node.attributes[blockComponentBackgroundColor] as String?; + final selectedColor = bgColor?.tryToColor(); + // get default background color for callout block from themeExtension + final defaultColor = node.type == CalloutBlockKeys.type + ? AFThemeExtension.of(context).calloutBGColor + : Colors.transparent; + final colors = [ + // reset to default background color + FlowyColorOption( + color: defaultColor, + i18n: LocaleKeys.document_plugins_optionAction_defaultColor.tr(), + id: optionActionColorDefaultColor, + ), + ...FlowyTint.values.map( + (e) => FlowyColorOption( + color: e.color(context), + i18n: e.tintName(AppFlowyEditorL10n.current), + id: e.id, + ), + ), + ]; + + return FlowyColorPicker( + colors: colors, + selected: selectedColor, + border: Border.all( + color: AFThemeExtension.of(context).onBackground, + ), + onTap: (option, index) async { + final transaction = editorState.transaction; + transaction.updateNode(node, { + blockComponentBackgroundColor: option.id, + }); + await editorState.apply(transaction); + + innerController.close(); + controller.close(); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/depth_option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/depth_option_action.dart new file mode 100644 index 0000000000..bc083cd617 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/depth_option_action.dart @@ -0,0 +1,142 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +enum OptionDepthType { + h1(1, 'H1'), + h2(2, 'H2'), + h3(3, 'H3'), + h4(4, 'H4'), + h5(5, 'H5'), + h6(6, 'H6'); + + const OptionDepthType(this.level, this.description); + + final String description; + final int level; + + static OptionDepthType fromLevel(int? level) { + switch (level) { + case 1: + return OptionDepthType.h1; + case 2: + return OptionDepthType.h2; + case 3: + default: + return OptionDepthType.h3; + } + } +} + +class DepthOptionAction extends PopoverActionCell { + DepthOptionAction({ + required this.editorState, + }); + + final EditorState editorState; + + @override + Widget? leftIcon(Color iconColor) { + return FlowySvg( + OptionAction.depth.svg, + size: const Size.square(16), + ); + } + + @override + String get name => LocaleKeys.document_plugins_optionAction_depth.tr(); + + @override + PopoverActionCellBuilder get builder => + (context, parentController, controller) { + return DepthOptionMenu( + onTap: (depth) async { + await onDepthChanged(depth); + parentController.close(); + parentController.close(); + }, + ); + }; + + OptionDepthType depth(Node node) { + final level = node.attributes[OutlineBlockKeys.depth]; + return OptionDepthType.fromLevel(level); + } + + Future onDepthChanged(OptionDepthType depth) async { + final selection = editorState.selection; + final node = selection != null + ? editorState.getNodeAtPath(selection.start.path) + : null; + + if (node == null || depth == this.depth(node)) return; + + final transaction = editorState.transaction; + transaction.updateNode( + node, + {OutlineBlockKeys.depth: depth.level}, + ); + await editorState.apply(transaction); + } +} + +class DepthOptionMenu extends StatelessWidget { + const DepthOptionMenu({ + super.key, + required this.onTap, + }); + + final Future Function(OptionDepthType) onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 42, + child: Column( + mainAxisSize: MainAxisSize.min, + children: buildDepthOptions(context, onTap), + ), + ); + } + + List buildDepthOptions( + BuildContext context, + Future Function(OptionDepthType) onTap, + ) { + return OptionDepthType.values + .map((e) => OptionDepthWrapper(e)) + .map( + (e) => HoverButton( + onTap: () => onTap(e.inner), + itemHeight: ActionListSizes.itemHeight, + name: e.name, + ), + ) + .toList(); + } +} + +class OptionDepthWrapper extends ActionCell { + OptionDepthWrapper(this.inner); + + final OptionDepthType inner; + + @override + String get name => inner.description; +} + +class OptionActionWrapper extends ActionCell { + OptionActionWrapper(this.inner); + + final OptionAction inner; + + @override + Widget? leftIcon(Color iconColor) => FlowySvg(inner.svg); + + @override + String get name => inner.description; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/divider_option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/divider_option_action.dart new file mode 100644 index 0000000000..75e4339b5e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/divider_option_action.dart @@ -0,0 +1,20 @@ +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter/material.dart'; + +class DividerOptionAction extends CustomActionCell { + @override + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 4.0), + child: Divider( + height: 1.0, + thickness: 1.0, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart new file mode 100644 index 0000000000..08e2a51fa9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/option_actions.dart @@ -0,0 +1,74 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; + +export 'align_option_action.dart'; +export 'color_option_action.dart'; +export 'depth_option_action.dart'; +export 'divider_option_action.dart'; +export 'turn_into_option_action.dart'; + +enum OptionAction { + delete, + duplicate, + turnInto, + moveUp, + moveDown, + copyLinkToBlock, + + /// callout background color + color, + divider, + align, + depth; + + FlowySvgData get svg { + switch (this) { + case OptionAction.delete: + return FlowySvgs.trash_s; + case OptionAction.duplicate: + return FlowySvgs.copy_s; + case OptionAction.turnInto: + return FlowySvgs.turninto_s; + case OptionAction.moveUp: + return const FlowySvgData('editor/move_up'); + case OptionAction.moveDown: + return const FlowySvgData('editor/move_down'); + case OptionAction.color: + return const FlowySvgData('editor/color'); + case OptionAction.divider: + return const FlowySvgData('editor/divider'); + case OptionAction.align: + return FlowySvgs.m_aa_bulleted_list_s; + case OptionAction.depth: + return FlowySvgs.tag_s; + case OptionAction.copyLinkToBlock: + return FlowySvgs.share_tab_copy_s; + } + } + + String get description { + switch (this) { + case OptionAction.delete: + return LocaleKeys.document_plugins_optionAction_delete.tr(); + case OptionAction.duplicate: + return LocaleKeys.document_plugins_optionAction_duplicate.tr(); + case OptionAction.turnInto: + return LocaleKeys.document_plugins_optionAction_turnInto.tr(); + case OptionAction.moveUp: + return LocaleKeys.document_plugins_optionAction_moveUp.tr(); + case OptionAction.moveDown: + return LocaleKeys.document_plugins_optionAction_moveDown.tr(); + case OptionAction.color: + return LocaleKeys.document_plugins_optionAction_color.tr(); + case OptionAction.align: + return LocaleKeys.document_plugins_optionAction_align.tr(); + case OptionAction.depth: + return LocaleKeys.document_plugins_optionAction_depth.tr(); + case OptionAction.copyLinkToBlock: + return LocaleKeys.document_plugins_optionAction_copyLinkToBlock.tr(); + case OptionAction.divider: + throw UnsupportedError('Divider does not have description'); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart new file mode 100644 index 0000000000..b213a5ac02 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option/turn_into_option_action.dart @@ -0,0 +1,254 @@ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/workspace/presentation/widgets/pop_up_action.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'; + +class TurnIntoOptionAction extends CustomActionCell { + TurnIntoOptionAction({ + required this.editorState, + required this.blockComponentBuilder, + }); + + final EditorState editorState; + final Map blockComponentBuilder; + final PopoverController innerController = PopoverController(); + + @override + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { + return AppFlowyPopover( + asBarrier: true, + controller: innerController, + mutex: mutex, + popupBuilder: (context) => BlocProvider( + create: (context) => BlockActionOptionCubit( + editorState: editorState, + blockComponentBuilder: blockComponentBuilder, + ), + child: BlocBuilder( + builder: (context, _) => _buildTurnIntoOptionMenu(context), + ), + ), + direction: PopoverDirection.rightWithCenterAligned, + offset: const Offset(10, 0), + animationDuration: Durations.short3, + beginScaleFactor: 1.0, + beginOpacity: 0.8, + child: HoverButton( + itemHeight: ActionListSizes.itemHeight, + // todo(lucas): replace the svg with the correct one + leftIcon: const FlowySvg(FlowySvgs.turninto_s), + name: LocaleKeys.document_plugins_optionAction_turnInto.tr(), + onTap: () { + innerController.show(); + }, + ), + ); + } + + Widget _buildTurnIntoOptionMenu(BuildContext context) { + final selection = editorState.selection?.normalized; + // the selection may not be collapsed, for example, if a block contains some children, + // the selection will be the start from the current block and end at the last child block. + // we should take care of this case: + // converting a block that contains children to a heading block, + // we should move all the children under the heading block. + if (selection == null) { + return const SizedBox.shrink(); + } + + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null) { + return const SizedBox.shrink(); + } + + return TurnIntoOptionMenu(node: node); + } +} + +class TurnIntoOptionMenu extends StatelessWidget { + const TurnIntoOptionMenu({ + super.key, + required this.node, + }); + + final Node node; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: _buildTurnIntoOptions(context, node), + ); + } + + List _buildTurnIntoOptions(BuildContext context, Node node) { + final children = []; + + for (final type in EditorOptionActionType.turnInto.supportTypes) { + if (type != HeadingBlockKeys.type) { + children.add( + _TurnInfoButton( + type: type, + node: node, + ), + ); + } else { + // support h46 + for (final i in [1, 2, 3]) { + children.add( + _TurnInfoButton( + type: type, + node: node, + level: i, + ), + ); + } + } + } + + return children; + } +} + +class _TurnInfoButton extends StatelessWidget { + const _TurnInfoButton({ + required this.type, + required this.node, + this.level, + }); + + final String type; + final Node node; + final int? level; + + @override + Widget build(BuildContext context) { + final name = _buildLocalization( + type, + level: level, + ); + final leftIcon = _buildLeftIcon( + type, + level: level, + ); + final rightIcon = _buildRightIcon( + type, + node, + level: level, + ); + + return HoverButton( + name: name, + leftIcon: FlowySvg(leftIcon), + rightIcon: rightIcon, + itemHeight: ActionListSizes.itemHeight, + onTap: () { + context.read().turnIntoBlock( + type, + node, + level: level, + ); + }, + ); + } + + Widget? _buildRightIcon( + String type, + Node node, { + int? level, + }) { + if (type != node.type) { + return null; + } + + if (node.type == HeadingBlockKeys.type) { + final nodeLevel = node.attributes[HeadingBlockKeys.level] ?? 1; + if (level != nodeLevel) { + return null; + } + } + + return const FlowySvg( + FlowySvgs.workspace_selected_s, + blendMode: null, + ); + } + + FlowySvgData _buildLeftIcon( + String type, { + int? level, + }) { + if (type == ParagraphBlockKeys.type) { + return FlowySvgs.slash_menu_icon_text_s; + } else if (type == HeadingBlockKeys.type) { + switch (level) { + case 1: + return FlowySvgs.slash_menu_icon_h1_s; + case 2: + return FlowySvgs.slash_menu_icon_h2_s; + case 3: + return FlowySvgs.slash_menu_icon_h3_s; + // support h4h6 + default: + return FlowySvgs.slash_menu_icon_text_s; + } + } else if (type == QuoteBlockKeys.type) { + return FlowySvgs.slash_menu_icon_quote_s; + } else if (type == BulletedListBlockKeys.type) { + return FlowySvgs.slash_menu_icon_bulleted_list_s; + } else if (type == NumberedListBlockKeys.type) { + return FlowySvgs.slash_menu_icon_numbered_list_s; + } else if (type == TodoListBlockKeys.type) { + return FlowySvgs.slash_menu_icon_checkbox_s; + } else if (type == CalloutBlockKeys.type) { + return FlowySvgs.slash_menu_icon_callout_s; + } + + throw UnimplementedError('Unsupported block type: $type'); + } + + String _buildLocalization( + String type, { + int? level, + }) { + switch (type) { + case ParagraphBlockKeys.type: + return LocaleKeys.document_slashMenu_name_text.tr(); + case HeadingBlockKeys.type: + switch (level) { + case 1: + return LocaleKeys.document_slashMenu_name_heading1.tr(); + case 2: + return LocaleKeys.document_slashMenu_name_heading2.tr(); + case 3: + return LocaleKeys.document_slashMenu_name_heading3.tr(); + default: + return LocaleKeys.document_slashMenu_name_text.tr(); + } + case QuoteBlockKeys.type: + return LocaleKeys.document_slashMenu_name_quote.tr(); + case BulletedListBlockKeys.type: + return LocaleKeys.document_slashMenu_name_bulletedList.tr(); + case NumberedListBlockKeys.type: + return LocaleKeys.document_slashMenu_name_numberedList.tr(); + case TodoListBlockKeys.type: + return LocaleKeys.document_slashMenu_name_todoList.tr(); + case CalloutBlockKeys.type: + return LocaleKeys.document_slashMenu_name_callout.tr(); + } + + throw UnimplementedError('Unsupported block type: $type'); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option_action.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option_action.dart deleted file mode 100644 index 6fb9d2f908..0000000000 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option_action.dart +++ /dev/null @@ -1,430 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; -import 'package:appflowy/workspace/presentation/widgets/pop_up_action.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/theme_extension.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:styled_widget/styled_widget.dart'; - -const optionActionColorDefaultColor = 'appflowy_theme_default_color'; - -enum OptionAction { - delete, - duplicate, - turnInto, - moveUp, - moveDown, - copyLinkToBlock, - - /// callout background color - color, - divider, - align, - depth; - - FlowySvgData get svg { - switch (this) { - case OptionAction.delete: - return FlowySvgs.trash_s; - case OptionAction.duplicate: - return FlowySvgs.copy_s; - case OptionAction.turnInto: - return const FlowySvgData('editor/turn_into'); - case OptionAction.moveUp: - return const FlowySvgData('editor/move_up'); - case OptionAction.moveDown: - return const FlowySvgData('editor/move_down'); - case OptionAction.color: - return const FlowySvgData('editor/color'); - case OptionAction.divider: - return const FlowySvgData('editor/divider'); - case OptionAction.align: - return FlowySvgs.m_aa_bulleted_list_s; - case OptionAction.depth: - return FlowySvgs.tag_s; - case OptionAction.copyLinkToBlock: - return FlowySvgs.share_tab_copy_s; - } - } - - String get description { - switch (this) { - case OptionAction.delete: - return LocaleKeys.document_plugins_optionAction_delete.tr(); - case OptionAction.duplicate: - return LocaleKeys.document_plugins_optionAction_duplicate.tr(); - case OptionAction.turnInto: - return LocaleKeys.document_plugins_optionAction_turnInto.tr(); - case OptionAction.moveUp: - return LocaleKeys.document_plugins_optionAction_moveUp.tr(); - case OptionAction.moveDown: - return LocaleKeys.document_plugins_optionAction_moveDown.tr(); - case OptionAction.color: - return LocaleKeys.document_plugins_optionAction_color.tr(); - case OptionAction.align: - return LocaleKeys.document_plugins_optionAction_align.tr(); - case OptionAction.depth: - return LocaleKeys.document_plugins_optionAction_depth.tr(); - case OptionAction.copyLinkToBlock: - return LocaleKeys.document_plugins_optionAction_copyLinkToBlock.tr(); - case OptionAction.divider: - throw UnsupportedError('Divider does not have description'); - } - } -} - -enum OptionAlignType { - left, - center, - right; - - static OptionAlignType fromString(String? value) { - switch (value) { - case 'left': - return OptionAlignType.left; - case 'center': - return OptionAlignType.center; - case 'right': - return OptionAlignType.right; - default: - return OptionAlignType.center; - } - } - - FlowySvgData get svg { - switch (this) { - case OptionAlignType.left: - return FlowySvgs.align_left_s; - case OptionAlignType.center: - return FlowySvgs.align_center_s; - case OptionAlignType.right: - return FlowySvgs.align_right_s; - } - } - - String get description { - switch (this) { - case OptionAlignType.left: - return LocaleKeys.document_plugins_optionAction_left.tr(); - case OptionAlignType.center: - return LocaleKeys.document_plugins_optionAction_center.tr(); - case OptionAlignType.right: - return LocaleKeys.document_plugins_optionAction_right.tr(); - } - } -} - -enum OptionDepthType { - h1(1, 'H1'), - h2(2, 'H2'), - h3(3, 'H3'), - h4(4, 'H4'), - h5(5, 'H5'), - h6(6, 'H6'); - - const OptionDepthType(this.level, this.description); - - final String description; - final int level; - - static OptionDepthType fromLevel(int? level) { - switch (level) { - case 1: - return OptionDepthType.h1; - case 2: - return OptionDepthType.h2; - case 3: - default: - return OptionDepthType.h3; - } - } -} - -class DividerOptionAction extends CustomActionCell { - @override - Widget buildWithContext(BuildContext context, PopoverController controller) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 4.0), - child: Divider( - height: 1.0, - thickness: 1.0, - ), - ); - } -} - -class AlignOptionAction extends PopoverActionCell { - AlignOptionAction({ - required this.editorState, - }); - - final EditorState editorState; - - @override - Widget? leftIcon(Color iconColor) { - return FlowySvg( - align.svg, - size: const Size.square(12), - ).padding(all: 2.0); - } - - @override - String get name { - return LocaleKeys.document_plugins_optionAction_align.tr(); - } - - @override - PopoverActionCellBuilder get builder => - (context, parentController, controller) { - final selection = editorState.selection?.normalized; - if (selection == null) { - return const SizedBox.shrink(); - } - final node = editorState.getNodeAtPath(selection.start.path); - if (node == null) { - return const SizedBox.shrink(); - } - final children = buildAlignOptions(context, (align) async { - await onAlignChanged(align); - controller.close(); - parentController.close(); - }); - return IntrinsicHeight( - child: IntrinsicWidth( - child: Column( - children: children, - ), - ), - ); - }; - - List buildAlignOptions( - BuildContext context, - void Function(OptionAlignType) onTap, - ) { - return OptionAlignType.values.map((e) => OptionAlignWrapper(e)).map((e) { - final leftIcon = e.leftIcon(Theme.of(context).colorScheme.onSurface); - final rightIcon = e.rightIcon(Theme.of(context).colorScheme.onSurface); - return HoverButton( - onTap: () => onTap(e.inner), - itemHeight: ActionListSizes.itemHeight, - leftIcon: leftIcon, - name: e.name, - rightIcon: rightIcon, - ); - }).toList(); - } - - OptionAlignType get align { - final selection = editorState.selection; - if (selection == null) { - return OptionAlignType.center; - } - final node = editorState.getNodeAtPath(selection.start.path); - final align = node?.attributes['align']; - return OptionAlignType.fromString(align); - } - - Future onAlignChanged(OptionAlignType align) async { - if (align == this.align) { - return; - } - final selection = editorState.selection; - if (selection == null) { - return; - } - final node = editorState.getNodeAtPath(selection.start.path); - if (node == null) { - return; - } - final transaction = editorState.transaction; - transaction.updateNode(node, { - 'align': align.name, - }); - await editorState.apply(transaction); - } -} - -class ColorOptionAction extends PopoverActionCell { - ColorOptionAction({ - required this.editorState, - }); - - final EditorState editorState; - - @override - Widget? leftIcon(Color iconColor) { - return const FlowySvg( - FlowySvgs.color_format_m, - size: Size.square(12), - ).padding(all: 2.0); - } - - @override - String get name => LocaleKeys.document_plugins_optionAction_color.tr(); - - @override - Widget Function( - BuildContext context, - PopoverController parentController, - PopoverController controller, - ) get builder => (context, parentController, controller) { - final selection = editorState.selection?.normalized; - if (selection == null) { - return const SizedBox.shrink(); - } - final node = editorState.getNodeAtPath(selection.start.path); - if (node == null) { - return const SizedBox.shrink(); - } - final bgColor = - node.attributes[blockComponentBackgroundColor] as String?; - final selectedColor = bgColor?.tryToColor(); - // get default background color for callout block from themeExtension - final defaultColor = node.type == CalloutBlockKeys.type - ? AFThemeExtension.of(context).calloutBGColor - : Colors.transparent; - final colors = [ - // reset to default background color - FlowyColorOption( - color: defaultColor, - i18n: LocaleKeys.document_plugins_optionAction_defaultColor.tr(), - id: optionActionColorDefaultColor, - ), - ...FlowyTint.values.map( - (e) => FlowyColorOption( - color: e.color(context), - i18n: e.tintName(AppFlowyEditorL10n.current), - id: e.id, - ), - ), - ]; - - return FlowyColorPicker( - colors: colors, - selected: selectedColor, - border: Border.all( - color: AFThemeExtension.of(context).onBackground, - ), - onTap: (option, index) async { - final transaction = editorState.transaction; - transaction.updateNode(node, { - blockComponentBackgroundColor: option.id, - }); - await editorState.apply(transaction); - - controller.close(); - parentController.close(); - }, - ); - }; -} - -class DepthOptionAction extends PopoverActionCell { - DepthOptionAction({required this.editorState}); - - final EditorState editorState; - - @override - Widget? leftIcon(Color iconColor) { - return FlowySvg( - OptionAction.depth.svg, - size: const Size.square(12), - ).padding(all: 2.0); - } - - @override - String get name => LocaleKeys.document_plugins_optionAction_depth.tr(); - - @override - PopoverActionCellBuilder get builder => - (context, parentController, controller) { - final children = buildDepthOptions(context, (depth) async { - await onDepthChanged(depth); - controller.close(); - parentController.close(); - }); - - return SizedBox( - width: 42, - child: Column( - mainAxisSize: MainAxisSize.min, - children: children, - ), - ); - }; - - List buildDepthOptions( - BuildContext context, - Future Function(OptionDepthType) onTap, - ) { - return OptionDepthType.values - .map((e) => OptionDepthWrapper(e)) - .map( - (e) => HoverButton( - onTap: () => onTap(e.inner), - itemHeight: ActionListSizes.itemHeight, - name: e.name, - ), - ) - .toList(); - } - - OptionDepthType depth(Node node) { - final level = node.attributes[OutlineBlockKeys.depth]; - return OptionDepthType.fromLevel(level); - } - - Future onDepthChanged(OptionDepthType depth) async { - final selection = editorState.selection; - final node = selection != null - ? editorState.getNodeAtPath(selection.start.path) - : null; - - if (node == null || depth == this.depth(node)) return; - - final transaction = editorState.transaction; - transaction.updateNode( - node, - {OutlineBlockKeys.depth: depth.level}, - ); - await editorState.apply(transaction); - } -} - -class OptionDepthWrapper extends ActionCell { - OptionDepthWrapper(this.inner); - - final OptionDepthType inner; - - @override - String get name => inner.description; -} - -class OptionActionWrapper extends ActionCell { - OptionActionWrapper(this.inner); - - final OptionAction inner; - - @override - Widget? leftIcon(Color iconColor) => FlowySvg(inner.svg); - - @override - String get name => inner.description; -} - -class OptionAlignWrapper extends ActionCell { - OptionAlignWrapper(this.inner); - - final OptionAlignType inner; - - @override - Widget? leftIcon(Color iconColor) => FlowySvg(inner.svg); - - @override - String get name => inner.description; -} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart index 096f768dac..c718ec2e13 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/locale_keys.g.dart' show LocaleKeys; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -8,6 +6,7 @@ import 'package:easy_localization/easy_localization.dart' import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -95,10 +94,7 @@ class CalloutBlockComponentBuilder extends BlockComponentBuilder { // validate the data of the node, if the result is false, the node will be rendered as a placeholder @override - bool validate(Node node) => - node.delta != null && - node.children.isEmpty && - node.attributes[CalloutBlockKeys.icon] is String; + bool validate(Node node) => node.delta != null && node.children.isEmpty; } // the main widget for rendering the callout block diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart index d277a3c11b..6dd8e4d4b7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart @@ -1,5 +1,5 @@ export 'actions/block_action_list.dart'; -export 'actions/option_action.dart'; +export 'actions/option/option_actions.dart'; export 'align_toolbar_item/align_toolbar_item.dart'; export 'base/backtick_character_command.dart'; export 'base/cover_title_command.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart index b6e695d024..3380d8184b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; @@ -17,6 +15,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; // text menu item final textSlashMenuItem = SelectionMenuItem( diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart index 30c2a05de4..569b4a4ea4 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart @@ -124,9 +124,11 @@ class ShortcutsCubit extends Cubit { // check if currentShortcut is a codeblock shortcut. final isCodeBlockCommand = currentShortcut.isCodeBlockCommand; - for (final e in state.commandShortcutEvents) { - if (e.command == command && e.isCodeBlockCommand == isCodeBlockCommand) { - return e; + for (final shortcut in state.commandShortcutEvents) { + final keybindings = shortcut.command.split(','); + if (keybindings.contains(command) && + shortcut.isCodeBlockCommand == isCodeBlockCommand) { + return shortcut; } } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart index ef0622f85f..5bed313bae 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart @@ -90,7 +90,11 @@ class SpaceMoreActionTypeWrapper extends CustomActionCell { final void Function(PopoverController controller, dynamic data) onTap; @override - Widget buildWithContext(BuildContext context, PopoverController controller) { + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { if (inner == SpaceMoreActionType.divider) { return _buildDivider(); } else if (inner == SpaceMoreActionType.changeIcon) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart index fa2893535a..32a65bc803 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart @@ -73,7 +73,11 @@ class _WorkspaceMoreActionWrapper extends CustomActionCell { final UserWorkspacePB workspace; @override - Widget buildWithContext(BuildContext context, PopoverController controller) { + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { if (inner == WorkspaceMoreAction.divider) { return const Divider(); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart index f64971d8dd..be436d96f2 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_more_action_button.dart @@ -137,7 +137,11 @@ class ViewMoreActionTypeWrapper extends CustomActionCell { final Offset? moveActionOffset; @override - Widget buildWithContext(BuildContext context, PopoverController controller) { + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { if (inner == ViewMoreActionType.divider) { return _buildDivider(); } else if (inner == ViewMoreActionType.lastModified) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart index d5b70cd233..f5c8ffa146 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/social_media_section.dart @@ -5,7 +5,11 @@ import 'package:flutter/material.dart'; class SocialMediaSection extends CustomActionCell { @override - Widget buildWithContext(BuildContext context, PopoverController controller) { + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { final List children = [ Divider( height: 1, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart index 6363ee08e5..923f695188 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/float_bubble/version_section.dart @@ -12,7 +12,11 @@ import 'package:styled_widget/styled_widget.dart'; class FlowyVersionSection extends CustomActionCell { @override - Widget buildWithContext(BuildContext context, PopoverController controller) { + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ) { return FutureBuilder( future: PackageInfo.fromPlatform(), builder: (BuildContext context, AsyncSnapshot snapshot) { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart index 6de2dbb85f..c32c1d1872 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart @@ -41,6 +41,7 @@ class ViewAction extends StatelessWidget { context, // this is a dummy controller, we don't need to control the popover here. PopoverController(), + null, ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/pop_up_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/pop_up_action.dart index c1e49cbb32..ca0825d68a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/pop_up_action.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/pop_up_action.dart @@ -19,6 +19,10 @@ class PopoverActionList extends StatefulWidget { this.offset = Offset.zero, this.animationDuration = const Duration(), this.slideDistance = 20, + this.beginScaleFactor = 0.9, + this.endScaleFactor = 1.0, + this.beginOpacity = 0.0, + this.endOpacity = 1.0, this.constraints = const BoxConstraints( minWidth: 120, maxWidth: 460, @@ -39,6 +43,10 @@ class PopoverActionList extends StatefulWidget { final BoxConstraints constraints; final Duration animationDuration; final double slideDistance; + final double beginScaleFactor; + final double endScaleFactor; + final double beginOpacity; + final double endOpacity; @override State> createState() => _PopoverActionListState(); @@ -61,6 +69,10 @@ class _PopoverActionListState asBarrier: widget.asBarrier, animationDuration: widget.animationDuration, slideDistance: widget.slideDistance, + beginScaleFactor: widget.beginScaleFactor, + endScaleFactor: widget.endScaleFactor, + beginOpacity: widget.beginOpacity, + endOpacity: widget.endOpacity, controller: popoverController, constraints: widget.constraints, direction: widget.direction, @@ -88,7 +100,11 @@ class _PopoverActionListState ); } else { final custom = action as CustomActionCell; - return custom.buildWithContext(context, popoverController); + return custom.buildWithContext( + context, + popoverController, + widget.popoverMutex, + ); } }).toList(); @@ -129,7 +145,11 @@ abstract class PopoverActionCell extends PopoverAction { } abstract class CustomActionCell extends PopoverAction { - Widget buildWithContext(BuildContext context, PopoverController controller); + Widget buildWithContext( + BuildContext context, + PopoverController controller, + PopoverMutex? mutex, + ); } abstract class PopoverAction {} diff --git a/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart b/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart index c122d14b9c..355a196621 100644 --- a/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart +++ b/frontend/appflowy_flutter/packages/appflowy_backend/lib/log.dart @@ -14,6 +14,10 @@ class Log { bool _enabled = false; + // used to disable log in tests + @visibleForTesting + bool disableLog = false; + Log() { _logger = Logger( printer: PrettyPrinter( @@ -60,22 +64,42 @@ class Log { } static void info(dynamic msg, [dynamic error, StackTrace? stackTrace]) { + if (shared.disableLog) { + return; + } + _log(Level.info, 0, msg, error, stackTrace); } static void debug(dynamic msg, [dynamic error, StackTrace? stackTrace]) { + if (shared.disableLog) { + return; + } + _log(Level.debug, 1, msg, error, stackTrace); } static void warn(dynamic msg, [dynamic error, StackTrace? stackTrace]) { + if (shared.disableLog) { + return; + } + _log(Level.warning, 3, msg, error, stackTrace); } static void trace(dynamic msg, [dynamic error, StackTrace? stackTrace]) { + if (shared.disableLog) { + return; + } + _log(Level.trace, 2, msg, error, stackTrace); } static void error(dynamic msg, [dynamic error, StackTrace? stackTrace]) { + if (shared.disableLog) { + return; + } + _log(Level.error, 4, msg, error, stackTrace); } } diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/example_button.dart b/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/example_button.dart index c3d2b22d5e..ebd757aad3 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/example_button.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/example/lib/example_button.dart @@ -47,6 +47,8 @@ class _PopoverMenuState extends State { PopoverTriggerFlags.hover | PopoverTriggerFlags.click, mutex: popOverMutex, offset: const Offset(10, 0), + asBarrier: true, + debugId: 'First', popupBuilder: (BuildContext context) { return const PopoverMenu(); }, @@ -59,6 +61,8 @@ class _PopoverMenuState extends State { triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click, mutex: popOverMutex, + asBarrier: true, + debugId: 'Second', offset: const Offset(10, 0), popupBuilder: (BuildContext context) { return const PopoverMenu(); @@ -94,6 +98,7 @@ class ExampleButton extends StatelessWidget { animationDuration: Durations.medium1, offset: offset, direction: direction, + debugId: label, child: TextButton( child: Text(label), onPressed: () {}, diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mask.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mask.dart index bb62b5171a..8e740cb6d2 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mask.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/mask.dart @@ -15,14 +15,18 @@ class RootOverlayEntry { void addEntry( BuildContext context, + String id, PopoverState newState, OverlayEntry entry, bool asBarrier, + AnimationController animationController, ) { _entries[newState] = OverlayEntryContext( + id, entry, newState, asBarrier, + animationController, ); Overlay.of(context).insert(entry); } @@ -32,30 +36,49 @@ class RootOverlayEntry { removedEntry?.overlayEntry.remove(); } - PopoverState? popEntry() { + OverlayEntryContext? popEntry() { if (isEmpty) { return null; } final lastEntry = _entries.values.last; _entries.remove(lastEntry.popoverState); - lastEntry.overlayEntry.remove(); - lastEntry.popoverState.widget.onClose?.call(); + lastEntry.animationController.reverse().then((_) { + lastEntry.overlayEntry.remove(); + lastEntry.popoverState.widget.onClose?.call(); + }); - return lastEntry.asBarrier ? lastEntry.popoverState : popEntry(); + return lastEntry.asBarrier ? lastEntry : popEntry(); + } + + bool isLastEntryAsBarrier() { + if (isEmpty) { + return false; + } + + return _entries.values.last.asBarrier; } } class OverlayEntryContext { OverlayEntryContext( + this.id, this.overlayEntry, this.popoverState, this.asBarrier, + this.animationController, ); + final String id; final OverlayEntry overlayEntry; final PopoverState popoverState; final bool asBarrier; + final AnimationController animationController; + + @override + String toString() { + return 'OverlayEntryContext(id: $id, asBarrier: $asBarrier, popoverState: ${popoverState.widget.debugId})'; + } } class PopoverMask extends StatelessWidget { diff --git a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart index 99063e10b3..eb6aa8236e 100644 --- a/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart +++ b/frontend/appflowy_flutter/packages/appflowy_popover/lib/src/popover.dart @@ -73,9 +73,10 @@ class Popover extends StatefulWidget { this.animationDuration = const Duration(milliseconds: 200), this.beginOpacity = 0.0, this.endOpacity = 1.0, - this.beginScaleFactor = 0.95, + this.beginScaleFactor = 1.0, this.endScaleFactor = 1.0, - this.slideDistance = 20.0, + this.slideDistance = 5.0, + this.debugId, this.maskDecoration = const BoxDecoration( color: Color.fromARGB(0, 244, 67, 54), ), @@ -121,7 +122,7 @@ class Popover extends StatefulWidget { final bool skipTraversal; /// Animation time of the popover. - final Duration? animationDuration; + final Duration animationDuration; /// The distance of the popover's slide animation. final double slideDistance; @@ -134,6 +135,8 @@ class Popover extends StatefulWidget { final double beginOpacity; final double endOpacity; + final String? debugId; + /// The content area of the popover. final Widget child; @@ -202,7 +205,7 @@ class PopoverState extends State with SingleTickerProviderStateMixin { } void showOverlay() { - close(); + close(withAnimation: true); if (widget.mutex != null) { widget.mutex?.state = this; @@ -211,14 +214,18 @@ class PopoverState extends State with SingleTickerProviderStateMixin { final shouldAddMask = rootEntry.isEmpty; rootEntry.addEntry( context, + widget.debugId ?? '', this, OverlayEntry( builder: (context) => _buildOverlayContent(shouldAddMask), ), widget.asBarrier, + animationController, ); - animationController.forward(); + if (widget.animationDuration != Duration.zero) { + animationController.forward(); + } } void close({ @@ -233,7 +240,9 @@ class PopoverState extends State with SingleTickerProviderStateMixin { } } - if (isDisposed || !withAnimation) { + if (isDisposed || + !withAnimation || + widget.animationDuration == Duration.zero) { callback(); } else { animationController.reverse().then((_) => callback()); @@ -242,9 +251,7 @@ class PopoverState extends State with SingleTickerProviderStateMixin { } void _removeRootOverlay() { - animationController.reverse().then((_) { - rootEntry.popEntry(); - }); + rootEntry.popEntry(); if (widget.mutex?.state == this) { widget.mutex?.removeState(); @@ -252,41 +259,43 @@ class PopoverState extends State with SingleTickerProviderStateMixin { } Widget _buildChild(BuildContext context) { + Widget child = widget.child; + if (widget.triggerActions == 0) { - return widget.child; + return child; } - return MouseRegion( - onEnter: (event) { - if (widget.triggerActions & PopoverTriggerFlags.hover != 0) { + child = _buildClickHandler( + child, + () { + widget.onOpen?.call(); + if (widget.triggerActions & PopoverTriggerFlags.click != 0) { showOverlay(); } }, - child: _buildClickHandler( - widget.child, - () { - widget.onOpen?.call(); - if (widget.triggerActions & PopoverTriggerFlags.click != 0) { - showOverlay(); - } - }, - ), ); + + if (widget.triggerActions & PopoverTriggerFlags.hover != 0) { + child = MouseRegion( + onEnter: (event) => showOverlay(), + child: child, + ); + } + + return child; } Widget _buildClickHandler(Widget child, VoidCallback handler) { - switch (widget.clickHandler) { - case PopoverClickHandler.listener: - return Listener( + return switch (widget.clickHandler) { + PopoverClickHandler.listener => Listener( onPointerDown: (_) => _callHandler(handler), child: child, - ); - case PopoverClickHandler.gestureDetector: - return GestureDetector( + ), + PopoverClickHandler.gestureDetector => GestureDetector( onTap: () => _callHandler(handler), child: child, - ); - } + ), + }; } void _callHandler(VoidCallback handler) { @@ -325,28 +334,34 @@ class PopoverState extends State with SingleTickerProviderStateMixin { } Widget _buildPopoverContainer() { - return AnimatedBuilder( - animation: animationController, - builder: (context, child) { - return Opacity( - opacity: fadeAnimation.value, - child: Transform.scale( - scale: scaleAnimation.value, - child: Transform.translate( - offset: slideAnimation.value, - child: child, - ), - ), - ); - }, - child: PopoverContainer( - delegate: layoutDelegate, - popupBuilder: widget.popupBuilder, - skipTraversal: widget.skipTraversal, - onClose: close, - onCloseAll: _removeRootOverlay, - ), + Widget child = PopoverContainer( + delegate: layoutDelegate, + popupBuilder: widget.popupBuilder, + skipTraversal: widget.skipTraversal, + onClose: close, + onCloseAll: _removeRootOverlay, ); + + if (widget.animationDuration != Duration.zero) { + child = AnimatedBuilder( + animation: animationController, + builder: (context, child) { + return Opacity( + opacity: fadeAnimation.value, + child: Transform.scale( + scale: scaleAnimation.value, + child: Transform.translate( + offset: slideAnimation.value, + child: child, + ), + ), + ); + }, + child: child, + ); + } + + return child; } void _buildAnimations() { @@ -378,7 +393,7 @@ class PopoverState extends State with SingleTickerProviderStateMixin { ).animate( CurvedAnimation( parent: animationController, - curve: Curves.easeOutCubic, + curve: Curves.easeInOut, ), ); } @@ -391,7 +406,7 @@ class PopoverState extends State with SingleTickerProviderStateMixin { ).animate( CurvedAnimation( parent: animationController, - curve: Curves.easeOutCubic, + curve: Curves.linear, ), ); } diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart index b6007ad069..e807b0ccf7 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/src/flowy_overlay/appflowy_popover.dart @@ -24,7 +24,11 @@ class AppFlowyPopover extends StatelessWidget { this.decorationColor, this.borderRadius, this.animationDuration = const Duration(), - this.slideDistance = 20.0, + this.slideDistance = 5.0, + this.beginScaleFactor = 0.9, + this.endScaleFactor = 1.0, + this.beginOpacity = 0.0, + this.endOpacity = 1.0, }); final Widget child; @@ -45,6 +49,10 @@ class AppFlowyPopover extends StatelessWidget { final BorderRadius? borderRadius; final Duration animationDuration; final double slideDistance; + final double beginScaleFactor; + final double endScaleFactor; + final double beginOpacity; + final double endOpacity; /// The widget that will be used to trigger the popover. /// @@ -63,6 +71,10 @@ class AppFlowyPopover extends StatelessWidget { controller: controller, animationDuration: animationDuration, slideDistance: slideDistance, + beginScaleFactor: beginScaleFactor, + endScaleFactor: endScaleFactor, + beginOpacity: beginOpacity, + endOpacity: endOpacity, onOpen: onOpen, onClose: onClose, canClose: canClose, diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 744d039b5e..5ef0d4cf02 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -53,8 +53,8 @@ packages: dependency: "direct main" description: path: "." - ref: "98356fa" - resolved-ref: "98356fac4cbac7abefcc8641eb8481a9e527611f" + ref: "0a0d441" + resolved-ref: "0a0d44133981f87e233bcb102b1482488ef43e91" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "3.3.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 48815afd4f..38d947e463 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -170,7 +170,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "98356fa" + ref: "0a0d441" appflowy_editor_plugins: git: 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 new file mode 100644 index 0000000000..9383bb1cb7 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/document/turn_into/turn_into_test.dart @@ -0,0 +1,289 @@ +import 'package:appflowy/plugins/document/presentation/editor_configuration.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('turn into:', () { + Document createDocument(List nodes) { + final document = Document.blank(); + document.insert([0], nodes); + return document; + } + + Future checkTurnInto( + Document document, + String originalType, + String originalText, { + String? toType, + void Function(EditorState editorState, Node node)? afterTurnInto, + }) async { + final editorState = EditorState(document: document); + final cubit = BlockActionOptionCubit( + editorState: editorState, + blockComponentBuilder: {}, + ); + + final types = toType == null + ? EditorOptionActionType.turnInto.supportTypes + : [toType]; + for (final type in types) { + if (type == originalType) { + continue; + } + + final node = editorState.getNodeAtPath([0])!; + expect(node.type, originalType); + final result = await cubit.turnIntoBlock( + type, + node, + ); + expect(result, true); + final newNode = editorState.getNodeAtPath([0])!; + expect(newNode.type, type); + expect(newNode.delta!.toPlainText(), originalText); + afterTurnInto?.call( + editorState, + newNode, + ); + + // turn it back the originalType for the next test + await cubit.turnIntoBlock( + originalType, + newNode, + ); + expect(result, true); + } + } + + setUpAll(() { + Log.shared.disableLog = true; + }); + + tearDownAll(() { + Log.shared.disableLog = false; + }); + + test('from heading to another blocks', () async { + const text = 'Heading 1'; + final document = createDocument([ + headingNode( + level: 1, + text: text, + ), + ]); + await checkTurnInto( + document, + HeadingBlockKeys.type, + text, + ); + }); + + test('from paragraph to another blocks', () async { + const text = 'Paragraph'; + final document = createDocument([ + paragraphNode( + text: text, + ), + ]); + await checkTurnInto( + document, + ParagraphBlockKeys.type, + text, + ); + }); + + test('from quote list to another blocks', () async { + const text = 'Quote'; + final document = createDocument([ + quoteNode( + delta: Delta()..insert(text), + ), + ]); + await checkTurnInto( + document, + QuoteBlockKeys.type, + text, + ); + }); + + test('from todo list to another blocks', () async { + const text = 'Todo'; + final document = createDocument([ + todoListNode( + checked: false, + text: text, + ), + ]); + await checkTurnInto( + document, + TodoListBlockKeys.type, + text, + ); + }); + + test('from bulleted list to another blocks', () async { + const text = 'bulleted list'; + final document = createDocument([ + bulletedListNode( + text: text, + ), + ]); + await checkTurnInto( + document, + BulletedListBlockKeys.type, + text, + ); + }); + + test('from numbered list to another blocks', () async { + const text = 'numbered list'; + final document = createDocument([ + numberedListNode( + delta: Delta()..insert(text), + ), + ]); + await checkTurnInto( + document, + NumberedListBlockKeys.type, + text, + ); + }); + + test('from callout to another blocks', () async { + const text = 'callout'; + final document = createDocument([ + calloutNode( + delta: Delta()..insert(text), + ), + ]); + await checkTurnInto( + document, + CalloutBlockKeys.type, + text, + ); + }); + + test('from nested list to heading', () async { + const text = 'bulleted list'; + const nestedText1 = 'nested bulleted list 1'; + const nestedText2 = 'nested bulleted list 2'; + const nestedText3 = 'nested bulleted list 3'; + final document = createDocument([ + bulletedListNode( + text: text, + children: [ + bulletedListNode( + text: nestedText1, + ), + bulletedListNode( + text: nestedText2, + ), + bulletedListNode( + text: nestedText3, + ), + ], + ), + ]); + await checkTurnInto( + document, + BulletedListBlockKeys.type, + text, + toType: HeadingBlockKeys.type, + afterTurnInto: (editorState, node) { + expect(node.type, HeadingBlockKeys.type); + expect(node.children.length, 0); + expect(node.delta!.toPlainText(), text); + + expect(editorState.document.root.children.length, 4); + expect( + editorState.document.root.children[1].type, + BulletedListBlockKeys.type, + ); + expect( + editorState.document.root.children[1].delta!.toPlainText(), + nestedText1, + ); + expect( + editorState.document.root.children[2].type, + BulletedListBlockKeys.type, + ); + expect( + editorState.document.root.children[2].delta!.toPlainText(), + nestedText2, + ); + expect( + editorState.document.root.children[3].type, + BulletedListBlockKeys.type, + ); + expect( + editorState.document.root.children[3].delta!.toPlainText(), + nestedText3, + ); + }, + ); + }); + + test('from numbered list to heading', () async { + const text = 'numbered list'; + const nestedText1 = 'nested numbered list 1'; + const nestedText2 = 'nested numbered list 2'; + const nestedText3 = 'nested numbered list 3'; + final document = createDocument([ + numberedListNode( + delta: Delta()..insert(text), + children: [ + numberedListNode( + delta: Delta()..insert(nestedText1), + ), + numberedListNode( + delta: Delta()..insert(nestedText2), + ), + numberedListNode( + delta: Delta()..insert(nestedText3), + ), + ], + ), + ]); + await checkTurnInto( + document, + NumberedListBlockKeys.type, + text, + toType: HeadingBlockKeys.type, + afterTurnInto: (editorState, node) { + expect(node.type, HeadingBlockKeys.type); + expect(node.children.length, 0); + expect(node.delta!.toPlainText(), text); + + expect(editorState.document.root.children.length, 4); + expect( + editorState.document.root.children[1].type, + NumberedListBlockKeys.type, + ); + expect( + editorState.document.root.children[1].delta!.toPlainText(), + nestedText1, + ); + expect( + editorState.document.root.children[2].type, + NumberedListBlockKeys.type, + ); + expect( + editorState.document.root.children[2].delta!.toPlainText(), + nestedText2, + ); + expect( + editorState.document.root.children[3].type, + NumberedListBlockKeys.type, + ); + expect( + editorState.document.root.children[3].delta!.toPlainText(), + nestedText3, + ); + }, + ); + }); + }); +} diff --git a/frontend/resources/flowy_icons/16x/turninto.svg b/frontend/resources/flowy_icons/16x/turninto.svg new file mode 100644 index 0000000000..3d1b62b697 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/turninto.svg @@ -0,0 +1 @@ +Transfer Line Streamline Icon: https://streamlinehq.com \ No newline at end of file