소스 검색

Merge pull request #1188 from LucasXu0/commands

feat: add commands and update checkbox logic
Lucas.Xu 2 년 전
부모
커밋
3df31c43f8
16개의 변경된 파일355개의 추가작업 그리고 21개의 파일을 삭제
  1. 34 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/commands/edit_text.dart
  2. 83 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_built_in_text.dart
  3. 67 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_text.dart
  4. 43 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text_command_infra.dart
  5. 4 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart
  6. 5 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart
  7. 1 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart
  8. 8 11
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart
  9. 13 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart
  10. 4 5
      frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart
  11. 8 4
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart
  12. 2 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart
  13. 5 1
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart
  14. 21 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/space_on_web_handler.dart
  15. 12 0
      frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart
  16. 45 0
      frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/space_on_web_handler_test.dart

+ 34 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/commands/edit_text.dart

@@ -0,0 +1,34 @@
+import 'dart:async';
+
+import 'package:appflowy_editor/src/commands/text_command_infra.dart';
+import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/document/path.dart';
+import 'package:appflowy_editor/src/editor_state.dart';
+import 'package:appflowy_editor/src/operation/transaction_builder.dart';
+import 'package:flutter/widgets.dart';
+
+Future<void> insertContextInText(
+  EditorState editorState,
+  int index,
+  String content, {
+  Path? path,
+  TextNode? textNode,
+}) async {
+  final result = getTextNodeToBeFormatted(
+    editorState,
+    path: path,
+    textNode: textNode,
+  );
+
+  final completer = Completer<void>();
+
+  TransactionBuilder(editorState)
+    ..insertText(result, index, content)
+    ..commit();
+
+  WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+    completer.complete();
+  });
+
+  return completer.future;
+}

+ 83 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_built_in_text.dart

@@ -0,0 +1,83 @@
+import 'package:appflowy_editor/src/commands/format_text.dart';
+import 'package:appflowy_editor/src/commands/text_command_infra.dart';
+import 'package:appflowy_editor/src/document/attributes.dart';
+import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
+import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/document/path.dart';
+import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/editor_state.dart';
+
+Future<void> formatBuiltInTextAttributes(
+  EditorState editorState,
+  String key,
+  Attributes attributes, {
+  Selection? selection,
+  Path? path,
+  TextNode? textNode,
+}) async {
+  final result = getTextNodeToBeFormatted(
+    editorState,
+    path: path,
+    textNode: textNode,
+  );
+  if (BuiltInAttributeKey.globalStyleKeys.contains(key)) {
+    // remove all the existing style
+    final newAttributes = result.attributes
+      ..removeWhere((key, value) {
+        if (BuiltInAttributeKey.globalStyleKeys.contains(key)) {
+          return true;
+        }
+        return false;
+      })
+      ..addAll(attributes)
+      ..addAll({
+        BuiltInAttributeKey.subtype: key,
+      });
+    return updateTextNodeAttributes(
+      editorState,
+      newAttributes,
+      textNode: textNode,
+    );
+  } else if (BuiltInAttributeKey.partialStyleKeys.contains(key)) {
+    return updateTextNodeDeltaAttributes(
+      editorState,
+      selection,
+      attributes,
+      textNode: textNode,
+    );
+  }
+}
+
+Future<void> formatTextToCheckbox(
+  EditorState editorState,
+  bool check, {
+  Path? path,
+  TextNode? textNode,
+}) async {
+  return formatBuiltInTextAttributes(
+    editorState,
+    BuiltInAttributeKey.checkbox,
+    {
+      BuiltInAttributeKey.checkbox: check,
+    },
+    path: path,
+    textNode: textNode,
+  );
+}
+
+Future<void> formatLinkInText(
+  EditorState editorState,
+  String? link, {
+  Path? path,
+  TextNode? textNode,
+}) async {
+  return formatBuiltInTextAttributes(
+    editorState,
+    BuiltInAttributeKey.href,
+    {
+      BuiltInAttributeKey.href: link,
+    },
+    path: path,
+    textNode: textNode,
+  );
+}

+ 67 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/commands/format_text.dart

@@ -0,0 +1,67 @@
+import 'dart:async';
+
+import 'package:appflowy_editor/src/commands/text_command_infra.dart';
+import 'package:appflowy_editor/src/document/attributes.dart';
+import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/document/path.dart';
+import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/editor_state.dart';
+import 'package:appflowy_editor/src/operation/transaction_builder.dart';
+import 'package:flutter/widgets.dart';
+
+Future<void> updateTextNodeAttributes(
+  EditorState editorState,
+  Attributes attributes, {
+  Path? path,
+  TextNode? textNode,
+}) async {
+  final result = getTextNodeToBeFormatted(
+    editorState,
+    path: path,
+    textNode: textNode,
+  );
+
+  final completer = Completer<void>();
+
+  TransactionBuilder(editorState)
+    ..updateNode(result, attributes)
+    ..commit();
+
+  WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+    completer.complete();
+  });
+
+  return completer.future;
+}
+
+Future<void> updateTextNodeDeltaAttributes(
+  EditorState editorState,
+  Selection? selection,
+  Attributes attributes, {
+  Path? path,
+  TextNode? textNode,
+}) {
+  final result = getTextNodeToBeFormatted(
+    editorState,
+    path: path,
+    textNode: textNode,
+  );
+  final newSelection = getSelection(editorState, selection: selection);
+
+  final completer = Completer<void>();
+
+  TransactionBuilder(editorState)
+    ..formatText(
+      result,
+      newSelection.startIndex,
+      newSelection.length,
+      attributes,
+    )
+    ..commit();
+
+  WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+    completer.complete();
+  });
+
+  return completer.future;
+}

+ 43 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/commands/text_command_infra.dart

@@ -0,0 +1,43 @@
+import 'package:appflowy_editor/src/document/node.dart';
+import 'package:appflowy_editor/src/document/path.dart';
+import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/editor_state.dart';
+
+// get formatted [TextNode]
+TextNode getTextNodeToBeFormatted(
+  EditorState editorState, {
+  Path? path,
+  TextNode? textNode,
+}) {
+  final currentSelection =
+      editorState.service.selectionService.currentSelection.value;
+  TextNode result;
+  if (textNode != null) {
+    result = textNode;
+  } else if (path != null) {
+    result = editorState.document.nodeAtPath(path) as TextNode;
+  } else if (currentSelection != null && currentSelection.isCollapsed) {
+    result = editorState.document.nodeAtPath(currentSelection.start.path)
+        as TextNode;
+  } else {
+    throw Exception('path and textNode cannot be null at the same time');
+  }
+  return result;
+}
+
+Selection getSelection(
+  EditorState editorState, {
+  Selection? selection,
+}) {
+  final currentSelection =
+      editorState.service.selectionService.currentSelection.value;
+  Selection result;
+  if (selection != null) {
+    result = selection;
+  } else if (currentSelection != null) {
+    result = currentSelection;
+  } else {
+    throw Exception('path and textNode cannot be null at the same time');
+  }
+  return result;
+}

+ 4 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart

@@ -53,6 +53,10 @@ class Selection {
 
 
   Selection get reversed => copyWith(start: end, end: start);
   Selection get reversed => copyWith(start: end, end: start);
 
 
+  int get startIndex => normalize.start.offset;
+  int get endIndex => normalize.end.offset;
+  int get length => endIndex - startIndex;
+
   Selection collapse({bool atStart = false}) {
   Selection collapse({bool atStart = false}) {
     if (atStart) {
     if (atStart) {
       return Selection(start: start, end: start);
       return Selection(start: start, end: start);

+ 5 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart

@@ -72,6 +72,8 @@ class EditorState {
   // TODO: only for testing.
   // TODO: only for testing.
   bool disableSealTimer = false;
   bool disableSealTimer = false;
 
 
+  bool editable = true;
+
   Selection? get cursorSelection {
   Selection? get cursorSelection {
     return _cursorSelection;
     return _cursorSelection;
   }
   }
@@ -112,6 +114,9 @@ class EditorState {
   /// should record the transaction in undo/redo stack.
   /// should record the transaction in undo/redo stack.
   apply(Transaction transaction,
   apply(Transaction transaction,
       [ApplyOptions options = const ApplyOptions()]) {
       [ApplyOptions options = const ApplyOptions()]) {
+    if (!editable) {
+      return;
+    }
     // TODO: validate the transation.
     // TODO: validate the transation.
     for (final op in transaction.operations) {
     for (final op in transaction.operations) {
       _applyOperation(op);
       _applyOperation(op);

+ 1 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart

@@ -31,6 +31,7 @@ class _LinkMenuState extends State<LinkMenu> {
   void initState() {
   void initState() {
     super.initState();
     super.initState();
     _textEditingController.text = widget.linkText ?? '';
     _textEditingController.text = widget.linkText ?? '';
+    _focusNode.requestFocus();
     _focusNode.addListener(_onFocusChange);
     _focusNode.addListener(_onFocusChange);
   }
   }
 
 

+ 8 - 11
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart

@@ -1,15 +1,8 @@
-import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/editor_state.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/commands/format_built_in_text.dart';
 import 'package:appflowy_editor/src/infra/flowy_svg.dart';
 import 'package:appflowy_editor/src/infra/flowy_svg.dart';
 import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
 import 'package:appflowy_editor/src/render/rich_text/built_in_text_widget.dart';
-import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart';
-import 'package:appflowy_editor/src/render/rich_text/flowy_rich_text.dart';
-import 'package:appflowy_editor/src/render/selection/selectable.dart';
-import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
 
 
-import 'package:appflowy_editor/src/service/render_plugin_service.dart';
-import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
 import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
 import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 
 
@@ -81,8 +74,12 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
               padding: iconPadding,
               padding: iconPadding,
               name: check ? 'check' : 'uncheck',
               name: check ? 'check' : 'uncheck',
             ),
             ),
-            onTap: () {
-              formatCheckbox(widget.editorState, !check);
+            onTap: () async {
+              await formatTextToCheckbox(
+                widget.editorState,
+                !check,
+                textNode: widget.textNode,
+              );
             },
             },
           ),
           ),
           Flexible(
           Flexible(

+ 13 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart

@@ -18,6 +18,8 @@ import 'package:appflowy_editor/src/extensions/attributes_extension.dart';
 import 'package:appflowy_editor/src/render/selection/selectable.dart';
 import 'package:appflowy_editor/src/render/selection/selectable.dart';
 import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
 import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
 
 
+const _kRichTextDebugMode = false;
+
 typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
 typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
 
 
 class FlowyRichText extends StatefulWidget {
 class FlowyRichText extends StatefulWidget {
@@ -261,6 +263,17 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
         ),
         ),
       );
       );
     }
     }
+    if (_kRichTextDebugMode) {
+      textSpans.add(
+        TextSpan(
+          text: '${widget.textNode.path}',
+          style: const TextStyle(
+            backgroundColor: Colors.red,
+            fontSize: 16.0,
+          ),
+        ),
+      );
+    }
     return TextSpan(
     return TextSpan(
       children: textSpans,
       children: textSpans,
     );
     );

+ 4 - 5
frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart

@@ -1,4 +1,5 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/commands/format_built_in_text.dart';
 import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
 import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
 import 'package:appflowy_editor/src/infra/flowy_svg.dart';
 import 'package:appflowy_editor/src/infra/flowy_svg.dart';
 import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
 import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
@@ -345,11 +346,8 @@ void showLinkMenu(
           onOpenLink: () async {
           onOpenLink: () async {
             await safeLaunchUrl(linkText);
             await safeLaunchUrl(linkText);
           },
           },
-          onSubmitted: (text) {
-            TransactionBuilder(editorState)
-              ..formatText(
-                  textNode, index, length, {BuiltInAttributeKey.href: text})
-              ..commit();
+          onSubmitted: (text) async {
+            await formatLinkInText(editorState, text, textNode: textNode);
             _dismissLinkMenu();
             _dismissLinkMenu();
           },
           },
           onCopyLink: () {
           onCopyLink: () {
@@ -377,6 +375,7 @@ void showLinkMenu(
   Overlay.of(context)?.insert(_linkMenuOverlay!);
   Overlay.of(context)?.insert(_linkMenuOverlay!);
 
 
   editorState.service.scrollService?.disable();
   editorState.service.scrollService?.disable();
+  editorState.service.keyboardService?.disable();
   editorState.service.selectionService.currentSelection
   editorState.service.selectionService.currentSelection
       .addListener(_dismissLinkMenu);
       .addListener(_dismissLinkMenu);
 }
 }

+ 8 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart

@@ -103,13 +103,17 @@ bool formatTextNodes(EditorState editorState, Attributes attributes) {
   final builder = TransactionBuilder(editorState);
   final builder = TransactionBuilder(editorState);
 
 
   for (final textNode in textNodes) {
   for (final textNode in textNodes) {
+    var newAttributes = {...textNode.attributes};
+    for (final globalStyleKey in BuiltInAttributeKey.globalStyleKeys) {
+      if (newAttributes.keys.contains(globalStyleKey)) {
+        newAttributes[globalStyleKey] = null;
+      }
+    }
+    newAttributes.addAll(attributes);
     builder
     builder
       ..updateNode(
       ..updateNode(
         textNode,
         textNode,
-        Attributes.fromIterable(
-          BuiltInAttributeKey.globalStyleKeys,
-          value: (_) => null,
-        )..addAll(attributes),
+        newAttributes,
       )
       )
       ..afterSelection = Selection.collapsed(
       ..afterSelection = Selection.collapsed(
         Position(
         Position(

+ 2 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart

@@ -72,6 +72,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
     editorState.selectionMenuItems = widget.selectionMenuItems;
     editorState.selectionMenuItems = widget.selectionMenuItems;
     editorState.editorStyle = widget.editorStyle;
     editorState.editorStyle = widget.editorStyle;
     editorState.service.renderPluginService = _createRenderPlugin();
     editorState.service.renderPluginService = _createRenderPlugin();
+    editorState.editable = widget.editable;
   }
   }
 
 
   @override
   @override
@@ -84,6 +85,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
     }
     }
 
 
     editorState.editorStyle = widget.editorStyle;
     editorState.editorStyle = widget.editorStyle;
+    editorState.editable = widget.editable;
     services = null;
     services = null;
   }
   }
 
 

+ 5 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart

@@ -297,7 +297,11 @@ class _AppFlowyInputState extends State<AppFlowyInput>
         _updateCaretPosition(textNodes.first, selection);
         _updateCaretPosition(textNodes.first, selection);
       }
       }
     } else {
     } else {
-      // close();
+      // https://github.com/flutter/flutter/issues/104944
+      // Disable IME for the Web.
+      if (kIsWeb) {
+        close();
+      }
     }
     }
   }
   }
 
 

+ 21 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/space_on_web_handler.dart

@@ -0,0 +1,21 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/commands/edit_text.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+
+ShortcutEventHandler spaceOnWebHandler = (editorState, event) {
+  final selection = editorState.service.selectionService.currentSelection.value;
+  final textNodes = editorState.service.selectionService.currentSelectedNodes
+      .whereType<TextNode>()
+      .toList(growable: false);
+  if (selection == null ||
+      !selection.isCollapsed ||
+      !kIsWeb ||
+      textNodes.length != 1) {
+    return KeyEventResult.ignored;
+  }
+
+  insertContextInText(editorState, selection.startIndex, ' ');
+
+  return KeyEventResult.handled;
+};

+ 12 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart

@@ -9,9 +9,11 @@ import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_und
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/format_style_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/format_style_handler.dart';
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/space_on_web_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/tab_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/tab_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart';
 import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart';
 import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart';
 import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart';
+import 'package:flutter/foundation.dart';
 
 
 //
 //
 List<ShortcutEvent> builtInShortcutEvents = [
 List<ShortcutEvent> builtInShortcutEvents = [
@@ -249,4 +251,14 @@ List<ShortcutEvent> builtInShortcutEvents = [
     command: 'tab',
     command: 'tab',
     handler: tabHandler,
     handler: tabHandler,
   ),
   ),
+  // https://github.com/flutter/flutter/issues/104944
+  // Workaround: Using space editing on the web platform often results in errors,
+  //  so adding a shortcut event to handle the space input instead of using the
+  //  `input_service`.
+  if (kIsWeb)
+    ShortcutEvent(
+      key: 'Space on the Web',
+      command: 'space',
+      handler: spaceOnWebHandler,
+    ),
 ];
 ];

+ 45 - 0
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/space_on_web_handler_test.dart

@@ -0,0 +1,45 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import '../../infra/test_editor.dart';
+
+void main() async {
+  setUpAll(() {
+    TestWidgetsFlutterBinding.ensureInitialized();
+  });
+
+  group('space_on_web_handler.dart', () {
+    testWidgets('Presses space key on web', (tester) async {
+      if (!kIsWeb) return;
+      const count = 10;
+      const text = 'Welcome to Appflowy 😁';
+      final editor = tester.editor;
+      for (var i = 0; i < count; i++) {
+        editor.insertTextNode(text);
+      }
+      await editor.startTesting();
+
+      for (var i = 0; i < count; i++) {
+        await editor.updateSelection(
+          Selection.single(path: [i], startOffset: 1),
+        );
+        await editor.pressLogicKey(LogicalKeyboardKey.space);
+        expect(
+          (editor.nodeAtPath([i]) as TextNode).toRawString(),
+          'W elcome to Appflowy 😁',
+        );
+      }
+      for (var i = 0; i < count; i++) {
+        await editor.updateSelection(
+          Selection.single(path: [i], startOffset: text.length + 1),
+        );
+        await editor.pressLogicKey(LogicalKeyboardKey.space);
+        expect(
+          (editor.nodeAtPath([i]) as TextNode).toRawString(),
+          'W elcome to Appflowy 😁 ',
+        );
+      }
+    });
+  });
+}