Przeglądaj źródła

feat: support toggle list (#3016)

Lucas.Xu 1 rok temu
rodzic
commit
2da37122e4

+ 2 - 0
frontend/appflowy_flutter/integration_test/document/document_test_runner.dart

@@ -7,6 +7,7 @@ import 'document_with_database_test.dart' as document_with_database_test;
 import 'document_with_inline_math_equation_test.dart'
     as document_with_inline_math_equation_test;
 import 'document_with_inline_page_test.dart' as document_with_inline_page_test;
+import 'document_with_toggle_list_test.dart' as document_with_toggle_list_test;
 import 'edit_document_test.dart' as document_edit_test;
 
 void startTesting() {
@@ -19,4 +20,5 @@ void startTesting() {
   document_with_inline_page_test.main();
   document_with_inline_math_equation_test.main();
   document_with_cover_image_test.main();
+  document_with_toggle_list_test.main();
 }

+ 237 - 0
frontend/appflowy_flutter/integration_test/document/document_with_toggle_list_test.dart

@@ -0,0 +1,237 @@
+import 'dart:io';
+
+import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.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();
+  TestWidgetsFlutterBinding.ensureInitialized();
+
+  group('toggle list in document', () {
+    void expectToggleListOpened() {
+      expect(find.byIcon(Icons.arrow_drop_down), findsOneWidget);
+      expect(find.byIcon(Icons.arrow_right), findsNothing);
+    }
+
+    void expectToggleListClosed() {
+      expect(find.byIcon(Icons.arrow_drop_down), findsNothing);
+      expect(find.byIcon(Icons.arrow_right), findsOneWidget);
+    }
+
+    testWidgets('convert > to toggle list, and click the icon to close it',
+        (tester) async {
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      // create a new document
+      await tester.createNewPageWithName(
+        ViewLayoutPB.Document,
+      );
+
+      // tap the first line of the document
+      await tester.editor.tapLineOfEditorAt(0);
+      // insert a toggle list
+      const text = 'This is a toggle list sample';
+      await tester.ime.insertText('> $text');
+      await tester.editor.updateSelection(
+        Selection.single(
+          path: [0],
+          startOffset: 0,
+          endOffset: text.length,
+        ),
+      );
+
+      final editorState = tester.editor.getCurrentEditorState();
+      final toggleList = editorState.document.nodeAtPath([0])!;
+      expect(
+        toggleList.type,
+        ToggleListBlockKeys.type,
+      );
+      expect(
+        toggleList.attributes[ToggleListBlockKeys.collapsed],
+        false,
+      );
+      expect(
+        toggleList.delta!.toPlainText(),
+        text,
+      );
+
+      // Press the arrow down key to move the cursor to the next line
+      await tester.simulateKeyEvent(
+        LogicalKeyboardKey.arrowDown,
+      );
+      const text2 = 'This is a child node';
+      await tester.ime.insertText(text2);
+      expect(find.text(text2, findRichText: true), findsOneWidget);
+
+      // Click the toggle list icon to close it
+      final toggleListIcon = find.byIcon(Icons.arrow_drop_down);
+      await tester.tapButton(toggleListIcon);
+
+      // expect the toggle list to be closed
+      expect(find.text(text2, findRichText: true), findsNothing);
+    });
+
+    testWidgets('press enter key when the toggle list is closed',
+        (tester) async {
+      // if the toggle list is closed, press enter key will insert a new toggle list after it
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      // create a new document
+      await tester.createNewPageWithName(
+        ViewLayoutPB.Document,
+      );
+
+      // tap the first line of the document
+      await tester.editor.tapLineOfEditorAt(0);
+      // insert a toggle list
+      const text = 'Hello AppFlowy';
+      await tester.ime.insertText('> $text');
+
+      // Click the toggle list icon to close it
+      final toggleListIcon = find.byIcon(Icons.arrow_drop_down);
+      await tester.tapButton(toggleListIcon);
+
+      // Press the enter key
+      await tester.editor.updateSelection(
+        Selection.collapse(
+          [0],
+          'Hello '.length,
+        ),
+      );
+      await tester.ime.insertCharacter('\n');
+
+      final editorState = tester.editor.getCurrentEditorState();
+      final node0 = editorState.getNodeAtPath([0])!;
+      final node1 = editorState.getNodeAtPath([1])!;
+
+      expect(node0.type, ToggleListBlockKeys.type);
+      expect(node0.attributes[ToggleListBlockKeys.collapsed], true);
+      expect(node0.delta!.toPlainText(), 'Hello ');
+      expect(node1.type, ToggleListBlockKeys.type);
+      expect(node1.delta!.toPlainText(), 'AppFlowy');
+    });
+
+    testWidgets('press enter key when the toggle list is open', (tester) async {
+      // if the toggle list is open, press enter key will insert a new paragraph inside it
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      // create a new document
+      await tester.createNewPageWithName(
+        ViewLayoutPB.Document,
+      );
+
+      // tap the first line of the document
+      await tester.editor.tapLineOfEditorAt(0);
+      // insert a toggle list
+      const text = 'Hello AppFlowy';
+      await tester.ime.insertText('> $text');
+
+      // Press the enter key
+      await tester.editor.updateSelection(
+        Selection.collapse(
+          [0],
+          'Hello '.length,
+        ),
+      );
+      await tester.ime.insertCharacter('\n');
+
+      final editorState = tester.editor.getCurrentEditorState();
+      final node0 = editorState.getNodeAtPath([0])!;
+      final node00 = editorState.getNodeAtPath([0, 0])!;
+      final node1 = editorState.getNodeAtPath([1]);
+
+      expect(node0.type, ToggleListBlockKeys.type);
+      expect(node0.attributes[ToggleListBlockKeys.collapsed], false);
+      expect(node0.delta!.toPlainText(), 'Hello ');
+      expect(node00.type, ParagraphBlockKeys.type);
+      expect(node00.delta!.toPlainText(), 'AppFlowy');
+      expect(node1, isNull);
+    });
+
+    testWidgets('clear the format if toggle list if empty', (tester) async {
+      // if the toggle list is open, press enter key will insert a new paragraph inside it
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      // create a new document
+      await tester.createNewPageWithName(
+        ViewLayoutPB.Document,
+      );
+
+      // tap the first line of the document
+      await tester.editor.tapLineOfEditorAt(0);
+      // insert a toggle list
+      await tester.ime.insertText('> ');
+
+      // Press the enter key
+      // Click the toggle list icon to close it
+      final toggleListIcon = find.byIcon(Icons.arrow_drop_down);
+      await tester.tapButton(toggleListIcon);
+
+      await tester.editor.updateSelection(
+        Selection.collapse(
+          [0],
+          0,
+        ),
+      );
+      await tester.ime.insertCharacter('\n');
+
+      final editorState = tester.editor.getCurrentEditorState();
+      final node0 = editorState.getNodeAtPath([0])!;
+
+      expect(node0.type, ParagraphBlockKeys.type);
+    });
+
+    testWidgets('use cmd/ctrl + enter to open/close the toggle list',
+        (tester) async {
+      // if the toggle list is open, press enter key will insert a new paragraph inside it
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      // create a new document
+      await tester.createNewPageWithName(
+        ViewLayoutPB.Document,
+      );
+
+      // tap the first line of the document
+      await tester.editor.tapLineOfEditorAt(0);
+      // insert a toggle list
+      await tester.ime.insertText('> Hello');
+
+      expectToggleListOpened();
+
+      await tester.editor.updateSelection(
+        Selection.collapse(
+          [0],
+          0,
+        ),
+      );
+      await tester.simulateKeyEvent(
+        LogicalKeyboardKey.enter,
+        isMetaPressed: Platform.isMacOS,
+        isControlPressed: Platform.isLinux || Platform.isWindows,
+      );
+
+      expectToggleListClosed();
+
+      await tester.simulateKeyEvent(
+        LogicalKeyboardKey.enter,
+        isMetaPressed: Platform.isMacOS,
+        isControlPressed: Platform.isLinux || Platform.isWindows,
+      );
+
+      expectToggleListOpened();
+    });
+  });
+}

+ 6 - 2
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart

@@ -37,6 +37,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
   final inlinePageReferenceService = InlinePageReferenceService();
 
   final List<CommandShortcutEvent> commandShortcutEvents = [
+    toggleToggleListCommand,
     ...codeBlockCommands,
     ...standardCommandShortcutEvents,
   ];
@@ -68,7 +69,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
         ...codeBlockCharacterEvents,
 
         // toggle list
-        // formatGreaterToToggleList,
+        formatGreaterToToggleList,
+        insertChildNodeInsideToggleList,
 
         // customize the slash menu command
         customSlashCommand(
@@ -107,6 +109,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
   void initState() {
     super.initState();
 
+    indentableBlockTypes.add(ToggleListBlockKeys.type);
     slashMenuItems = _customSlashMenuItems();
 
     effectiveScrollController = widget.scrollController ?? ScrollController();
@@ -286,6 +289,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
         TodoListBlockKeys.type,
         CalloutBlockKeys.type,
         OutlineBlockKeys.type,
+        ToggleListBlockKeys.type,
       ];
 
       final supportAlignBuilderType = [
@@ -313,7 +317,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
         final top = builder.configuration.padding(context.node).top;
         final padding = context.node.type == HeadingBlockKeys.type
             ? EdgeInsets.only(top: top + 8.0)
-            : EdgeInsets.only(top: top);
+            : EdgeInsets.only(top: top + 2.0);
         return Padding(
           padding: padding,
           child: BlockActionList(

+ 68 - 54
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart

@@ -1,7 +1,6 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flowy_infra_ui/flowy_infra_ui.dart';
 import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
 
 class ToggleListBlockKeys {
   const ToggleListBlockKeys._();
@@ -11,24 +10,35 @@ class ToggleListBlockKeys {
   /// The content of a code block.
   ///
   /// The value is a String.
-  static const String delta = 'delta';
+  static const String delta = blockComponentDelta;
+
+  static const String backgroundColor = blockComponentBackgroundColor;
+
+  static const String textDirection = blockComponentTextDirection;
 
   /// The value is a bool.
   static const String collapsed = 'collapsed';
 }
 
 Node toggleListBlockNode({
+  String? text,
   Delta? delta,
   bool collapsed = false,
+  String? textDirection,
+  Attributes? attributes,
+  Iterable<Node>? children,
 }) {
-  final attributes = {
-    ToggleListBlockKeys.delta: (delta ?? Delta()).toJson(),
-    ToggleListBlockKeys.collapsed: collapsed,
-  };
   return Node(
     type: ToggleListBlockKeys.type,
-    attributes: attributes,
-    children: [paragraphNode()],
+    attributes: {
+      ToggleListBlockKeys.collapsed: collapsed,
+      ToggleListBlockKeys.delta:
+          (delta ?? (Delta()..insert(text ?? ''))).toJson(),
+      if (attributes != null) ...attributes,
+      if (textDirection != null)
+        ToggleListBlockKeys.textDirection: textDirection,
+    },
+    children: children ?? [paragraphNode()],
   );
 }
 
@@ -86,7 +96,9 @@ class _ToggleListBlockComponentWidgetState
         SelectableMixin,
         DefaultSelectableMixin,
         BlockComponentConfigurable,
-        BlockComponentBackgroundColorMixin {
+        BlockComponentBackgroundColorMixin,
+        NestedBlockComponentStatefulWidgetMixin,
+        BlockComponentTextDirectionMixin {
   // the key used to forward focus to the richtext child
   @override
   final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text');
@@ -105,63 +117,65 @@ class _ToggleListBlockComponentWidgetState
   @override
   Node get node => widget.node;
 
-  bool get collapsed => node.attributes[ToggleListBlockKeys.collapsed] ?? false;
+  @override
+  EdgeInsets get indentPadding => configuration.indentPadding(
+        node,
+        calculateTextDirection(
+          defaultTextDirection: Directionality.maybeOf(context),
+        ),
+      );
 
-  late final editorState = context.read<EditorState>();
+  bool get collapsed => node.attributes[ToggleListBlockKeys.collapsed] ?? false;
 
   @override
   Widget build(BuildContext context) {
     return collapsed
-        ? buildToggleListBlockComponent(context)
-        : buildToggleListBlockComponentWithChildren(context);
+        ? buildComponent(context)
+        : buildComponentWithChildren(context);
   }
 
-  Widget buildToggleListBlockComponentWithChildren(BuildContext context) {
-    return Container(
-      color: backgroundColor,
-      child: NestedListWidget(
-        children: editorState.renderer.buildList(
-          context,
-          widget.node.children,
-        ),
-        child: buildToggleListBlockComponent(context),
-      ),
+  @override
+  Widget buildComponent(BuildContext context) {
+    final textDirection = calculateTextDirection(
+      defaultTextDirection: Directionality.maybeOf(context),
     );
-  }
 
-  // build the richtext child
-  Widget buildToggleListBlockComponent(BuildContext context) {
-    Widget child = Row(
-      crossAxisAlignment: CrossAxisAlignment.start,
-      children: [
-        // the emoji picker button for the note
-        FlowyIconButton(
-          width: 24.0,
-          icon: Icon(
-            collapsed ? Icons.arrow_right : Icons.arrow_drop_down,
-          ),
-          onPressed: onCollapsed,
-        ),
-        const SizedBox(
-          width: 4.0,
-        ),
-        Expanded(
-          child: AppFlowyRichText(
-            key: forwardKey,
-            node: widget.node,
-            editorState: editorState,
-            placeholderText: placeholderText,
-            lineHeight: 1.5,
-            textSpanDecorator: (textSpan) => textSpan.updateTextStyle(
-              textStyle,
+    Widget child = Container(
+      color: backgroundColor,
+      width: double.infinity,
+      child: Row(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          // the emoji picker button for the note
+          FlowyIconButton(
+            width: 24.0,
+            icon: Icon(
+              collapsed ? Icons.arrow_right : Icons.arrow_drop_down,
             ),
-            placeholderTextSpanDecorator: (textSpan) =>
-                textSpan.updateTextStyle(
-              placeholderTextStyle,
+            onPressed: onCollapsed,
+          ),
+          const SizedBox(
+            width: 4.0,
+          ),
+          Expanded(
+            child: AppFlowyRichText(
+              key: forwardKey,
+              node: widget.node,
+              editorState: editorState,
+              placeholderText: placeholderText,
+              lineHeight: 1.5,
+              textSpanDecorator: (textSpan) => textSpan.updateTextStyle(
+                textStyle,
+              ),
+              placeholderTextSpanDecorator: (textSpan) =>
+                  textSpan.updateTextStyle(
+                placeholderTextStyle,
+              ),
+              textDirection: textDirection,
             ),
           ),
-        ),
-      ],
+        ],
+      ),
     );
 
     child = Padding(

+ 105 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/toggle/toggle_block_shortcut_event.dart

@@ -1,5 +1,6 @@
 import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter/material.dart';
 
 const _greater = '>';
 
@@ -22,3 +23,107 @@ CharacterShortcutEvent formatGreaterToToggleList = CharacterShortcutEvent(
     ),
   ),
 );
+
+/// Press enter key to insert child node inside the toggle list
+///
+/// - support
+///   - desktop
+///   - mobile
+///   - web
+CharacterShortcutEvent insertChildNodeInsideToggleList = CharacterShortcutEvent(
+  key: 'insert child node inside toggle list',
+  character: '\n',
+  handler: (editorState) async {
+    final selection = editorState.selection;
+    if (selection == null || !selection.isCollapsed) {
+      return false;
+    }
+    final node = editorState.getNodeAtPath(selection.start.path);
+    final delta = node?.delta;
+    if (node == null ||
+        node.type != ToggleListBlockKeys.type ||
+        delta == null) {
+      return false;
+    }
+    final slicedDelta = delta.slice(selection.start.offset);
+    final transaction = editorState.transaction;
+    final collapsed = node.attributes[ToggleListBlockKeys.collapsed] as bool;
+    if (collapsed) {
+      // if the delta is empty, clear the format
+      if (delta.isEmpty) {
+        transaction
+          ..insertNode(
+            selection.start.path.next,
+            paragraphNode(),
+          )
+          ..deleteNode(node)
+          ..afterSelection = Selection.collapse(selection.start.path, 0);
+      } else {
+        // insert a toggle list block below the current toggle list block
+        transaction
+          ..deleteText(node, selection.startIndex, slicedDelta.length)
+          ..insertNode(
+            selection.start.path.next,
+            toggleListBlockNode(collapsed: true, delta: slicedDelta),
+          )
+          ..afterSelection = Selection.collapse(selection.start.path.next, 0);
+      }
+    } else {
+      // insert a paragraph block inside the current toggle list block
+      transaction
+        ..deleteText(node, selection.startIndex, slicedDelta.length)
+        ..insertNode(
+          selection.start.path + [0],
+          paragraphNode(delta: slicedDelta),
+        )
+        ..afterSelection = Selection.collapse(selection.start.path + [0], 0);
+    }
+    await editorState.apply(transaction);
+    return true;
+  },
+);
+
+/// cmd/ctrl + enter to close or open the toggle list
+///
+/// - support
+///   - desktop
+///   - web
+///
+
+// toggle the todo list
+final CommandShortcutEvent toggleToggleListCommand = CommandShortcutEvent(
+  key: 'toggle the toggle list',
+  command: 'ctrl+enter',
+  macOSCommand: 'cmd+enter',
+  handler: _toggleToggleListCommandHandler,
+);
+
+CommandShortcutEventHandler _toggleToggleListCommandHandler = (editorState) {
+  if (PlatformExtension.isMobile) {
+    assert(false, 'enter key is not supported on mobile platform.');
+    return KeyEventResult.ignored;
+  }
+
+  final selection = editorState.selection;
+  if (selection == null) {
+    return KeyEventResult.ignored;
+  }
+  final nodes = editorState.getNodesInSelection(selection);
+  if (nodes.isEmpty || nodes.length > 1) {
+    return KeyEventResult.ignored;
+  }
+
+  final node = nodes.first;
+  if (node.type != ToggleListBlockKeys.type) {
+    return KeyEventResult.ignored;
+  }
+
+  final collapsed = node.attributes[ToggleListBlockKeys.collapsed] as bool;
+  final transaction = editorState.transaction;
+  transaction.updateNode(node, {
+    ToggleListBlockKeys.collapsed: !collapsed,
+  });
+  transaction.afterSelection = selection;
+  editorState.apply(transaction);
+  return KeyEventResult.handled;
+};

+ 1 - 1
frontend/appflowy_flutter/pubspec.yaml

@@ -45,7 +45,7 @@ dependencies:
   appflowy_editor:
     git:
       url: https://github.com/AppFlowy-IO/appflowy-editor.git
-      ref: 33b18d9
+      ref: 023f3c8
   appflowy_popover:
     path: packages/appflowy_popover
 

+ 7 - 0
frontend/flowy-server-config/Cargo.lock

@@ -0,0 +1,7 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "flowy-server-config"
+version = "0.1.0"