From 159fe63575be2521d87e1f636838564c087bd4a9 Mon Sep 17 00:00:00 2001
From: "Lucas.Xu" <lucas.xu@appflowy.io>
Date: Mon, 1 Aug 2022 22:41:30 +0800
Subject: [PATCH] feat: implement edit text style by command + x

---
 .../lib/extensions/text_node_extensions.dart  | 88 +++++++++++++++++++
 .../format_rich_text_style.dart               | 86 ++++++++++++++++++
 ...pdate_text_style_by_command_x_handler.dart | 72 +++------------
 .../lib/service/selection_service.dart        | 11 ++-
 4 files changed, 194 insertions(+), 63 deletions(-)
 create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/extensions/text_node_extensions.dart
 create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/service/default_text_operations/format_rich_text_style.dart

diff --git a/frontend/app_flowy/packages/flowy_editor/lib/extensions/text_node_extensions.dart b/frontend/app_flowy/packages/flowy_editor/lib/extensions/text_node_extensions.dart
new file mode 100644
index 0000000000..29e90784ae
--- /dev/null
+++ b/frontend/app_flowy/packages/flowy_editor/lib/extensions/text_node_extensions.dart
@@ -0,0 +1,88 @@
+import 'package:flowy_editor/document/node.dart';
+import 'package:flowy_editor/document/path.dart';
+import 'package:flowy_editor/document/position.dart';
+import 'package:flowy_editor/document/selection.dart';
+import 'package:flowy_editor/document/text_delta.dart';
+import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
+
+extension TextNodeExtension on TextNode {
+  bool allSatisfyBoldInSelection(Selection selection) =>
+      allSatisfyInSelection(StyleKey.bold, selection);
+
+  bool allSatisfyItalicInSelection(Selection selection) =>
+      allSatisfyInSelection(StyleKey.italic, selection);
+
+  bool allSatisfyUnderlineInSelection(Selection selection) =>
+      allSatisfyInSelection(StyleKey.underline, selection);
+
+  bool allSatisfyStrikethroughInSelection(Selection selection) =>
+      allSatisfyInSelection(StyleKey.strikethrough, selection);
+
+  bool allSatisfyInSelection(String styleKey, Selection selection) {
+    final ops = delta.operations.whereType<TextInsert>();
+    var start = 0;
+    for (final op in ops) {
+      if (start >= selection.end.offset) {
+        break;
+      }
+      final length = op.length;
+      if (start < selection.end.offset &&
+          start + length > selection.start.offset) {
+        if (op.attributes == null ||
+            !op.attributes!.containsKey(styleKey) ||
+            op.attributes![styleKey] == false) {
+          return false;
+        }
+      }
+      start += length;
+    }
+    return true;
+  }
+}
+
+extension TextNodesExtension on List<TextNode> {
+  bool allSatisfyBoldInSelection(Selection selection) =>
+      allSatisfyInSelection(StyleKey.bold, selection);
+
+  bool allSatisfyItalicInSelection(Selection selection) =>
+      allSatisfyInSelection(StyleKey.italic, selection);
+
+  bool allSatisfyUnderlineInSelection(Selection selection) =>
+      allSatisfyInSelection(StyleKey.underline, selection);
+
+  bool allSatisfyStrikethroughInSelection(Selection selection) =>
+      allSatisfyInSelection(StyleKey.strikethrough, selection);
+
+  bool allSatisfyInSelection(String styleKey, Selection selection) {
+    if (isEmpty) {
+      return false;
+    }
+    if (length == 1) {
+      return first.allSatisfyInSelection(styleKey, selection);
+    } else {
+      for (var i = 0; i < length; i++) {
+        final node = this[i];
+        final Selection newSelection;
+        if (i == 0 && pathEquals(node.path, selection.start.path)) {
+          newSelection = selection.copyWith(
+            end: Position(path: node.path, offset: node.toRawString().length),
+          );
+        } else if (i == length - 1 &&
+            pathEquals(node.path, selection.end.path)) {
+          newSelection = selection.copyWith(
+            start: Position(path: node.path, offset: 0),
+          );
+        } else {
+          newSelection = Selection(
+            start: Position(path: node.path, offset: 0),
+            end: Position(path: node.path, offset: node.toRawString().length),
+          );
+        }
+        if (!node.allSatisfyInSelection(styleKey, newSelection)) {
+          return false;
+        }
+      }
+      return true;
+    }
+  }
+}
diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/default_text_operations/format_rich_text_style.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/default_text_operations/format_rich_text_style.dart
new file mode 100644
index 0000000000..c88eab833f
--- /dev/null
+++ b/frontend/app_flowy/packages/flowy_editor/lib/service/default_text_operations/format_rich_text_style.dart
@@ -0,0 +1,86 @@
+import 'package:flowy_editor/document/node.dart';
+import 'package:flowy_editor/editor_state.dart';
+import 'package:flowy_editor/operation/transaction_builder.dart';
+
+bool formatRichTextStyle(
+    EditorState editorState, Map<String, dynamic> attributes) {
+  final selection = editorState.service.selectionService.currentSelection;
+  final nodes = editorState.service.selectionService.currentSelectedNodes.value;
+  final textNodes = nodes.whereType<TextNode>().toList();
+
+  if (selection == null || textNodes.isEmpty) {
+    return false;
+  }
+
+  final builder = TransactionBuilder(editorState);
+
+  // 1. All nodes are text nodes.
+  // 2. The first node is not TextNode.
+  // 3. The last node is not TextNode.
+  if (textNodes.length == nodes.length) {
+    if (textNodes.length == 1) {
+      builder.formatText(
+        textNodes.first,
+        selection.start.offset,
+        selection.end.offset - selection.start.offset,
+        attributes,
+      );
+    } else {
+      for (var i = 0; i < textNodes.length; i++) {
+        final node = textNodes[i];
+        if (i == 0) {
+          builder.formatText(
+            node,
+            selection.start.offset,
+            node.toRawString().length - selection.start.offset,
+            attributes,
+          );
+        } else if (i == textNodes.length - 1) {
+          builder.formatText(
+            node,
+            0,
+            selection.end.offset,
+            attributes,
+          );
+        } else {
+          builder.formatText(
+            node,
+            0,
+            node.toRawString().length,
+            attributes,
+          );
+        }
+      }
+    }
+  } else {
+    for (var i = 0; i < textNodes.length; i++) {
+      final node = textNodes[i];
+      if (i == 0 && node == nodes.first) {
+        builder.formatText(
+          node,
+          selection.start.offset,
+          node.toRawString().length - selection.start.offset,
+          attributes,
+        );
+      } else if (i == textNodes.length - 1 && node == nodes.last) {
+        builder.formatText(
+          node,
+          0,
+          selection.end.offset,
+          attributes,
+        );
+      } else {
+        builder.formatText(
+          node,
+          0,
+          node.toRawString().length,
+          attributes,
+        );
+      }
+    }
+  }
+
+  builder.commit();
+
+  return true;
+}
diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart
index 5f13484442..6e4b742785 100644
--- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart
+++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart
@@ -1,22 +1,21 @@
-import 'package:flowy_editor/document/node.dart';
-import 'package:flowy_editor/document/selection.dart';
-import 'package:flowy_editor/editor_state.dart';
-import 'package:flowy_editor/operation/transaction_builder.dart';
-import 'package:flowy_editor/service/keyboard_service.dart';
-import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
 import 'package:flutter/material.dart';
 
+import 'package:flowy_editor/document/node.dart';
+import 'package:flowy_editor/extensions/text_node_extensions.dart';
+import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
+import 'package:flowy_editor/service/default_text_operations/format_rich_text_style.dart';
+import 'package:flowy_editor/service/keyboard_service.dart';
+
 FlowyKeyEventHandler updateTextStyleByCommandXHandler = (editorState, event) {
   if (!event.isMetaPressed || event.character == null) {
     return KeyEventResult.ignored;
   }
 
   final selection = editorState.service.selectionService.currentSelection;
-  final nodes = editorState.service.selectionService.currentSelectedNodes.value
-      .whereType<TextNode>()
-      .toList();
+  final nodes = editorState.service.selectionService.currentSelectedNodes.value;
+  final textNodes = nodes.whereType<TextNode>().toList(growable: false);
 
-  if (selection == null || nodes.isEmpty) {
+  if (selection == null || textNodes.isEmpty) {
     return KeyEventResult.ignored;
   }
 
@@ -24,7 +23,9 @@ FlowyKeyEventHandler updateTextStyleByCommandXHandler = (editorState, event) {
     // bold
     case 'B':
     case 'b':
-      _makeBold(editorState, nodes, selection);
+      formatRichTextStyle(editorState, {
+        StyleKey.bold: !textNodes.allSatisfyBoldInSelection(selection),
+      });
       return KeyEventResult.handled;
     default:
       break;
@@ -32,52 +33,3 @@ FlowyKeyEventHandler updateTextStyleByCommandXHandler = (editorState, event) {
 
   return KeyEventResult.ignored;
 };
-
-// TODO: implement unBold.
-void _makeBold(
-    EditorState editorState, List<TextNode> nodes, Selection selection) {
-  final builder = TransactionBuilder(editorState);
-  if (nodes.length == 1) {
-    builder.formatText(
-      nodes.first,
-      selection.start.offset,
-      selection.end.offset - selection.start.offset,
-      {
-        'bold': true,
-      },
-    );
-  } else {
-    for (var i = 0; i < nodes.length; i++) {
-      final node = nodes[i];
-      if (i == 0) {
-        builder.formatText(
-          node,
-          selection.start.offset,
-          node.toRawString().length - selection.start.offset,
-          {
-            'bold': true,
-          },
-        );
-      } else if (i == nodes.length - 1) {
-        builder.formatText(
-          node,
-          0,
-          selection.end.offset,
-          {
-            'bold': true,
-          },
-        );
-      } else {
-        builder.formatText(
-          node,
-          0,
-          node.toRawString().length,
-          {
-            'bold': true,
-          },
-        );
-      }
-    }
-  }
-  builder.commit();
-}
diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart
index be1fd0bc8a..0695dd5e90 100644
--- a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart
+++ b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart
@@ -422,14 +422,19 @@ class _FlowySelectionState extends State<FlowySelection>
 
     // compute the selection in range.
     if (first != null && last != null) {
-      bool isDownward = panStartOffset!.dy <= panEndOffset!.dy;
+      bool isDownward;
+      if (first == last) {
+        isDownward = panStartOffset!.dx < panEndOffset!.dx;
+      } else {
+        isDownward = panStartOffset!.dy < panEndOffset!.dy;
+      }
       final start =
           first.getSelectionInRange(panStartOffset!, panEndOffset!).start;
       final end = last.getSelectionInRange(panStartOffset!, panEndOffset!).end;
       final selection = Selection(
           start: isDownward ? start : end, end: isDownward ? end : start);
-      debugPrint('[_onPanUpdate] $selection');
-      editorState.updateCursorSelection(selection);
+      debugPrint('[_onPanUpdate] isDownward = $isDownward, $selection');
+      editorState.service.selectionService.updateSelection(selection);
     }
   }