Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add outline block #2750

Merged
merged 13 commits into from
Jun 29, 2023
3 changes: 3 additions & 0 deletions frontend/appflowy_flutter/assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,9 @@
"createANewCalendar": "Create a new Calendar"
}
},
"selectionMenu": {
"outline": "Outline"
},
"plugins": {
"referencedBoard": "Referenced Board",
"referencedGrid": "Referenced Grid",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

import '../util/ime.dart';
import '../util/util.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

group('outline block test', () {
const location = 'outline_test';

setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});

tearDown(() async {
await TestFolder.cleanTestLocation(null);
});

testWidgets('insert an outline block', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();

await tester.createNewPageWithName(
ViewLayoutPB.Document,
'outline_test',
);

await tester.editor.tapLineOfEditorAt(0);
await insertOutlineInDocument(tester);

// validate the outline is inserted
expect(find.byType(OutlineBlockWidget), findsOneWidget);
});

testWidgets('insert an outline block and check if headings are visible',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();

await tester.createNewPageWithName(
ViewLayoutPB.Document,
'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');

/* Results in:
* # Heading 1
* ## Heading 2
* ### Heading 3
*/

await tester.editor.tapLineOfEditorAt(3);
await insertOutlineInDocument(tester);

expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text('Heading 1'),
),
findsOneWidget,
);

// Heading 2 is prefixed with a bullet
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text('Heading 2'),
),
findsOneWidget,
);

// Heading 3 is prefixed with a dash
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text('Heading 3'),
),
findsOneWidget,
);

// update the Heading 1 to Heading 1Hello world
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('Hello world');
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text('Heading 1Hello world'),
),
findsOneWidget,
);
});
});
}

/// Inserts an outline block in the document
Future<void> insertOutlineInDocument(WidgetTester tester) async {
// open the actions menu and insert the outline block
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_selectionMenu_outline.tr(),
);
await tester.pumpAndSettle();
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ class EditorOperations {

/// Tap the line of editor at [index]
Future<void> tapLineOfEditorAt(int index) async {
final textBlocks = find.byType(TextBlockComponentWidget);
final textBlocks = find.byType(FlowyRichText);
index = index.clamp(0, textBlocks.evaluate().length - 1);
await tester.tapAt(tester.getTopRight(textBlocks.at(index)));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_list.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/database/referenced_database_menu_tem.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
Expand Down Expand Up @@ -65,6 +64,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
codeBlockItem,
emojiMenuItem,
autoGeneratorMenuItem,
outlineItem,
];

late final Map<String, BlockComponentBuilder> blockComponentBuilders =
Expand Down Expand Up @@ -236,6 +236,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(),
SmartEditBlockKeys.type: SmartEditBlockComponentBuilder(),
ToggleListBlockKeys.type: ToggleListBlockComponentBuilder(),
OutlineBlockKeys.type: OutlineBlockComponentBuilder(),
};

final builders = {
Expand All @@ -258,7 +259,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
NumberedListBlockKeys.type,
QuoteBlockKeys.type,
TodoListBlockKeys.type,
CalloutBlockKeys.type
CalloutBlockKeys.type,
OutlineBlockKeys.type,
];

final supportAlignBuilderType = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import 'dart:async';

import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class OutlineBlockKeys {
const OutlineBlockKeys._();

static const String type = 'outline';
static const String backgroundColor = blockComponentBackgroundColor;
}

// defining the callout block menu item for selection
SelectionMenuItem outlineItem = SelectionMenuItem.node(
name: LocaleKeys.document_selectionMenu_outline.tr(),
iconData: Icons.list_alt,
keywords: ['outline', 'table of contents'],
nodeBuilder: (editorState) => outlineBlockNode(),
replace: (_, node) => node.delta?.isEmpty ?? false,
);

Node outlineBlockNode() {
return Node(
type: OutlineBlockKeys.type,
);
}

class OutlineBlockComponentBuilder extends BlockComponentBuilder {
OutlineBlockComponentBuilder({
this.configuration = const BlockComponentConfiguration(),
});

@override
final BlockComponentConfiguration configuration;

@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return OutlineBlockWidget(
key: node.key,
node: node,
configuration: configuration,
showActions: showActions(node),
actionBuilder: (context, state) => actionBuilder(
blockComponentContext,
state,
),
);
}

@override
bool validate(Node node) => node.children.isEmpty;
}

class OutlineBlockWidget extends BlockComponentStatefulWidget {
const OutlineBlockWidget({
super.key,
required super.node,
super.showActions,
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
});

@override
State<OutlineBlockWidget> createState() => _OutlineBlockWidgetState();
}

class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
with BlockComponentConfigurable {
@override
BlockComponentConfiguration get configuration => widget.configuration;

@override
Node get node => widget.node;

// get the background color of the note block from the node's attributes
Color get backgroundColor {
final colorString =
node.attributes[OutlineBlockKeys.backgroundColor] as String?;
if (colorString == null) {
return Colors.transparent;
}
return colorString.toColor();
}

late EditorState editorState = context.read<EditorState>();
late Stream<(TransactionTime, Transaction)> stream =
editorState.transactionStream;

@override
Widget build(BuildContext context) {
return StreamBuilder(
stream: stream,
builder: (context, snapshot) {
if (widget.showActions && widget.actionBuilder != null) {
return BlockComponentActionWrapper(
node: widget.node,
actionBuilder: widget.actionBuilder!,
child: _buildOutlineBlock(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
child: _buildOutlineBlock(),
child: child,

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not advisable to use 'child' here. The widget will not be updated within the builder if 'child' is returned; instead, we should rebuild the widget.

The below code is wrong too. It should return _buildOutlineBlock() directly, not the child.

);
}
return _buildOutlineBlock();
},
);
}

Widget _buildOutlineBlock() {
final children = getHeadingNodes()
.map(
(e) => Container(
padding: const EdgeInsets.only(
bottom: 4.0,
),
width: double.infinity,
child: OutlineItemWidget(node: e),
),
)
.toList();
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: backgroundColor,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: children,
),
);
}

Iterable<Node> getHeadingNodes() {
final children = editorState.document.root.children;
return children.where((element) => element.type == HeadingBlockKeys.type);
}
}

class OutlineItemWidget extends StatelessWidget {
OutlineItemWidget({
super.key,
required this.node,
}) {
assert(node.type == HeadingBlockKeys.type);
}

final Node node;

@override
Widget build(BuildContext context) {
final editorState = context.read<EditorState>();
final textStyle = editorState.editorStyle.textStyleConfiguration;
final style = textStyle.href.combine(textStyle.text);
return FlowyHover(
style: HoverStyle(
hoverColor: Colors.grey.withOpacity(0.2), // TODO: use theme color.
),
child: GestureDetector(
onTap: () => updateBlockSelection(context),
child: Container(
padding: EdgeInsets.only(left: node.leftIndent),
child: Text(
node.outlineItemText,
style: style,
),
),
),
);
}

void updateBlockSelection(BuildContext context) {
final editorState = context.read<EditorState>();
editorState.selectionType = SelectionType.block;
editorState.selection = Selection.collapse(
node.path,
node.delta?.length ?? 0,
);
editorState.selectionType = null;
}
}

extension on Node {
double get leftIndent {
assert(type != HeadingBlockKeys.type);
if (type != HeadingBlockKeys.type) {
return 0.0;
}
final level = attributes[HeadingBlockKeys.level];
if (level == 2) {
return 20;
} else if (level == 3) {
return 40;
}
return 0;
}

String get outlineItemText {
return delta?.toPlainText() ?? '';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ export 'header/custom_cover_picker.dart';
export 'emoji_picker/emoji_menu_item.dart';
export 'extensions/flowy_tint_extension.dart';
export 'database/inline_database_menu_item.dart';
export 'database/referenced_database_menu_item.dart';
export 'database/database_view_block_component.dart';
export 'math_equation/math_equation_block_component.dart';
export 'openai/widgets/auto_completion_node_widget.dart';
export 'openai/widgets/smart_edit_node_widget.dart';
export 'openai/widgets/smart_edit_toolbar_item.dart';
export 'toggle/toggle_block_component.dart';
export 'toggle/toggle_block_shortcut_event.dart';
export 'outline/outline_block_component.dart';