mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
42cb032bca
commit
15c9a67028
@ -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');
|
||||
}
|
||||
|
@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user