diff --git a/frontend/appflowy_flutter/assets/translations/en.json b/frontend/appflowy_flutter/assets/translations/en.json index cb1d2ee285735..e14c12d79e5ec 100644 --- a/frontend/appflowy_flutter/assets/translations/en.json +++ b/frontend/appflowy_flutter/assets/translations/en.json @@ -385,6 +385,9 @@ "createANewCalendar": "Create a new Calendar" } }, + "selectionMenu": { + "outline": "Outline" + }, "plugins": { "referencedBoard": "Referenced Board", "referencedGrid": "Referenced Grid", diff --git a/frontend/appflowy_flutter/integration_test/plugins/outline_block_test.dart b/frontend/appflowy_flutter/integration_test/plugins/outline_block_test.dart new file mode 100644 index 0000000000000..f9d66328a81c9 --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/plugins/outline_block_test.dart @@ -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 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(); +} 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 9d2c14abb506f..2068571541ff6 100644 --- a/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart +++ b/frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart @@ -30,7 +30,8 @@ class EditorOperations { /// Tap the line of editor at [index] Future 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))); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index f92918b464589..a796831a200f5 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -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'; @@ -65,6 +64,7 @@ class _AppFlowyEditorPageState extends State { codeBlockItem, emojiMenuItem, autoGeneratorMenuItem, + outlineItem, ]; late final Map blockComponentBuilders = @@ -236,6 +236,7 @@ class _AppFlowyEditorPageState extends State { AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(), SmartEditBlockKeys.type: SmartEditBlockComponentBuilder(), ToggleListBlockKeys.type: ToggleListBlockComponentBuilder(), + OutlineBlockKeys.type: OutlineBlockComponentBuilder(), }; final builders = { @@ -258,7 +259,8 @@ class _AppFlowyEditorPageState extends State { NumberedListBlockKeys.type, QuoteBlockKeys.type, TodoListBlockKeys.type, - CalloutBlockKeys.type + CalloutBlockKeys.type, + OutlineBlockKeys.type, ]; final supportAlignBuilderType = [ diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/referenced_database_menu_tem.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/referenced_database_menu_item.dart similarity index 100% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/referenced_database_menu_tem.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/referenced_database_menu_item.dart 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 new file mode 100644 index 0000000000000..01b871c488ca5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart @@ -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 createState() => _OutlineBlockWidgetState(); +} + +class _OutlineBlockWidgetState extends State + 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(); + 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(), + ); + } + 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 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(); + 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.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() ?? ''; + } +} 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 647ecdc032e09..f7fd337747bf0 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 @@ -7,6 +7,7 @@ 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'; @@ -14,3 +15,4 @@ 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';