From 15c9a67028ea1bae233cad053d1377c97ee20a64 Mon Sep 17 00:00:00 2001 From: Jayaprakash Date: Tue, 20 Feb 2024 08:22:06 +0530 Subject: [PATCH] feat: implement outline block component depth control (#4642) * feat: add support to control the depth of outline block component * feat: update localization keys * feat: add depth option to `BlockOptionButton` * feat: retrive outline block heading components upto the depth level * feat: add depth option config to editor configuration * test: outline block depth control * feat: add outline block placeholder * refactor: remove redundant codes * ci: trigger github actions * chore: refactor `OptionDepthType` enum * fix: flutter ci error * refactor: removed `finalHeadingLevel` from outline keys * fix: flutter ci error --- .../document_with_outline_block_test.dart | 118 ++++++++++++++++-- .../util/editor_test_operations.dart | 22 ++++ .../presentation/editor_configuration.dart | 9 ++ .../actions/block_action_option_button.dart | 4 + .../editor_plugins/actions/option_action.dart | 115 ++++++++++++++++- .../actions/option_action_button.dart | 6 + .../outline/outline_block_component.dart | 30 ++++- .../presentation/widgets/pop_up_action.dart | 6 + .../packages/flowy_infra/lib/language.dart | 6 +- frontend/resources/translations/en.json | 8 +- 10 files changed, 304 insertions(+), 20 deletions(-) diff --git a/frontend/appflowy_flutter/integration_test/document/document_with_outline_block_test.dart b/frontend/appflowy_flutter/integration_test/document/document_with_outline_block_test.dart index 6f8e027b6222f..f45eafbad6c02 100644 --- a/frontend/appflowy_flutter/integration_test/document/document_with_outline_block_test.dart +++ b/frontend/appflowy_flutter/integration_test/document/document_with_outline_block_test.dart @@ -1,11 +1,18 @@ 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'; import '../util/util.dart'; +const String heading1 = "Heading 1"; +const String heading2 = "Heading 2"; +const String heading3 = "Heading 3"; + void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -33,12 +40,8 @@ void main() { await tester.createNewPageWithNameUnderParent( name: 'outline_test', ); - await tester.editor.tapLineOfEditorAt(0); - - await tester.ime.insertText('# Heading 1\n'); - await tester.ime.insertText('## Heading 2\n'); - await tester.ime.insertText('### Heading 3\n'); + await insertHeadingComponent(tester); /* Results in: * # Heading 1 * ## Heading 2 @@ -51,7 +54,7 @@ void main() { expect( find.descendant( of: find.byType(OutlineBlockWidget), - matching: find.text('Heading 1'), + matching: find.text(heading1), ), findsOneWidget, ); @@ -60,7 +63,7 @@ void main() { expect( find.descendant( of: find.byType(OutlineBlockWidget), - matching: find.text('Heading 2'), + matching: find.text(heading2), ), findsOneWidget, ); @@ -69,7 +72,7 @@ void main() { expect( find.descendant( of: find.byType(OutlineBlockWidget), - matching: find.text('Heading 3'), + matching: find.text(heading3), ), findsOneWidget, ); @@ -80,10 +83,85 @@ void main() { expect( find.descendant( of: find.byType(OutlineBlockWidget), - matching: find.text('Heading 1Hello world'), + matching: find.text('${heading1}Hello world'), + ), + findsOneWidget, + ); + }); + + testWidgets("control the depth of outline block", (tester) async { + await tester.initializeAppFlowy(); + await tester.tapGoButton(); + + await tester.createNewPageWithNameUnderParent( + name: 'outline_test', + ); + + await insertHeadingComponent(tester); + /* Results in: + * # Heading 1 + * ## Heading 2 + * ### Heading 3 + */ + + await tester.editor.tapLineOfEditorAt(3); + await insertOutlineInDocument(tester); + + // expect to find only the `heading1` widget under the [OutlineBlockWidget] + await hoverAndClickDepthOptionAction(tester, [3], 1); + expect( + find.descendant( + of: find.byType(OutlineBlockWidget), + matching: find.text(heading2), + ), + findsNothing, + ); + expect( + find.descendant( + of: find.byType(OutlineBlockWidget), + matching: find.text(heading3), + ), + findsNothing, + ); + ////// + + /// expect to find only the 'heading1' and 'heading2' under the [OutlineBlockWidget] + await hoverAndClickDepthOptionAction(tester, [3], 2); + expect( + find.descendant( + of: find.byType(OutlineBlockWidget), + matching: find.text(heading3), + ), + findsNothing, + ); + ////// + + // expect to find all the headings under the [OutlineBlockWidget] + await hoverAndClickDepthOptionAction(tester, [3], 3); + expect( + find.descendant( + of: find.byType(OutlineBlockWidget), + matching: find.text(heading1), ), findsOneWidget, ); + + expect( + find.descendant( + of: find.byType(OutlineBlockWidget), + matching: find.text(heading2), + ), + findsOneWidget, + ); + + expect( + find.descendant( + of: find.byType(OutlineBlockWidget), + matching: find.text(heading3), + ), + findsOneWidget, + ); + ////// }); }); } @@ -97,3 +175,25 @@ Future insertOutlineInDocument(WidgetTester tester) async { ); await tester.pumpAndSettle(); } + +Future hoverAndClickDepthOptionAction( + WidgetTester tester, + 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.pumpAndSettle(); +} + +Future insertHeadingComponent(WidgetTester tester) async { + await tester.editor.tapLineOfEditorAt(0); + await tester.ime.insertText('# $heading1\n'); + await tester.ime.insertText('## $heading2\n'); + await tester.ime.insertText('### $heading3\n'); +} diff --git a/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart b/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart index 4267f43f9bb96..64dbd1746e535 100644 --- a/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart +++ b/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart @@ -6,6 +6,7 @@ import 'package:appflowy/plugins/base/emoji/emoji_picker.dart'; import 'package:appflowy/plugins/base/emoji/emoji_skin_tone.dart'; import 'package:appflowy/plugins/base/icon/icon_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/header/cover_editor.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart'; @@ -241,4 +242,25 @@ class EditorOperations { }, ); } + + /// hover and click on the option menu button beside the block component. + Future hoverAndClickOptionMenuButton(Path path) async { + final optionMenuButton = find.byWidgetPredicate( + (widget) => + widget is BlockComponentActionWrapper && + widget.node.path.equals(path), + ); + await tester.hoverOnWidget( + optionMenuButton, + onHover: () async { + await tester.tapButton( + find.byWidgetPredicate( + (widget) => + widget is BlockOptionButton && + widget.blockComponentContext.node.path.equals(path), + ), + ); + }, + ); + } } 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 087618d0444e6..7ecc4088d3fbf 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_configuration.dart @@ -229,6 +229,10 @@ Map getEditorBuilderMap({ ImageBlockKeys.type, ]; + final supportDepthBuilderType = [ + OutlineBlockKeys.type, + ]; + final colorAction = [ OptionAction.divider, OptionAction.color, @@ -239,10 +243,15 @@ Map getEditorBuilderMap({ OptionAction.align, ]; + final depthAction = [ + OptionAction.depth, + ]; + final List actions = [ ...standardActions, if (supportColorBuilderTypes.contains(entry.key)) ...colorAction, if (supportAlignBuilderType.contains(entry.key)) ...alignAction, + if (supportDepthBuilderType.contains(entry.key)) ...depthAction, ]; if (PlatformExtension.isDesktop) { 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 bc2a04e96d9c5..a5617a55586ff 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 @@ -34,12 +34,15 @@ class BlockOptionButton extends StatelessWidget { return ColorOptionAction(editorState: editorState); case OptionAction.align: return AlignOptionAction(editorState: editorState); + case OptionAction.depth: + return DepthOptionAction(editorState: editorState); default: return OptionActionWrapper(e); } }).toList(); return PopoverActionList( + popoverMutex: PopoverMutex(), direction: context.read().state.layoutDirection == LayoutDirection.rtlLayout @@ -136,6 +139,7 @@ class BlockOptionButton extends StatelessWidget { case OptionAction.align: case OptionAction.color: case OptionAction.divider: + case OptionAction.depth: throw UnimplementedError(); } editorState.apply(transaction); 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 index 9de7d1648301c..dd5dc93f5bc77 100644 --- 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 @@ -22,7 +22,8 @@ enum OptionAction { /// callout background color color, divider, - align; + align, + depth; FlowySvgData get svg { switch (this) { @@ -41,7 +42,9 @@ enum OptionAction { case OptionAction.divider: return const FlowySvgData('editor/divider'); case OptionAction.align: - return FlowySvgs.align_center_s; + return FlowySvgs.m_aa_bulleted_list_s; + case OptionAction.depth: + return FlowySvgs.tag_s; } } @@ -61,6 +64,8 @@ enum OptionAction { 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.divider: throw UnsupportedError('Divider does not have description'); } @@ -108,6 +113,29 @@ enum OptionAlignType { } } +enum OptionDepthType { + h1(1, "H1"), + h2(2, "H2"), + h3(3, "H3"); + + 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) { @@ -285,6 +313,89 @@ class ColorOptionAction extends PopoverActionCell { }; } +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, + leftIcon: null, + name: e.name, + rightIcon: null, + ), + ) + .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); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option_action_button.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option_action_button.dart index bf8dfdde43185..15e2610bdcd99 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option_action_button.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/actions/option_action_button.dart @@ -29,12 +29,17 @@ class OptionActionList extends StatelessWidget { return ColorOptionAction( editorState: editorState, ); + } else if (e == OptionAction.depth) { + return DepthOptionAction( + editorState: editorState, + ); } else { return OptionActionWrapper(e); } }).toList(); return PopoverActionList( + popoverMutex: PopoverMutex(), direction: PopoverDirection.leftWithCenterAligned, actions: popoverActions, onPopupBuilder: () => blockComponentState.alwaysShowActions = true, @@ -105,6 +110,7 @@ class OptionActionList extends StatelessWidget { case OptionAction.align: case OptionAction.color: case OptionAction.divider: + case OptionAction.depth: throw UnimplementedError(); } editorState.apply(transaction); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart index 1aa48ade9acf0..dd271326d6a4e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart @@ -14,6 +14,7 @@ class OutlineBlockKeys { static const String type = 'outline'; static const String backgroundColor = blockComponentBackgroundColor; + static const String depth = 'depth'; } // defining the callout block menu item for selection @@ -73,6 +74,9 @@ class _OutlineBlockWidgetState extends State BlockComponentConfigurable, BlockComponentTextDirectionMixin, BlockComponentBackgroundColorMixin { + // Change the value if the heading block type supports heading levels greater than '3' + static const finalHeadingLevel = 3; + @override BlockComponentConfiguration get configuration => widget.configuration; @@ -141,7 +145,11 @@ class _OutlineBlockWidgetState extends State style: configuration.placeholderTextStyle(node), ), ) - : DecoratedBox( + : Container( + padding: const EdgeInsets.symmetric( + vertical: 2.0, + horizontal: 5.0, + ), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(8.0)), color: backgroundColor, @@ -151,7 +159,19 @@ class _OutlineBlockWidgetState extends State crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, textDirection: textDirection, - children: children, + children: [ + Text( + LocaleKeys.document_outlineBlock_placeholder.tr(), + style: Theme.of(context).textTheme.titleLarge, + ), + const VSpace(8.0), + Padding( + padding: const EdgeInsets.only(left: 15.0), + child: Column( + children: children, + ), + ), + ], ), ); @@ -166,10 +186,14 @@ class _OutlineBlockWidgetState extends State Iterable getHeadingNodes() { final children = editorState.document.root.children; + final int level = + node.attributes[OutlineBlockKeys.depth] ?? finalHeadingLevel; + return children.where( (element) => element.type == HeadingBlockKeys.type && - element.delta?.isNotEmpty == true, + element.delta?.isNotEmpty == true && + element.attributes[HeadingBlockKeys.level] <= level, ); } } 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 be91eb7c91625..83a42249d5be9 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 @@ -7,6 +7,7 @@ import 'package:styled_widget/styled_widget.dart'; class PopoverActionList extends StatefulWidget { const PopoverActionList({ super.key, + this.popoverMutex, required this.actions, required this.buildChild, required this.onSelected, @@ -23,6 +24,7 @@ class PopoverActionList extends StatefulWidget { ), }); + final PopoverMutex? popoverMutex; final List actions; final Widget Function(PopoverController) buildChild; final Function(T, PopoverController) onSelected; @@ -74,6 +76,7 @@ class _PopoverActionListState ); } else if (action is PopoverActionCell) { return PopoverActionCellWidget( + popoverMutex: widget.popoverMutex, popoverController: popoverController, action: action, itemHeight: ActionListSizes.itemHeight, @@ -164,11 +167,13 @@ class ActionCellWidget extends StatelessWidget { class PopoverActionCellWidget extends StatefulWidget { const PopoverActionCellWidget({ super.key, + this.popoverMutex, required this.popoverController, required this.action, required this.itemHeight, }); + final PopoverMutex? popoverMutex; final T action; final double itemHeight; @@ -190,6 +195,7 @@ class _PopoverActionCellWidgetState final rightIcon = actionCell.rightIcon(Theme.of(context).colorScheme.onSurface); return AppFlowyPopover( + mutex: widget.popoverMutex, controller: popoverController, asBarrier: true, popupBuilder: (context) => actionCell.builder( diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart index 2d21a67dc8a84..2a80f770ba792 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/language.dart @@ -74,9 +74,7 @@ String languageFromLocale(Locale locale) { return "اردو"; case "hin": return "हिन्दी"; - - // If not found then the language code will be displayed - default: - return locale.languageCode; } + // If not found then the language code will be displayed + return locale.languageCode; } diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 4b5c29da69a63..7dc6b3918b660 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -779,7 +779,8 @@ "left": "Left", "center": "Center", "right": "Right", - "defaultColor": "Default" + "defaultColor": "Default", + "depth": "Depth" }, "image": { "copiedToPasteBoard": "The image link has been copied to the clipboard", @@ -816,6 +817,9 @@ "date": "Date", "emoji": "Emoji" }, + "outlineBlock": { + "placeholder": "Table of Contents" + }, "textBlock": { "placeholder": "Type '/' for commands" }, @@ -1276,4 +1280,4 @@ "userIcon": "User icon" }, "noLogFiles": "There're no log files" -} +} \ No newline at end of file