Browse Source

feat: add outline block (#2750)

Aman Negi 1 year ago
parent
commit
95d4fb6865

+ 3 - 0
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",

+ 114 - 0
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<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();
+}

+ 2 - 1
frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart

@@ -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)));
     await tester.pumpAndSettle();
   }

+ 4 - 2
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/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
@@ -68,6 +67,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
     codeBlockItem,
     emojiMenuItem,
     autoGeneratorMenuItem,
+    outlineItem,
   ];
 
   late final Map<String, BlockComponentBuilder> blockComponentBuilders =
@@ -255,6 +255,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
       AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(),
       SmartEditBlockKeys.type: SmartEditBlockComponentBuilder(),
       ToggleListBlockKeys.type: ToggleListBlockComponentBuilder(),
+      OutlineBlockKeys.type: OutlineBlockComponentBuilder(),
     };
 
     final builders = {
@@ -277,7 +278,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
         NumberedListBlockKeys.type,
         QuoteBlockKeys.type,
         TodoListBlockKeys.type,
-        CalloutBlockKeys.type
+        CalloutBlockKeys.type,
+        OutlineBlockKeys.type,
       ];
 
       final supportAlignBuilderType = [

+ 0 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/referenced_database_menu_tem.dart → frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/database/referenced_database_menu_item.dart


+ 203 - 0
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<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(),
+          );
+        }
+        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() ?? '';
+  }
+}

+ 2 - 0
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';