Kaynağa Gözat

feat: #931 highlight the status of the currently selected style in toolbar

Lucas.Xu 2 yıl önce
ebeveyn
işleme
3eaa31c68c

+ 8 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/editor_state_extensions.dart

@@ -0,0 +1,8 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
+
+extension EditorStateExtensions on EditorState {
+  List<TextNode> get selectedTextNodes =>
+      service.selectionService.currentSelectedNodes
+          .whereType<TextNode>()
+          .toList(growable: false);
+}

+ 56 - 34
frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/text_node_extensions.dart

@@ -29,56 +29,63 @@ extension TextNodeExtension on TextNode {
   }
 
   bool allSatisfyLinkInSelection(Selection selection) =>
-      allSatisfyInSelection(StyleKey.href, selection, (value) {
+      allSatisfyInSelection(selection, StyleKey.href, (value) {
         return value != null;
       });
 
   bool allSatisfyBoldInSelection(Selection selection) =>
-      allSatisfyInSelection(StyleKey.bold, selection, (value) {
+      allSatisfyInSelection(selection, StyleKey.bold, (value) {
         return value == true;
       });
 
   bool allSatisfyItalicInSelection(Selection selection) =>
-      allSatisfyInSelection(StyleKey.italic, selection, (value) {
+      allSatisfyInSelection(selection, StyleKey.italic, (value) {
         return value == true;
       });
 
   bool allSatisfyUnderlineInSelection(Selection selection) =>
-      allSatisfyInSelection(StyleKey.underline, selection, (value) {
+      allSatisfyInSelection(selection, StyleKey.underline, (value) {
         return value == true;
       });
 
   bool allSatisfyStrikethroughInSelection(Selection selection) =>
-      allSatisfyInSelection(StyleKey.strikethrough, selection, (value) {
+      allSatisfyInSelection(selection, StyleKey.strikethrough, (value) {
         return value == true;
       });
 
   bool allSatisfyInSelection(
-    String styleKey,
     Selection selection,
+    String styleKey,
     bool Function(dynamic value) test,
   ) {
-    final ops = delta.whereType<TextInsert>();
-    final startOffset =
-        selection.isBackward ? selection.start.offset : selection.end.offset;
-    final endOffset =
-        selection.isBackward ? selection.end.offset : selection.start.offset;
-    var start = 0;
-    for (final op in ops) {
-      if (start >= endOffset) {
-        break;
+    if (StyleKey.globalStyleKeys.contains(styleKey)) {
+      if (attributes.containsKey(styleKey)) {
+        return test(attributes[styleKey]);
       }
-      final length = op.length;
-      if (start < endOffset && start + length > startOffset) {
-        if (op.attributes == null ||
-            !op.attributes!.containsKey(styleKey) ||
-            !test(op.attributes![styleKey])) {
-          return false;
+    } else if (StyleKey.partialStyleKeys.contains(styleKey)) {
+      final ops = delta.whereType<TextInsert>();
+      final startOffset =
+          selection.isBackward ? selection.start.offset : selection.end.offset;
+      final endOffset =
+          selection.isBackward ? selection.end.offset : selection.start.offset;
+      var start = 0;
+      for (final op in ops) {
+        if (start >= endOffset) {
+          break;
         }
+        final length = op.length;
+        if (start < endOffset && start + length > startOffset) {
+          if (op.attributes == null ||
+              !op.attributes!.containsKey(styleKey) ||
+              !test(op.attributes![styleKey])) {
+            return false;
+          }
+        }
+        start += length;
       }
-      start += length;
+      return true;
     }
-    return true;
+    return false;
   }
 
   bool allNotSatisfyInSelection(
@@ -111,29 +118,44 @@ extension TextNodeExtension on TextNode {
 }
 
 extension TextNodesExtension on List<TextNode> {
-  bool allSatisfyBoldInSelection(Selection selection) =>
-      allSatisfyInSelection(StyleKey.bold, selection, true);
+  bool allSatisfyBoldInSelection(Selection selection) => allSatisfyInSelection(
+        selection,
+        StyleKey.bold,
+        (value) => value == true,
+      );
 
   bool allSatisfyItalicInSelection(Selection selection) =>
-      allSatisfyInSelection(StyleKey.italic, selection, true);
+      allSatisfyInSelection(
+        selection,
+        StyleKey.italic,
+        (value) => value == true,
+      );
 
   bool allSatisfyUnderlineInSelection(Selection selection) =>
-      allSatisfyInSelection(StyleKey.underline, selection, true);
+      allSatisfyInSelection(
+        selection,
+        StyleKey.underline,
+        (value) => value == true,
+      );
 
   bool allSatisfyStrikethroughInSelection(Selection selection) =>
-      allSatisfyInSelection(StyleKey.strikethrough, selection, true);
+      allSatisfyInSelection(
+        selection,
+        StyleKey.strikethrough,
+        (value) => value == true,
+      );
 
   bool allSatisfyInSelection(
-    String styleKey,
     Selection selection,
-    dynamic matchValue,
+    String styleKey,
+    bool Function(dynamic value) test,
   ) {
     if (isEmpty) {
       return false;
     }
     if (length == 1) {
-      return first.allSatisfyInSelection(styleKey, selection, (value) {
-        return value == matchValue;
+      return first.allSatisfyInSelection(selection, styleKey, (value) {
+        return test(value);
       });
     } else {
       for (var i = 0; i < length; i++) {
@@ -154,8 +176,8 @@ extension TextNodesExtension on List<TextNode> {
             end: Position(path: node.path, offset: node.toRawString().length),
           );
         }
-        if (!node.allSatisfyInSelection(styleKey, newSelection, (value) {
-          return value == matchValue;
+        if (!node.allSatisfyInSelection(newSelection, styleKey, (value) {
+          return test(value);
         })) {
           return false;
         }

+ 2 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart

@@ -47,6 +47,8 @@ class StyleKey {
     StyleKey.italic,
     StyleKey.underline,
     StyleKey.strikethrough,
+    StyleKey.backgroundColor,
+    StyleKey.href,
   ];
 
   static List<String> globalStyleKeys = [

+ 127 - 20
frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart

@@ -4,38 +4,43 @@ 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/rich_text/rich_text_style.dart';
 import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
+import 'package:appflowy_editor/src/extensions/editor_state_extensions.dart';
 import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
 import 'package:flutter/material.dart';
 import 'package:rich_clipboard/rich_clipboard.dart';
 
-typedef ToolbarEventHandler = void Function(
+typedef ToolbarItemEventHandler = void Function(
     EditorState editorState, BuildContext context);
-typedef ToolbarShowValidator = bool Function(EditorState editorState);
+typedef ToolbarItemValidator = bool Function(EditorState editorState);
+typedef ToolbarItemHighlightCallback = bool Function(EditorState editorState);
 
 class ToolbarItem {
   ToolbarItem({
     required this.id,
     required this.type,
-    required this.icon,
+    required this.iconBuilder,
     this.tooltipsMessage = '',
     required this.validator,
+    required this.highlightCallback,
     required this.handler,
   });
 
   final String id;
   final int type;
-  final Widget icon;
+  final Widget Function(bool isHighlight) iconBuilder;
   final String tooltipsMessage;
-  final ToolbarShowValidator validator;
-  final ToolbarEventHandler handler;
+  final ToolbarItemValidator validator;
+  final ToolbarItemEventHandler handler;
+  final ToolbarItemHighlightCallback highlightCallback;
 
   factory ToolbarItem.divider() {
     return ToolbarItem(
       id: 'divider',
       type: -1,
-      icon: const FlowySvg(name: 'toolbar/divider'),
+      iconBuilder: (_) => const FlowySvg(name: 'toolbar/divider'),
       validator: (editorState) => true,
       handler: (editorState, context) {},
+      highlightCallback: (editorState) => false,
     );
   }
 
@@ -59,103 +64,205 @@ List<ToolbarItem> defaultToolbarItems = [
     id: 'appflowy.toolbar.h1',
     type: 1,
     tooltipsMessage: 'Heading 1',
-    icon: const FlowySvg(name: 'toolbar/h1'),
+    iconBuilder: (isHighlight) => FlowySvg(
+      name: 'toolbar/h1',
+      color: isHighlight ? Colors.lightBlue : null,
+    ),
     validator: _onlyShowInSingleTextSelection,
+    highlightCallback: (editorState) => _allSatisfy(
+      editorState,
+      StyleKey.heading,
+      (value) => value == StyleKey.h1,
+    ),
     handler: (editorState, context) => formatHeading(editorState, StyleKey.h1),
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.h2',
     type: 1,
     tooltipsMessage: 'Heading 2',
-    icon: const FlowySvg(name: 'toolbar/h2'),
+    iconBuilder: (isHighlight) => FlowySvg(
+      name: 'toolbar/h2',
+      color: isHighlight ? Colors.lightBlue : null,
+    ),
     validator: _onlyShowInSingleTextSelection,
+    highlightCallback: (editorState) => _allSatisfy(
+      editorState,
+      StyleKey.heading,
+      (value) => value == StyleKey.h2,
+    ),
     handler: (editorState, context) => formatHeading(editorState, StyleKey.h2),
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.h3',
     type: 1,
     tooltipsMessage: 'Heading 3',
-    icon: const FlowySvg(name: 'toolbar/h3'),
+    iconBuilder: (isHighlight) => FlowySvg(
+      name: 'toolbar/h3',
+      color: isHighlight ? Colors.lightBlue : null,
+    ),
     validator: _onlyShowInSingleTextSelection,
+    highlightCallback: (editorState) => _allSatisfy(
+      editorState,
+      StyleKey.heading,
+      (value) => value == StyleKey.h3,
+    ),
     handler: (editorState, context) => formatHeading(editorState, StyleKey.h3),
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.bold',
     type: 2,
     tooltipsMessage: 'Bold',
-    icon: const FlowySvg(name: 'toolbar/bold'),
+    iconBuilder: (isHighlight) => FlowySvg(
+      name: 'toolbar/bold',
+      color: isHighlight ? Colors.lightBlue : null,
+    ),
     validator: _showInTextSelection,
+    highlightCallback: (editorState) => _allSatisfy(
+      editorState,
+      StyleKey.bold,
+      (value) => value == true,
+    ),
     handler: (editorState, context) => formatBold(editorState),
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.italic',
     type: 2,
     tooltipsMessage: 'Italic',
-    icon: const FlowySvg(name: 'toolbar/italic'),
+    iconBuilder: (isHighlight) => FlowySvg(
+      name: 'toolbar/italic',
+      color: isHighlight ? Colors.lightBlue : null,
+    ),
     validator: _showInTextSelection,
+    highlightCallback: (editorState) => _allSatisfy(
+      editorState,
+      StyleKey.italic,
+      (value) => value == true,
+    ),
     handler: (editorState, context) => formatItalic(editorState),
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.underline',
     type: 2,
     tooltipsMessage: 'Underline',
-    icon: const FlowySvg(name: 'toolbar/underline'),
+    iconBuilder: (isHighlight) => FlowySvg(
+      name: 'toolbar/underline',
+      color: isHighlight ? Colors.lightBlue : null,
+    ),
     validator: _showInTextSelection,
+    highlightCallback: (editorState) => _allSatisfy(
+      editorState,
+      StyleKey.underline,
+      (value) => value == true,
+    ),
     handler: (editorState, context) => formatUnderline(editorState),
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.strikethrough',
     type: 2,
     tooltipsMessage: 'Strikethrough',
-    icon: const FlowySvg(name: 'toolbar/strikethrough'),
+    iconBuilder: (isHighlight) => FlowySvg(
+      name: 'toolbar/strikethrough',
+      color: isHighlight ? Colors.lightBlue : null,
+    ),
     validator: _showInTextSelection,
+    highlightCallback: (editorState) => _allSatisfy(
+      editorState,
+      StyleKey.strikethrough,
+      (value) => value == true,
+    ),
     handler: (editorState, context) => formatStrikethrough(editorState),
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.quote',
     type: 3,
     tooltipsMessage: 'Quote',
-    icon: const FlowySvg(name: 'toolbar/quote'),
+    iconBuilder: (isHighlight) => FlowySvg(
+      name: 'toolbar/quote',
+      color: isHighlight ? Colors.lightBlue : null,
+    ),
     validator: _onlyShowInSingleTextSelection,
+    highlightCallback: (editorState) => _allSatisfy(
+      editorState,
+      StyleKey.subtype,
+      (value) => value == StyleKey.quote,
+    ),
     handler: (editorState, context) => formatQuote(editorState),
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.bulleted_list',
     type: 3,
     tooltipsMessage: 'Bulleted list',
-    icon: const FlowySvg(name: 'toolbar/bulleted_list'),
+    iconBuilder: (isHighlight) => FlowySvg(
+      name: 'toolbar/bulleted_list',
+      color: isHighlight ? Colors.lightBlue : null,
+    ),
     validator: _onlyShowInSingleTextSelection,
+    highlightCallback: (editorState) => _allSatisfy(
+      editorState,
+      StyleKey.subtype,
+      (value) => value == StyleKey.bulletedList,
+    ),
     handler: (editorState, context) => formatBulletedList(editorState),
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.link',
     type: 4,
     tooltipsMessage: 'Link',
-    icon: const FlowySvg(name: 'toolbar/link'),
+    iconBuilder: (isHighlight) => FlowySvg(
+      name: 'toolbar/link',
+      color: isHighlight ? Colors.lightBlue : null,
+    ),
     validator: _onlyShowInSingleTextSelection,
+    highlightCallback: (editorState) => _allSatisfy(
+      editorState,
+      StyleKey.href,
+      (value) => value != null,
+    ),
     handler: (editorState, context) => showLinkMenu(context, editorState),
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.highlight',
     type: 4,
     tooltipsMessage: 'Highlight',
-    icon: const FlowySvg(name: 'toolbar/highlight'),
+    iconBuilder: (isHighlight) => FlowySvg(
+      name: 'toolbar/highlight',
+      color: isHighlight ? Colors.lightBlue : null,
+    ),
     validator: _showInTextSelection,
+    highlightCallback: (editorState) => _allSatisfy(
+      editorState,
+      StyleKey.backgroundColor,
+      (value) => value != null,
+    ),
     handler: (editorState, context) => formatHighlight(editorState),
   ),
 ];
 
-ToolbarShowValidator _onlyShowInSingleTextSelection = (editorState) {
+ToolbarItemValidator _onlyShowInSingleTextSelection = (editorState) {
   final nodes = editorState.service.selectionService.currentSelectedNodes;
   return (nodes.length == 1 && nodes.first is TextNode);
 };
 
-ToolbarShowValidator _showInTextSelection = (editorState) {
+ToolbarItemValidator _showInTextSelection = (editorState) {
   final nodes = editorState.service.selectionService.currentSelectedNodes
       .whereType<TextNode>();
   return nodes.isNotEmpty;
 };
 
+bool _allSatisfy(
+  EditorState editorState,
+  String styleKey,
+  bool Function(dynamic value) test,
+) {
+  final selection = editorState.service.selectionService.currentSelection.value;
+  return selection != null &&
+      editorState.selectedTextNodes.allSatisfyInSelection(
+        selection,
+        styleKey,
+        test,
+      );
+}
+
 OverlayEntry? _linkMenuOverlay;
 EditorState? _editorState;
 bool _changeSelectionInner = false;

+ 4 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item_widget.dart

@@ -6,11 +6,13 @@ class ToolbarItemWidget extends StatelessWidget {
   const ToolbarItemWidget({
     Key? key,
     required this.item,
+    required this.isHighlight,
     required this.onPressed,
   }) : super(key: key);
 
   final ToolbarItem item;
   final VoidCallback onPressed;
+  final bool isHighlight;
 
   @override
   Widget build(BuildContext context) {
@@ -23,8 +25,9 @@ class ToolbarItemWidget extends StatelessWidget {
         child: MouseRegion(
           cursor: SystemMouseCursors.click,
           child: IconButton(
+            highlightColor: Colors.yellow,
             padding: EdgeInsets.zero,
-            icon: item.icon,
+            icon: item.iconBuilder(isHighlight),
             iconSize: 28,
             onPressed: onPressed,
           ),

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

@@ -64,6 +64,7 @@ class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
                   (item) => Center(
                     child: ToolbarItemWidget(
                       item: item,
+                      isHighlight: item.highlightCallback(widget.editorState),
                       onPressed: () {
                         item.handler(widget.editorState, context);
                       },

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

@@ -157,7 +157,7 @@ bool formatRichTextPartialStyle(EditorState editorState, String styleKey,
 }
 
 bool _allSatisfyInSelection(
-    EditorState editorState, String styleKey, dynamic value) {
+    EditorState editorState, String styleKey, dynamic matchValue) {
   final selection = editorState.service.selectionService.currentSelection.value;
   final nodes = editorState.service.selectionService.currentSelectedNodes;
   final textNodes = nodes.whereType<TextNode>().toList(growable: false);
@@ -166,7 +166,9 @@ bool _allSatisfyInSelection(
     return false;
   }
 
-  return textNodes.allSatisfyInSelection(styleKey, selection, value);
+  return textNodes.allSatisfyInSelection(selection, styleKey, (value) {
+    return value == matchValue;
+  });
 }
 
 bool formatRichTextStyle(EditorState editorState, Attributes attributes) {

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

@@ -102,4 +102,9 @@ class _FlowyToolbarState extends State<FlowyToolbar>
     }
     return dividedItems;
   }
+
+  // List<ToolbarItem> _highlightItems(
+  //   List<ToolbarItem> items,
+  //   Selection selection,
+  // ) {}
 }

+ 17 - 1
frontend/app_flowy/packages/appflowy_editor/test/render/toolbar/toolbar_item_widget_test.dart

@@ -11,17 +11,28 @@ void main() async {
   group('toolbar_item_widget.dart', () {
     testWidgets('test single toolbar item widget', (tester) async {
       final key = GlobalKey();
+      final iconKey = GlobalKey();
       var hit = false;
       final item = ToolbarItem(
         id: 'appflowy.toolbar.test',
         type: 1,
-        icon: const Icon(Icons.abc),
+        iconBuilder: (isHighlight) {
+          return Icon(
+            key: iconKey,
+            Icons.abc,
+            color: isHighlight ? Colors.lightBlue : null,
+          );
+        },
         validator: (editorState) => true,
         handler: (editorState, context) {},
+        highlightCallback: (editorState) {
+          return true;
+        },
       );
       final widget = ToolbarItemWidget(
         key: key,
         item: item,
+        isHighlight: true,
         onPressed: (() {
           hit = true;
         }),
@@ -36,6 +47,11 @@ void main() async {
       );
 
       expect(find.byKey(key), findsOneWidget);
+      expect(find.byKey(iconKey), findsOneWidget);
+      expect(
+        (tester.firstWidget(find.byKey(iconKey)) as Icon).color,
+        Colors.lightBlue,
+      );
 
       await tester.tap(find.byKey(key));
       await tester.pumpAndSettle();

+ 5 - 11
frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/update_text_style_by_command_x_handler_test.dart

@@ -2,7 +2,6 @@ import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
 import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
 import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
-import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
 import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
@@ -91,8 +90,8 @@ Future<void> _testUpdateTextStyleByCommandX(
   var textNode = editor.nodeAtPath([1]) as TextNode;
   expect(
       textNode.allSatisfyInSelection(
-        matchStyle,
         selection,
+        matchStyle,
         (value) {
           return value == matchValue;
         },
@@ -110,8 +109,8 @@ Future<void> _testUpdateTextStyleByCommandX(
   textNode = editor.nodeAtPath([1]) as TextNode;
   expect(
       textNode.allSatisfyInSelection(
-        matchStyle,
         selection,
+        matchStyle,
         (value) {
           return value == matchValue;
         },
@@ -144,12 +143,12 @@ Future<void> _testUpdateTextStyleByCommandX(
   for (final node in nodes) {
     expect(
       node.allSatisfyInSelection(
-        matchStyle,
         Selection.single(
           path: node.path,
           startOffset: 0,
           endOffset: text.length,
         ),
+        matchStyle,
         (value) {
           return value == matchValue;
         },
@@ -196,11 +195,6 @@ Future<void> _testLinkMenuInSingleTextSelection(WidgetTester tester) async {
   // show toolbar
   expect(find.byType(ToolbarWidget), findsOneWidget);
 
-  final item = defaultToolbarItems
-      .where((item) => item.id == 'appflowy.toolbar.link')
-      .first;
-  expect(find.byWidget(item.icon), findsOneWidget);
-
   // trigger the link menu
   await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true);
 
@@ -215,8 +209,8 @@ Future<void> _testLinkMenuInSingleTextSelection(WidgetTester tester) async {
   final node = editor.nodeAtPath([1]) as TextNode;
   expect(
       node.allSatisfyInSelection(
-        StyleKey.href,
         selection,
+        StyleKey.href,
         (value) => value == link,
       ),
       true);
@@ -244,8 +238,8 @@ Future<void> _testLinkMenuInSingleTextSelection(WidgetTester tester) async {
 
   expect(
       node.allSatisfyInSelection(
-        StyleKey.href,
         selection,
+        StyleKey.href,
         (value) => value == link,
       ),
       false);

+ 11 - 1
frontend/app_flowy/packages/appflowy_editor/test/service/toolbar_service_test.dart

@@ -1,5 +1,6 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
+import 'package:appflowy_editor/src/render/toolbar/toolbar_item_widget.dart';
 import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart';
 import 'package:flutter_test/flutter_test.dart';
 import '../infra/test_editor.dart';
@@ -30,7 +31,16 @@ void main() async {
       final item = defaultToolbarItems
           .where((item) => item.id == 'appflowy.toolbar.link')
           .first;
-      expect(find.byWidget(item.icon), findsNothing);
+      final finder = find.byType(ToolbarItemWidget);
+
+      expect(
+        tester
+            .widgetList<ToolbarItemWidget>(finder)
+            .toList(growable: false)
+            .where((element) => element.item.id == item.id)
+            .isEmpty,
+        true,
+      );
     });
   });
 }