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
This commit is contained in:
Jayaprakash 2024-02-20 08:22:06 +05:30 committed by GitHub
parent 42cb032bca
commit 15c9a67028
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 304 additions and 20 deletions

View File

@ -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,11 +83,86 @@ 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<void> insertOutlineInDocument(WidgetTester tester) async {
);
await tester.pumpAndSettle();
}
Future<void> hoverAndClickDepthOptionAction(
WidgetTester tester,
List<int> 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<void> 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');
}

View File

@ -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<void> 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),
),
);
},
);
}
}

View File

@ -229,6 +229,10 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
ImageBlockKeys.type,
];
final supportDepthBuilderType = [
OutlineBlockKeys.type,
];
final colorAction = [
OptionAction.divider,
OptionAction.color,
@ -239,10 +243,15 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
OptionAction.align,
];
final depthAction = [
OptionAction.depth,
];
final List<OptionAction> actions = [
...standardActions,
if (supportColorBuilderTypes.contains(entry.key)) ...colorAction,
if (supportAlignBuilderType.contains(entry.key)) ...alignAction,
if (supportDepthBuilderType.contains(entry.key)) ...depthAction,
];
if (PlatformExtension.isDesktop) {

View File

@ -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<PopoverAction>(
popoverMutex: PopoverMutex(),
direction:
context.read<AppearanceSettingsCubit>().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);

View File

@ -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<Widget> buildDepthOptions(
BuildContext context,
Future<void> 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<void> 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);

View File

@ -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<PopoverAction>(
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);

View File

@ -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<OutlineBlockWidget>
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<OutlineBlockWidget>
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<OutlineBlockWidget>
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<OutlineBlockWidget>
Iterable<Node> 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,
);
}
}

View File

@ -7,6 +7,7 @@ import 'package:styled_widget/styled_widget.dart';
class PopoverActionList<T extends PopoverAction> extends StatefulWidget {
const PopoverActionList({
super.key,
this.popoverMutex,
required this.actions,
required this.buildChild,
required this.onSelected,
@ -23,6 +24,7 @@ class PopoverActionList<T extends PopoverAction> extends StatefulWidget {
),
});
final PopoverMutex? popoverMutex;
final List<T> actions;
final Widget Function(PopoverController) buildChild;
final Function(T, PopoverController) onSelected;
@ -74,6 +76,7 @@ class _PopoverActionListState<T extends PopoverAction>
);
} else if (action is PopoverActionCell) {
return PopoverActionCellWidget<T>(
popoverMutex: widget.popoverMutex,
popoverController: popoverController,
action: action,
itemHeight: ActionListSizes.itemHeight,
@ -164,11 +167,13 @@ class ActionCellWidget<T extends PopoverAction> extends StatelessWidget {
class PopoverActionCellWidget<T extends PopoverAction> 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<T extends PopoverAction>
final rightIcon =
actionCell.rightIcon(Theme.of(context).colorScheme.onSurface);
return AppFlowyPopover(
mutex: widget.popoverMutex,
controller: popoverController,
asBarrier: true,
popupBuilder: (context) => actionCell.builder(

View File

@ -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;
}

View File

@ -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"
}
}