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 { 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: { - // 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> _actions = >{ 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 { @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 { + _ToggleTextStyleAction(this.state); + + final RawEditorState state; + + bool _isStyleActive(Attribute styleAttr, Map 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 { + _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 { + _OpenSearchAction(this.state); + + final RawEditorState state; + + @override + Future invoke(OpenSearchIntent intent, [BuildContext? context]) async { + await showDialog( + 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 { + _ApplyHeaderAction(this.state); + + final RawEditorState state; + + Attribute _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 { + _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 _onPressedHandler(BuildContext context) async { await showDialog( 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? _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 { + late String _text; + late TextEditingController _controller; + late List? _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; + }); + } +}