From 6456c5b91aeef5c464804e2e2d9b0d34f3c6bf79 Mon Sep 17 00:00:00 2001
From: Benjamin Quinn <benjamin.quinn92@gmail.com>
Date: Thu, 27 Oct 2022 00:38:17 -0400
Subject: [PATCH] Add keyboard shortcuts for editor actions (#986)

---
 example/lib/pages/home_page.dart              |  19 +-
 example/linux/my_application.cc               |   2 +-
 .../Flutter/GeneratedPluginRegistrant.swift   |   2 +-
 example/pubspec.yaml                          |   4 +-
 lib/src/widgets/raw_editor.dart               | 234 +++++++++++++++++-
 lib/src/widgets/toolbar/search_button.dart    | 135 +---------
 lib/src/widgets/toolbar/search_dialog.dart    | 134 ++++++++++
 7 files changed, 373 insertions(+), 157 deletions(-)
 create mode 100644 lib/src/widgets/toolbar/search_dialog.dart

diff --git a/example/lib/pages/home_page.dart b/example/lib/pages/home_page.dart
index 15623a81..7b3c57ba 100644
--- a/example/lib/pages/home_page.dart
+++ b/example/lib/pages/home_page.dart
@@ -92,24 +92,7 @@ class _HomePageState extends State<HomePage> {
         color: Colors.grey.shade800,
         child: _buildMenuBar(context),
       ),
-      body: RawKeyboardListener(
-        focusNode: FocusNode(),
-        onKey: (event) {
-          if (event.data.isControlPressed && event.character == 'b') {
-            if (_controller!
-                .getSelectionStyle()
-                .attributes
-                .keys
-                .contains('bold')) {
-              _controller!
-                  .formatSelection(Attribute.clone(Attribute.bold, null));
-            } else {
-              _controller!.formatSelection(Attribute.bold);
-            }
-          }
-        },
-        child: _buildWelcomeEditor(context),
-      ),
+      body: _buildWelcomeEditor(context),
     );
   }
 
diff --git a/example/linux/my_application.cc b/example/linux/my_application.cc
index 8ef02f26..5f64f84a 100644
--- a/example/linux/my_application.cc
+++ b/example/linux/my_application.cc
@@ -27,7 +27,7 @@ static void my_application_activate(GApplication* application) {
   // in case the window manager does more exotic layout, e.g. tiling.
   // If running on Wayland assume the header bar will work (may need changing
   // if future cases occur).
-  gboolean use_header_bar = TRUE;
+  gboolean use_header_bar = FALSE;
 #ifdef GDK_WINDOWING_X11
   GdkScreen *screen = gtk_window_get_screen(window);
   if (GDK_IS_X11_SCREEN(screen)) {
diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift
index 886c66ec..5b11d5f3 100644
--- a/example/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -5,7 +5,7 @@
 import FlutterMacOS
 import Foundation
 
-import device_info_plus_macos
+import device_info_plus
 import pasteboard
 import path_provider_macos
 import url_launcher_macos
diff --git a/example/pubspec.yaml b/example/pubspec.yaml
index f271d0b7..caaa4ab9 100644
--- a/example/pubspec.yaml
+++ b/example/pubspec.yaml
@@ -29,8 +29,8 @@ dependencies:
   # Use with the CupertinoIcons class for iOS style icons.
   cupertino_icons: ^1.0.4
   path_provider: ^2.0.9
-  filesystem_picker: ^2.0.0
-  file_picker: ^4.6.1
+  filesystem_picker: ^3.1.0
+  file_picker: ^5.2.2
   flutter_quill:
     path: ../
   flutter_quill_extensions:
diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart
index 1b4cd939..d724bb97 100644
--- a/lib/src/widgets/raw_editor.dart
+++ b/lib/src/widgets/raw_editor.dart
@@ -4,7 +4,6 @@ import 'dart:math' as math;
 // ignore: unnecessary_import
 import 'dart:typed_data';
 
-import 'package:flutter/cupertino.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
@@ -38,6 +37,7 @@ import 'raw_editor/raw_editor_state_text_input_client_mixin.dart';
 import 'text_block.dart';
 import 'text_line.dart';
 import 'text_selection.dart';
+import 'toolbar/search_dialog.dart';
 
 class RawEditor extends StatefulWidget {
   const RawEditor(
@@ -370,13 +370,59 @@ class RawEditorState extends EditorState
       data: _styles!,
       child: Shortcuts(
         shortcuts: <LogicalKeySet, Intent>{
-          // shortcuts added for Windows platform
+          // shortcuts added for Desktop platforms.
           LogicalKeySet(LogicalKeyboardKey.escape):
               const HideSelectionToolbarIntent(),
           LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ):
               const UndoTextIntent(SelectionChangedCause.keyboard),
           LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyY):
               const RedoTextIntent(SelectionChangedCause.keyboard),
+
+          // Selection formatting.
+          LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyB):
+              const ToggleTextStyleIntent(Attribute.bold),
+          LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyU):
+              const ToggleTextStyleIntent(Attribute.underline),
+          LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyI):
+              const ToggleTextStyleIntent(Attribute.italic),
+          LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift,
+                  LogicalKeyboardKey.keyS):
+              const ToggleTextStyleIntent(Attribute.strikeThrough),
+          LogicalKeySet(
+                  LogicalKeyboardKey.control, LogicalKeyboardKey.backquote):
+              const ToggleTextStyleIntent(Attribute.inlineCode),
+          LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyL):
+              const ToggleTextStyleIntent(Attribute.ul),
+          LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyO):
+              const ToggleTextStyleIntent(Attribute.ol),
+          LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift,
+                  LogicalKeyboardKey.keyB):
+              const ToggleTextStyleIntent(Attribute.blockQuote),
+          LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift,
+                  LogicalKeyboardKey.tilde):
+              const ToggleTextStyleIntent(Attribute.codeBlock),
+          // Indent
+          LogicalKeySet(
+                  LogicalKeyboardKey.control, LogicalKeyboardKey.bracketRight):
+              const IndentSelectionIntent(true),
+          LogicalKeySet(
+                  LogicalKeyboardKey.control, LogicalKeyboardKey.bracketLeft):
+              const IndentSelectionIntent(false),
+
+          LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF):
+              const OpenSearchIntent(),
+
+          LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit1):
+              const ApplyHeaderIntent(Attribute.h1),
+          LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit2):
+              const ApplyHeaderIntent(Attribute.h2),
+          LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit3):
+              const ApplyHeaderIntent(Attribute.h3),
+          LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit0):
+              const ApplyHeaderIntent(Attribute.header),
+
+          LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.shift,
+              LogicalKeyboardKey.keyL): const ApplyCheckListIntent(),
         },
         child: Actions(
           actions: _actions,
@@ -1168,6 +1214,17 @@ class RawEditorState extends EditorState
       _UpdateTextSelectionToAdjacentLineAction<
           ExtendSelectionVerticallyToAdjacentLineIntent>(this);
 
+  late final _ToggleTextStyleAction _formatSelectionAction =
+      _ToggleTextStyleAction(this);
+
+  late final _IndentSelectionAction _indentSelectionAction =
+      _IndentSelectionAction(this);
+
+  late final _OpenSearchAction _openSearchAction = _OpenSearchAction(this);
+  late final _ApplyHeaderAction _applyHeaderAction = _ApplyHeaderAction(this);
+  late final _ApplyCheckListAction _applyCheckListAction =
+      _ApplyCheckListAction(this);
+
   late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
     DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false),
     ReplaceTextIntent: _replaceTextAction,
@@ -1214,6 +1271,13 @@ class RawEditorState extends EditorState
         _makeOverridable(_HideSelectionToolbarAction(this)),
     UndoTextIntent: _makeOverridable(_UndoKeyboardAction(this)),
     RedoTextIntent: _makeOverridable(_RedoKeyboardAction(this)),
+
+    OpenSearchIntent: _openSearchAction,
+    // Selection Formatting
+    ToggleTextStyleIntent: _formatSelectionAction,
+    IndentSelectionIntent: _indentSelectionAction,
+    ApplyHeaderIntent: _applyHeaderAction,
+    ApplyCheckListIntent: _applyCheckListAction,
   };
 
   @override
@@ -1962,3 +2026,169 @@ class _RedoKeyboardAction extends ContextAction<RedoTextIntent> {
   @override
   bool get isActionEnabled => true;
 }
+
+class ToggleTextStyleIntent extends Intent {
+  const ToggleTextStyleIntent(this.attribute);
+
+  final Attribute attribute;
+}
+
+// Toggles a text style (underline, bold, italic, strikethrough) on, or off.
+class _ToggleTextStyleAction extends Action<ToggleTextStyleIntent> {
+  _ToggleTextStyleAction(this.state);
+
+  final RawEditorState state;
+
+  bool _isStyleActive(Attribute styleAttr, Map<String, Attribute> attrs) {
+    if (styleAttr.key == Attribute.list.key) {
+      final attribute = attrs[styleAttr.key];
+      if (attribute == null) {
+        return false;
+      }
+      return attribute.value == styleAttr.value;
+    }
+    return attrs.containsKey(styleAttr.key);
+  }
+
+  @override
+  void invoke(ToggleTextStyleIntent intent, [BuildContext? context]) {
+    final isActive = _isStyleActive(
+        intent.attribute, state.controller.getSelectionStyle().attributes);
+    state.controller.formatSelection(
+        isActive ? Attribute.clone(intent.attribute, null) : intent.attribute);
+  }
+
+  @override
+  bool get isActionEnabled => true;
+}
+
+class IndentSelectionIntent extends Intent {
+  const IndentSelectionIntent(this.isIncrease);
+
+  final bool isIncrease;
+}
+
+// Toggles a text style (underline, bold, italic, strikethrough) on, or off.
+class _IndentSelectionAction extends Action<IndentSelectionIntent> {
+  _IndentSelectionAction(this.state);
+
+  final RawEditorState state;
+
+  @override
+  void invoke(IndentSelectionIntent intent, [BuildContext? context]) {
+    final indent =
+        state.controller.getSelectionStyle().attributes[Attribute.indent.key];
+    if (indent == null) {
+      if (intent.isIncrease) {
+        state.controller.formatSelection(Attribute.indentL1);
+      }
+      return;
+    }
+    if (indent.value == 1 && !intent.isIncrease) {
+      state.controller
+          .formatSelection(Attribute.clone(Attribute.indentL1, null));
+      return;
+    }
+    if (intent.isIncrease) {
+      state.controller
+          .formatSelection(Attribute.getIndentLevel(indent.value + 1));
+      return;
+    }
+    state.controller
+        .formatSelection(Attribute.getIndentLevel(indent.value - 1));
+  }
+
+  @override
+  bool get isActionEnabled => true;
+}
+
+class OpenSearchIntent extends Intent {
+  const OpenSearchIntent();
+}
+
+// Toggles a text style (underline, bold, italic, strikethrough) on, or off.
+class _OpenSearchAction extends ContextAction<OpenSearchIntent> {
+  _OpenSearchAction(this.state);
+
+  final RawEditorState state;
+
+  @override
+  Future invoke(OpenSearchIntent intent, [BuildContext? context]) async {
+    await showDialog<String>(
+      context: context!,
+      builder: (_) => SearchDialog(controller: state.controller, text: ''),
+    );
+  }
+
+  @override
+  bool get isActionEnabled => true;
+}
+
+class ApplyHeaderIntent extends Intent {
+  const ApplyHeaderIntent(this.header);
+
+  final Attribute header;
+}
+
+// Toggles a text style (underline, bold, italic, strikethrough) on, or off.
+class _ApplyHeaderAction extends Action<ApplyHeaderIntent> {
+  _ApplyHeaderAction(this.state);
+
+  final RawEditorState state;
+
+  Attribute<dynamic> _getHeaderValue() {
+    return state.controller
+            .getSelectionStyle()
+            .attributes[Attribute.header.key] ??
+        Attribute.header;
+  }
+
+  @override
+  void invoke(ApplyHeaderIntent intent, [BuildContext? context]) {
+    final _attribute =
+        _getHeaderValue() == intent.header ? Attribute.header : intent.header;
+    state.controller.formatSelection(_attribute);
+  }
+
+  @override
+  bool get isActionEnabled => true;
+}
+
+class ApplyCheckListIntent extends Intent {
+  const ApplyCheckListIntent();
+}
+
+// Toggles a text style (underline, bold, italic, strikethrough) on, or off.
+class _ApplyCheckListAction extends Action<ApplyCheckListIntent> {
+  _ApplyCheckListAction(this.state);
+
+  final RawEditorState state;
+
+  bool _getIsToggled() {
+    final attrs = state.controller.getSelectionStyle().attributes;
+    var attribute = state.controller.toolbarButtonToggler[Attribute.list.key];
+
+    if (attribute == null) {
+      attribute = attrs[Attribute.list.key];
+    } else {
+      // checkbox tapping causes controller.selection to go to offset 0
+      state.controller.toolbarButtonToggler.remove(Attribute.list.key);
+    }
+
+    if (attribute == null) {
+      return false;
+    }
+    return attribute.value == Attribute.unchecked.value ||
+        attribute.value == Attribute.checked.value;
+  }
+
+  @override
+  void invoke(ApplyCheckListIntent intent, [BuildContext? context]) {
+    state.controller.formatSelection(_getIsToggled()
+        ? Attribute.clone(Attribute.unchecked, null)
+        : Attribute.unchecked);
+  }
+
+  @override
+  bool get isActionEnabled => true;
+}
diff --git a/lib/src/widgets/toolbar/search_button.dart b/lib/src/widgets/toolbar/search_button.dart
index 52bc2068..b9436bf1 100644
--- a/lib/src/widgets/toolbar/search_button.dart
+++ b/lib/src/widgets/toolbar/search_button.dart
@@ -1,11 +1,10 @@
 import 'package:flutter/material.dart';
 
-import '../../models/documents/document.dart';
 import '../../models/themes/quill_dialog_theme.dart';
 import '../../models/themes/quill_icon_theme.dart';
-import '../../translations/toolbar.i18n.dart';
 import '../controller.dart';
 import '../toolbar.dart';
+import 'search_dialog.dart';
 
 class SearchButton extends StatelessWidget {
   const SearchButton({
@@ -52,140 +51,10 @@ class SearchButton extends StatelessWidget {
   Future<void> _onPressedHandler(BuildContext context) async {
     await showDialog<String>(
       context: context,
-      builder: (_) => _SearchDialog(
+      builder: (_) => SearchDialog(
           controller: controller, dialogTheme: dialogTheme, text: ''),
     ).then(_searchSubmitted);
   }
 
   void _searchSubmitted(String? value) {}
 }
-
-class _SearchDialog extends StatefulWidget {
-  const _SearchDialog(
-      {required this.controller, this.dialogTheme, this.text, Key? key})
-      : super(key: key);
-
-  final QuillController controller;
-  final QuillDialogTheme? dialogTheme;
-  final String? text;
-
-  @override
-  _SearchDialogState createState() => _SearchDialogState();
-}
-
-class _SearchDialogState extends State<_SearchDialog> {
-  late String _text;
-  late TextEditingController _controller;
-  late List<int>? _offsets;
-  late int _index;
-
-  @override
-  void initState() {
-    super.initState();
-    _text = widget.text ?? '';
-    _offsets = null;
-    _index = 0;
-    _controller = TextEditingController(text: _text);
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    return StatefulBuilder(builder: (context, setState) {
-      var label = '';
-      if (_offsets != null) {
-        label = '${_offsets!.length} ${'matches'.i18n}';
-        if (_offsets!.isNotEmpty) {
-          label += ', ${'showing match'.i18n} ${_index + 1}';
-        }
-      }
-      return AlertDialog(
-        backgroundColor: widget.dialogTheme?.dialogBackgroundColor,
-        content: Container(
-          height: 100,
-          child: Column(
-            children: [
-              TextField(
-                keyboardType: TextInputType.multiline,
-                style: widget.dialogTheme?.inputTextStyle,
-                decoration: InputDecoration(
-                    labelText: 'Search'.i18n,
-                    labelStyle: widget.dialogTheme?.labelTextStyle,
-                    floatingLabelStyle: widget.dialogTheme?.labelTextStyle),
-                autofocus: true,
-                onChanged: _textChanged,
-                controller: _controller,
-              ),
-              if (_offsets != null)
-                Padding(
-                  padding: const EdgeInsets.all(8),
-                  child: Text(label, textAlign: TextAlign.left),
-                ),
-            ],
-          ),
-        ),
-        actions: [
-          if (_offsets != null && _offsets!.isNotEmpty && _index > 0)
-            TextButton(
-              onPressed: () {
-                setState(() {
-                  _index -= 1;
-                });
-                _moveToPosition();
-              },
-              child: Text(
-                'Prev'.i18n,
-                style: widget.dialogTheme?.labelTextStyle,
-              ),
-            ),
-          if (_offsets != null &&
-              _offsets!.isNotEmpty &&
-              _index < _offsets!.length - 1)
-            TextButton(
-              onPressed: () {
-                setState(() {
-                  _index += 1;
-                });
-                _moveToPosition();
-              },
-              child: Text(
-                'Next'.i18n,
-                style: widget.dialogTheme?.labelTextStyle,
-              ),
-            ),
-          if (_offsets == null && _text.isNotEmpty)
-            TextButton(
-              onPressed: () {
-                setState(() {
-                  _offsets = widget.controller.document.search(_text);
-                  _index = 0;
-                });
-                if (_offsets!.isNotEmpty) {
-                  _moveToPosition();
-                }
-              },
-              child: Text(
-                'Ok'.i18n,
-                style: widget.dialogTheme?.labelTextStyle,
-              ),
-            ),
-        ],
-      );
-    });
-  }
-
-  void _moveToPosition() {
-    widget.controller.updateSelection(
-        TextSelection(
-            baseOffset: _offsets![_index],
-            extentOffset: _offsets![_index] + _text.length),
-        ChangeSource.LOCAL);
-  }
-
-  void _textChanged(String value) {
-    setState(() {
-      _text = value;
-      _offsets = null;
-      _index = 0;
-    });
-  }
-}
diff --git a/lib/src/widgets/toolbar/search_dialog.dart b/lib/src/widgets/toolbar/search_dialog.dart
new file mode 100644
index 00000000..84b40ef4
--- /dev/null
+++ b/lib/src/widgets/toolbar/search_dialog.dart
@@ -0,0 +1,134 @@
+import 'package:flutter/material.dart';
+
+import '../../../flutter_quill.dart' hide Text;
+import '../../../translations.dart';
+
+class SearchDialog extends StatefulWidget {
+  const SearchDialog(
+      {required this.controller, this.dialogTheme, this.text, Key? key})
+      : super(key: key);
+
+  final QuillController controller;
+  final QuillDialogTheme? dialogTheme;
+  final String? text;
+
+  @override
+  _SearchDialogState createState() => _SearchDialogState();
+}
+
+class _SearchDialogState extends State<SearchDialog> {
+  late String _text;
+  late TextEditingController _controller;
+  late List<int>? _offsets;
+  late int _index;
+
+  @override
+  void initState() {
+    super.initState();
+    _text = widget.text ?? '';
+    _offsets = null;
+    _index = 0;
+    _controller = TextEditingController(text: _text);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return StatefulBuilder(builder: (context, setState) {
+      var label = '';
+      if (_offsets != null) {
+        label = '${_offsets!.length} ${'matches'.i18n}';
+        if (_offsets!.isNotEmpty) {
+          label += ', ${'showing match'.i18n} ${_index + 1}';
+        }
+      }
+      return AlertDialog(
+        backgroundColor: widget.dialogTheme?.dialogBackgroundColor,
+        content: Container(
+          height: 100,
+          child: Column(
+            children: [
+              TextField(
+                keyboardType: TextInputType.multiline,
+                style: widget.dialogTheme?.inputTextStyle,
+                decoration: InputDecoration(
+                    labelText: 'Search'.i18n,
+                    labelStyle: widget.dialogTheme?.labelTextStyle,
+                    floatingLabelStyle: widget.dialogTheme?.labelTextStyle),
+                autofocus: true,
+                onChanged: _textChanged,
+                controller: _controller,
+              ),
+              if (_offsets != null)
+                Padding(
+                  padding: const EdgeInsets.all(8),
+                  child: Text(label, textAlign: TextAlign.left),
+                ),
+            ],
+          ),
+        ),
+        actions: [
+          if (_offsets != null && _offsets!.isNotEmpty && _index > 0)
+            TextButton(
+              onPressed: () {
+                setState(() {
+                  _index -= 1;
+                });
+                _moveToPosition();
+              },
+              child: Text(
+                'Prev'.i18n,
+                style: widget.dialogTheme?.labelTextStyle,
+              ),
+            ),
+          if (_offsets != null &&
+              _offsets!.isNotEmpty &&
+              _index < _offsets!.length - 1)
+            TextButton(
+              onPressed: () {
+                setState(() {
+                  _index += 1;
+                });
+                _moveToPosition();
+              },
+              child: Text(
+                'Next'.i18n,
+                style: widget.dialogTheme?.labelTextStyle,
+              ),
+            ),
+          if (_offsets == null && _text.isNotEmpty)
+            TextButton(
+              onPressed: () {
+                setState(() {
+                  _offsets = widget.controller.document.search(_text);
+                  _index = 0;
+                });
+                if (_offsets!.isNotEmpty) {
+                  _moveToPosition();
+                }
+              },
+              child: Text(
+                'Ok'.i18n,
+                style: widget.dialogTheme?.labelTextStyle,
+              ),
+            ),
+        ],
+      );
+    });
+  }
+
+  void _moveToPosition() {
+    widget.controller.updateSelection(
+        TextSelection(
+            baseOffset: _offsets![_index],
+            extentOffset: _offsets![_index] + _text.length),
+        ChangeSource.LOCAL);
+  }
+
+  void _textChanged(String value) {
+    setState(() {
+      _text = value;
+      _offsets = null;
+      _index = 0;
+    });
+  }
+}