diff --git a/README.md b/README.md index bf62a4c0c..039538dca 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,13 @@ You can join our [Slack Group] for discussion. - [๐Ÿ“ฆ Embed Blocks](#-embed-blocks) - [๐Ÿ”„ Conversion to HTML](#-conversion-to-html) - [๐Ÿ“ Spelling checker](#-spelling-checker) +- [โœ‚๏ธ Shortcut events](#-shortcut-events) - [๐ŸŒ Translation](#-translation) - [๐Ÿงช Testing](#-testing) - [๐Ÿค Contributing](#-contributing) - [๐Ÿ“œ Acknowledgments](#-acknowledgments) + ## ๐Ÿ“ธ Screenshots
@@ -291,6 +293,16 @@ It's implemented using the package `simple_spell_checker` in the [Example](./exa Take a look at [Spelling Checker](./doc/spell_checker.md) page for more info. +## โœ‚๏ธ Shortcut events + +We can customize some Shorcut events, using the parameters `characterShortcutEvents` or `spaceShortcutEvents` from `QuillEditorConfigurations` to add more functionality to our editor. + +> [!NOTE] +> +> You can get all standard shortcuts using `standardCharactersShortcutEvents` or `standardSpaceShorcutEvents` + +To see an example of this, you can check [customizing_shortcuts](./doc/customizing_shortcuts.md) + ## ๐ŸŒ Translation The package offers translations for the quill toolbar and editor, it will follow the system locale unless you set your diff --git a/doc/customizing_shortcuts.md b/doc/customizing_shortcuts.md new file mode 100644 index 000000000..c4b88f7cc --- /dev/null +++ b/doc/customizing_shortcuts.md @@ -0,0 +1,101 @@ +# Shortcut events + +We will use a simple example to illustrate how to quickly add a `CharacterShortcutEvent` event. + +In this example, text that starts and ends with an asterisk ( * ) character will be rendered in italics for emphasis. So typing `*xxx*` will automatically be converted into _`xxx`_. + +Let's start with a empty document: + +```dart +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter/material.dart'; + +class AsteriskToItalicStyle extends StatelessWidget { + const AsteriskToItalicStyle({super.key}); + + @override + Widget build(BuildContext context) { + return QuillEditor( + scrollController: , + focusNode: , + controller: , + configurations: QuillEditorConfigurations( + characterShortcutEvents: [], + ), + ); + } +} +``` + +At this point, nothing magic will happen after typing `*xxx*`. + +

+ Editor without shortcuts gif +

+ +To implement our shortcut event we will create a `CharacterShortcutEvent` instance to handle an asterisk input. + +We need to define key and character in a `CharacterShortcutEvent` object to customize hotkeys. We recommend using the description of your event as a key. For example, if the asterisk `*` is defined to make text italic, the key can be 'Asterisk to italic'. + +```dart +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter/material.dart'; + +// [handleFormatByWrappingWithSingleCharacter] is a example function that contains +// the necessary logic to replace asterisk characters and apply correctly the +// style to the text around them + +enum SingleCharacterFormatStyle { + code, + italic, + strikethrough, +} + +CharacterShortcutEvent asteriskToItalicStyleEvent = CharacterShortcutEvent( + key: 'Asterisk to italic', + character: '*', + handler: (QuillController controller) => handleFormatByWrappingWithSingleCharacter( + controller: controller, + character: '*', + formatStyle: SingleCharacterFormatStyle.italic, + ), +); +``` + +Now our 'asterisk handler' function is done and the only task left is to inject it into the `QuillEditorConfigurations`. + +```dart +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter/material.dart'; + +class AsteriskToItalicStyle extends StatelessWidget { + const AsteriskToItalicStyle({super.key}); + + @override + Widget build(BuildContext context) { + return QuillEditor( + scrollController: , + focusNode: , + controller: , + configurations: QuillEditorConfigurations( + characterShortcutEvents: [ + asteriskToItalicStyleEvent, + ], + ), + ); + } +} + +CharacterShortcutEvent asteriskToItalicStyleEvent = CharacterShortcutEvent( + key: 'Asterisk to italic', + character: '*', + handler: (QuillController controller) => handleFormatByWrappingWithSingleCharacter( + controller: controller, + character: '*', + formatStyle: SingleCharacterFormatStyle.italic, + ), +); +``` +

+ Editor with shortcuts gif +

diff --git a/example/lib/screens/quill/quill_screen.dart b/example/lib/screens/quill/quill_screen.dart index e55e6f86b..97cb0abfe 100644 --- a/example/lib/screens/quill/quill_screen.dart +++ b/example/lib/screens/quill/quill_screen.dart @@ -124,6 +124,8 @@ class _QuillScreenState extends State { child: MyQuillEditor( controller: _controller, configurations: QuillEditorConfigurations( + characterShortcutEvents: standardCharactersShortcutEvents, + spaceShortcutEvents: standardSpaceShorcutEvents, searchConfigurations: const QuillSearchConfigurations( searchEmbedMode: SearchEmbedMode.plainText, ), diff --git a/lib/flutter_quill.dart b/lib/flutter_quill.dart index 862ef2d55..868eed6ef 100644 --- a/lib/flutter_quill.dart +++ b/lib/flutter_quill.dart @@ -21,6 +21,7 @@ export 'src/editor/editor.dart'; export 'src/editor/embed/embed_editor_builder.dart'; export 'src/editor/provider.dart'; export 'src/editor/raw_editor/builders/leading_block_builder.dart'; +export 'src/editor/raw_editor/config/events/events.dart'; export 'src/editor/raw_editor/config/raw_editor_configurations.dart'; export 'src/editor/raw_editor/quill_single_child_scroll_view.dart'; export 'src/editor/raw_editor/raw_editor.dart'; diff --git a/lib/src/editor/config/editor_configurations.dart b/lib/src/editor/config/editor_configurations.dart index f992ce958..540f483c1 100644 --- a/lib/src/editor/config/editor_configurations.dart +++ b/lib/src/editor/config/editor_configurations.dart @@ -12,6 +12,7 @@ import '../../toolbar/theme/quill_dialog_theme.dart'; import '../editor_builder.dart'; import '../embed/embed_editor_builder.dart'; import '../raw_editor/builders/leading_block_builder.dart'; +import '../raw_editor/config/events/events.dart'; import '../raw_editor/raw_editor.dart'; import '../widgets/default_styles.dart'; import '../widgets/delegate.dart'; @@ -33,6 +34,8 @@ class QuillEditorConfigurations extends Equatable { this.sharedConfigurations = const QuillSharedConfigurations(), this.scrollable = true, this.padding = EdgeInsets.zero, + this.characterShortcutEvents = const [], + this.spaceShortcutEvents = const [], this.autoFocus = false, this.expands = false, this.placeholder, @@ -57,6 +60,8 @@ class QuillEditorConfigurations extends Equatable { this.onSingleLongTapStart, this.onSingleLongTapMoveUpdate, this.onSingleLongTapEnd, + @Deprecated( + 'Use space/char shortcut events instead - enableMarkdownStyleConversion will be removed in future releases.') this.enableMarkdownStyleConversion = true, this.enableAlwaysIndentOnTab = false, this.embedBuilders, @@ -102,6 +107,52 @@ class QuillEditorConfigurations extends Equatable { /// The text placeholder in the quill editor final String? placeholder; + /// Contains all the events that will be handled when + /// the exact characters satifies the condition. This mean + /// if you press asterisk key, if you have a `CharacterShortcutEvent` with + /// the asterisk then that event will be handled + /// + /// Supported by: + /// + /// - Web + /// - Desktop + /// ### Example + ///```dart + /// // you can get also the default implemented shortcuts + /// // calling [standardSpaceShorcutEvents] + ///final defaultShorcutsImplementation = + /// List.from([...standardCharactersShortcutEvents]) + /// + ///final boldFormat = CharacterShortcutEvent( + /// key: 'Shortcut event that will format current wrapped text in asterisk' + /// character: '*', + /// handler: (controller) {...your implementation} + ///); + ///``` + final List characterShortcutEvents; + + /// Contains all the events that will be handled when + /// space key is pressed + /// + /// Supported by: + /// + /// - Web + /// - Desktop + /// + /// ### Example + ///```dart + /// // you can get also the default implemented shortcuts + /// // calling [standardSpaceShorcutEvents] + ///final defaultShorcutsImplementation = + /// List.from([...standardSpaceShorcutEvents]) + /// + ///final spaceBulletList = SpaceShortcutEvent( + /// character: '-', + /// handler: (QuillText textNode, controller) {...your implementation} + ///); + ///``` + final List spaceShortcutEvents; + /// Whether the text can be changed. /// /// When this is set to `true`, the text cannot be modified @@ -145,6 +196,10 @@ class QuillEditorConfigurations extends Equatable { /// This setting controls the behavior of input. Specifically, when enabled, /// entering '1.' followed by a space or '-' followed by a space /// will automatically convert the input into a Markdown list format. + /// + /// ## !This functionality now does not work because was replaced by a more advanced using [SpaceShortcutEvent] and [CharacterShortcutEvent] classes + @Deprecated( + 'enableMarkdownStyleConversion is no longer used and will be removed in future releases. Use space/char shortcut events instead.') final bool enableMarkdownStyleConversion; /// Enables always indenting when the TAB key is pressed. @@ -450,6 +505,8 @@ class QuillEditorConfigurations extends Equatable { LinkActionPickerDelegate? linkActionPickerDelegate, bool? floatingCursorDisabled, TextSelectionControls? textSelectionControls, + List? characterShortcutEvents, + List? spaceShortcutEvents, Future Function(Uint8List imageBytes)? onImagePaste, Future Function(Uint8List imageBytes)? onGifPaste, Map? customShortcuts, @@ -483,8 +540,13 @@ class QuillEditorConfigurations extends Equatable { disableClipboard: disableClipboard ?? this.disableClipboard, scrollable: scrollable ?? this.scrollable, scrollBottomInset: scrollBottomInset ?? this.scrollBottomInset, + characterShortcutEvents: + characterShortcutEvents ?? this.characterShortcutEvents, + spaceShortcutEvents: spaceShortcutEvents ?? this.spaceShortcutEvents, padding: padding ?? this.padding, + // ignore: deprecated_member_use_from_same_package enableMarkdownStyleConversion: + // ignore: deprecated_member_use_from_same_package enableMarkdownStyleConversion ?? this.enableMarkdownStyleConversion, enableAlwaysIndentOnTab: enableAlwaysIndentOnTab ?? this.enableAlwaysIndentOnTab, diff --git a/lib/src/editor/editor.dart b/lib/src/editor/editor.dart index 163ded0ce..617d88fbc 100644 --- a/lib/src/editor/editor.dart +++ b/lib/src/editor/editor.dart @@ -295,13 +295,14 @@ class QuillEditorState extends State key: _editorKey, controller: controller, configurations: QuillRawEditorConfigurations( + characterShortcutEvents: + widget.configurations.characterShortcutEvents, + spaceShortcutEvents: widget.configurations.spaceShortcutEvents, customLeadingBuilder: widget.configurations.customLeadingBlockBuilder, focusNode: widget.focusNode, scrollController: widget.scrollController, scrollable: configurations.scrollable, - enableMarkdownStyleConversion: - configurations.enableMarkdownStyleConversion, enableAlwaysIndentOnTab: configurations.enableAlwaysIndentOnTab, scrollBottomInset: configurations.scrollBottomInset, padding: configurations.padding, diff --git a/lib/src/editor/raw_editor/config/events/character_shortcuts_events.dart b/lib/src/editor/raw_editor/config/events/character_shortcuts_events.dart new file mode 100644 index 000000000..538e4ecee --- /dev/null +++ b/lib/src/editor/raw_editor/config/events/character_shortcuts_events.dart @@ -0,0 +1,45 @@ +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +import '../../../../../flutter_quill.dart'; + +typedef CharacterShortcutEventHandler = bool Function( + QuillController controller); + +/// Defines the implementation of shortcut event based on character. +@immutable +class CharacterShortcutEvent extends Equatable { + const CharacterShortcutEvent({ + required this.key, + required this.character, + required this.handler, + }) : assert(character.length == 1 && character != '\n', + 'character cannot be major than one char, and it must not be a new line'); + + final String key; + final String character; + final CharacterShortcutEventHandler handler; + + bool execute(QuillController controller) { + return handler(controller); + } + + CharacterShortcutEvent copyWith({ + String? key, + String? character, + CharacterShortcutEventHandler? handler, + }) { + return CharacterShortcutEvent( + key: key ?? this.key, + character: character ?? this.character, + handler: handler ?? this.handler, + ); + } + + @override + String toString() => + 'CharacterShortcutEvent(key: $key, character: $character, handler: $handler)'; + + @override + List get props => [key, character, handler]; +} diff --git a/lib/src/editor/raw_editor/config/events/events.dart b/lib/src/editor/raw_editor/config/events/events.dart new file mode 100644 index 000000000..2e534e0fc --- /dev/null +++ b/lib/src/editor/raw_editor/config/events/events.dart @@ -0,0 +1,9 @@ +// event classes +export 'character_shortcuts_events.dart'; +export 'space_shortcut_events.dart'; +// default implementation of the shortcuts +export 'standard_char_shortcuts/block_shortcut_events_handlers.dart'; +export 'standard_char_shortcuts/double_character_shortcut_events.dart'; +export 'standard_char_shortcuts/single_character_shortcut_events.dart'; +// all available shortcuts +export 'standard_char_shortcuts/standard_shortcut_events.dart'; diff --git a/lib/src/editor/raw_editor/config/events/format/format_double_character_handler.dart b/lib/src/editor/raw_editor/config/events/format/format_double_character_handler.dart new file mode 100644 index 000000000..3ba14abab --- /dev/null +++ b/lib/src/editor/raw_editor/config/events/format/format_double_character_handler.dart @@ -0,0 +1,106 @@ +import '../../../../../../quill_delta.dart'; +import '../../../../../controller/quill_controller.dart'; +import '../../../../../document/attribute.dart'; +import '../../../../../document/document.dart'; + +// We currently have only one format style is triggered by double characters. +// **abc** or __abc__ -> bold abc +enum DoubleCharacterFormatStyle { + bold, // ** | __ + strikethrough, // ~~ +} + +bool handleFormatByWrappingWithDoubleCharacter({ + // for demonstration purpose, the following comments use * to represent the character from the parameter [char]. + required QuillController controller, + required String character, + required DoubleCharacterFormatStyle formatStyle, +}) { + assert(character.length == 1, 'Expected 1 char, got ${character.length}'); + final selection = controller.selection; + // if the selection is not collapsed or the cursor is at the first three index range, we don't need to format it. + if (!selection.isCollapsed || selection.end < 4) { + return false; + } + + final plainText = controller.document.toPlainText(); + + if (plainText.isEmpty) { + return false; + } + // The plainText should have at least 4 characters,like **a* or **a*. + // The last char in the plainText should be *[char]. Otherwise, we don't need to format it. + if (plainText.length < 4 || plainText[selection.end - 1] != character) { + return false; + } + + // find all the index of *[char] + var charIndexList = []; + for (var i = selection.end - 1; i > 0; i--) { + // If we found characters that satifies our handler, and it founds + // a new line, then, need to cancel the handler + // because bold (and common styles from markdown) cannot + // be applied between different paragraphs + if (charIndexList.isNotEmpty && plainText[i] == '\n') return false; + if (plainText[i] == character) { + charIndexList.add(i); + } + if (charIndexList.length >= 3) break; + } + + if (charIndexList.length < 3) { + return false; + } + + // to fix a char list like: [5, 1, 0] we reverse the + // list to transform it as: [0, 1 ,5] + charIndexList = [...charIndexList.reversed]; + + // for example: **abc* -> [0, 1, 5] + // thirdLastCharIndex = 0, secondLastCharIndex = 1, lastCharIndex = 5 + final thirdLastCharIndex = charIndexList[charIndexList.length - 3]; + final secondLastCharIndex = charIndexList[charIndexList.length - 2]; + final lastCharIndex = charIndexList[charIndexList.length - 1]; + // make sure the third *[char] and second *[char] are connected + // make sure the second *[char] and last *[char] are split by at least one character + if (secondLastCharIndex != thirdLastCharIndex + 1 || + lastCharIndex == secondLastCharIndex + 1) { + return false; + } + + // if is needed, we can use this to get the text inside the double chars + // final offsetOfTextInsideWrapperCharsLeft = thirdLastCharIndex + (secondLastCharIndex - (thirdLastCharIndex - 1)); + // final offsetOfTextInsideWrapperCharsRight = lastCharIndex - 1; + + late final Attribute? style; + + if (formatStyle case DoubleCharacterFormatStyle.bold) { + style = const BoldAttribute(); + } else if (formatStyle case DoubleCharacterFormatStyle.strikethrough) { + style = const StrikeThroughAttribute(); + } + // 1. delete all the *[char] + // 2. update the style of the text surrounded by the double *[char] to formatted text style + final deletionDelta = Delta() + ..retain(thirdLastCharIndex) // get all text before double chars + ..delete(2) // delete both start double char + ..retain( + lastCharIndex - + (thirdLastCharIndex + + (secondLastCharIndex - (thirdLastCharIndex - 1))), + style == null + ? null + : { + style.key: style.value + }) // retain the text before last double chars and apply the styles + ..delete(1); // delete last char + + controller + ..compose( + deletionDelta, + selection, + ChangeSource.local, + ) + ..moveCursorToPosition(selection.end - 3); + return true; +} diff --git a/lib/src/editor/raw_editor/config/events/format/format_single_character_handler.dart b/lib/src/editor/raw_editor/config/events/format/format_single_character_handler.dart new file mode 100644 index 000000000..8e0e4e7a0 --- /dev/null +++ b/lib/src/editor/raw_editor/config/events/format/format_single_character_handler.dart @@ -0,0 +1,112 @@ +import '../../../../../../quill_delta.dart'; +import '../../../../../controller/quill_controller.dart'; +import '../../../../../document/attribute.dart'; +import '../../../../../document/document.dart'; + +enum SingleCharacterFormatStyle { + code, + italic, + strikethrough, +} + +// for demonstration purpose, the following comments use * to represent the character from the parameter [char]. +bool handleFormatByWrappingWithSingleCharacter({ + required QuillController controller, + required String character, + required SingleCharacterFormatStyle formatStyle, +}) { + assert(character.length == 1, 'Expected 1 char, got ${character.length}.'); + final selection = controller.selection; + // If the selection is not collapsed or the cursor is at the first two index range, we don't need to format it. + if (!selection.isCollapsed || selection.end < 2) { + return false; + } + + final plainText = controller.document.toPlainText(); + + if (plainText.isEmpty) { + return false; + } + + // The plainText should have at least 2 characters, like *a. + if (plainText.length < 2) { + return false; + } + + var lastCharIndex = -1; + // found the nearest using the caret position as base + for (var i = selection.end - 1; i > 0; i--) { + // If we found characters that satifies our handler, and it founds + // a new line, then, need to cancel the handler + // because bold (and common styles from markdown) cannot + // be applied between different paragraphs + if (plainText[i] == '\n' && lastCharIndex == -1) return false; + + if (plainText[i] == character) { + lastCharIndex = i; + break; + } + } + + // We check this because we don't know if the text that we are trying + // to detect, is at the start of the document (this is because by some weird + // reason, the for never detects the character) + if (plainText[0] == character) { + lastCharIndex = _toSafeInteger(lastCharIndex); + } + if (lastCharIndex == -1) { + return false; + } + + final textAfterLastChar = + plainText.substring(lastCharIndex + 1, selection.end); + final textAfterLastCharIsEmpty = textAfterLastChar.trim().isEmpty; + + // The following conditions won't trigger the single character formatting: + // 1. There is no 'Character' in the plainText since by default is -1. + if (textAfterLastCharIsEmpty) { + return false; + } + + // If it is in a double character case, we should skip the single character formatting. + // For example, adding * after **a*, it should skip the single character formatting and it + // will be handled by double character formatting. + if ((character == '*' || character == '_' || character == '~') && + (lastCharIndex >= 1) && + (plainText[lastCharIndex - 1] == character)) { + return false; + } + + late final Attribute? style; + + if (formatStyle case SingleCharacterFormatStyle.italic) { + style = const ItalicAttribute(); + } else if (formatStyle case SingleCharacterFormatStyle.strikethrough) { + style = const StrikeThroughAttribute(); + } else if (formatStyle case SingleCharacterFormatStyle.code) { + style = const InlineCodeAttribute(); + } + // 1. delete all the *[char] + // 2. update the style of the text surrounded by the *[char] to a formatted text style + final deletionDelta = Delta() + ..retain(lastCharIndex) // get all text before chars + ..delete(1) // delete both start char + ..retain( + (selection.end - 2) - (lastCharIndex - 1), + style == null + ? null + : { + style.key: style.value + }); // retain the text before that the new char that we type on keyboard + + controller + ..compose( + deletionDelta, + selection, + ChangeSource.local, + ) + ..moveCursorToPosition(selection.end - 1); + return true; +} + +int _toSafeInteger(int value) => value <= -1 ? 0 : value; diff --git a/lib/src/editor/raw_editor/config/events/format/format_space_shortcut_event_handler.dart b/lib/src/editor/raw_editor/config/events/format/format_space_shortcut_event_handler.dart new file mode 100644 index 000000000..a15a8422d --- /dev/null +++ b/lib/src/editor/raw_editor/config/events/format/format_space_shortcut_event_handler.dart @@ -0,0 +1,75 @@ +import '../../../../../controller/quill_controller.dart'; +import '../../../../../document/attribute.dart'; +import '../../../../../document/document.dart'; + +enum BlockFormatStyle { + todo, + bullet, + ordered, + header, +} + +bool handleFormatBlockStyleBySpaceEvent({ + required QuillController controller, + required String character, + required BlockFormatStyle formatStyle, +}) { + assert(character.trim().isNotEmpty && character != '\n', + 'Expected character that cannot be empty, a whitespace or a new line. Got $character'); + if (formatStyle == BlockFormatStyle.todo) { + _updateSelectionForKeyPhrase(character, Attribute.unchecked, controller); + return true; + } else if (formatStyle == BlockFormatStyle.bullet) { + _updateSelectionForKeyPhrase(character, Attribute.ul, controller); + return true; + } else if (formatStyle == BlockFormatStyle.ordered) { + _updateSelectionForKeyPhrase(character, Attribute.ol, controller); + return true; + } else if (formatStyle == BlockFormatStyle.header) { + var headerAttribute = Attribute.header as Attribute; + final count = _count(character, '#'); + if (count == 1) { + headerAttribute = Attribute.h1; + } else if (count == 2) { + headerAttribute = Attribute.h2; + } else if (count == 3) { + headerAttribute = Attribute.h3; + } + _updateSelectionForKeyPhrase(character, headerAttribute, controller); + return true; + } + + return false; +} + +void _updateSelectionForKeyPhrase( + String phrase, Attribute attribute, QuillController controller) { + controller.replaceText(controller.selection.baseOffset - phrase.length, + phrase.length, '\n', null); + _moveCursor(-phrase.length, controller); + controller + ..formatSelection(attribute) + // Remove the added newline. + ..replaceText(controller.selection.baseOffset + 1, 1, '', null); +} + +void _moveCursor(int chars, QuillController controller) { + final selection = controller.selection; + controller.updateSelection( + controller.selection.copyWith( + baseOffset: selection.baseOffset + chars, + extentOffset: selection.baseOffset + chars), + ChangeSource.local); +} + +int _count(String char, String matchChar) { + var count = 0; + for (var i = 0; i < char.length; i++) { + if (char[i] == matchChar) { + count++; + } else { + break; + } + } + return count; +} diff --git a/lib/src/editor/raw_editor/config/events/space_shortcut_events.dart b/lib/src/editor/raw_editor/config/events/space_shortcut_events.dart new file mode 100644 index 000000000..68def2c1e --- /dev/null +++ b/lib/src/editor/raw_editor/config/events/space_shortcut_events.dart @@ -0,0 +1,41 @@ +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; +import '../../../../controller/quill_controller.dart'; +import '../../../../document/nodes/leaf.dart'; + +typedef SpaceShortcutEventHandler = bool Function( + QuillText node, QuillController controller); + +/// Defines the implementation of shortcut events for space key calls. +@immutable +class SpaceShortcutEvent extends Equatable { + SpaceShortcutEvent({ + required this.character, + required this.handler, + }) : assert(character != '\n' && character.trim().isNotEmpty, + 'character that cannot be empty, a whitespace or a new line. Ensure that you are passing a not empty character'); + + final String character; + final SpaceShortcutEventHandler handler; + + bool execute(QuillText node, QuillController controller) { + return handler(node, controller); + } + + SpaceShortcutEvent copyWith({ + String? character, + SpaceShortcutEventHandler? handler, + }) { + return SpaceShortcutEvent( + character: character ?? this.character, + handler: handler ?? this.handler, + ); + } + + @override + String toString() => + 'SpaceShortcutEvent(character: $character, handler: $handler)'; + + @override + List get props => [character, handler]; +} diff --git a/lib/src/editor/raw_editor/config/events/standard_char_shortcuts/block_shortcut_events_handlers.dart b/lib/src/editor/raw_editor/config/events/standard_char_shortcuts/block_shortcut_events_handlers.dart new file mode 100644 index 000000000..772b8127c --- /dev/null +++ b/lib/src/editor/raw_editor/config/events/standard_char_shortcuts/block_shortcut_events_handlers.dart @@ -0,0 +1,53 @@ +import '../format/format_space_shortcut_event_handler.dart'; +import '../space_shortcut_events.dart'; + +const _orderedList = '1.'; +const _bulletList = '-'; +const _headerStyle = '#'; +const _headerStyle2 = '##'; +const _headerStyle3 = '###'; + +final SpaceShortcutEvent formatOrderedNumberToList = SpaceShortcutEvent( + character: _orderedList, + handler: (node, controller) => handleFormatBlockStyleBySpaceEvent( + controller: controller, + character: _orderedList, + formatStyle: BlockFormatStyle.ordered, + ), +); + +final SpaceShortcutEvent formatHyphenToBulletList = SpaceShortcutEvent( + character: _bulletList, + handler: (node, controller) => handleFormatBlockStyleBySpaceEvent( + controller: controller, + character: _bulletList, + formatStyle: BlockFormatStyle.bullet, + ), +); + +final SpaceShortcutEvent formatHeaderToHeaderStyle = SpaceShortcutEvent( + character: _headerStyle, + handler: (node, controller) => handleFormatBlockStyleBySpaceEvent( + controller: controller, + character: _headerStyle, + formatStyle: BlockFormatStyle.header, + ), +); + +final SpaceShortcutEvent formatHeader2ToHeaderStyle = SpaceShortcutEvent( + character: _headerStyle2, + handler: (node, controller) => handleFormatBlockStyleBySpaceEvent( + controller: controller, + character: _headerStyle2, + formatStyle: BlockFormatStyle.header, + ), +); + +final SpaceShortcutEvent formatHeader3ToHeaderStyle = SpaceShortcutEvent( + character: _headerStyle3, + handler: (node, controller) => handleFormatBlockStyleBySpaceEvent( + controller: controller, + character: _headerStyle3, + formatStyle: BlockFormatStyle.header, + ), +); diff --git a/lib/src/editor/raw_editor/config/events/standard_char_shortcuts/double_character_shortcut_events.dart b/lib/src/editor/raw_editor/config/events/standard_char_shortcuts/double_character_shortcut_events.dart new file mode 100644 index 000000000..c72b1eaab --- /dev/null +++ b/lib/src/editor/raw_editor/config/events/standard_char_shortcuts/double_character_shortcut_events.dart @@ -0,0 +1,27 @@ +import '../character_shortcuts_events.dart'; +import '../format/format_double_character_handler.dart'; + +const _asterisk = '*'; +const _underscore = '_'; + +final CharacterShortcutEvent formatDoubleAsterisksToBold = + CharacterShortcutEvent( + key: 'Format double asterisks to bold', + character: _asterisk, + handler: (controller) => handleFormatByWrappingWithDoubleCharacter( + controller: controller, + character: _asterisk, + formatStyle: DoubleCharacterFormatStyle.bold, + ), +); + +final CharacterShortcutEvent formatDoubleUnderscoresToBold = + CharacterShortcutEvent( + key: 'Format double underscores to bold', + character: _underscore, + handler: (controller) => handleFormatByWrappingWithDoubleCharacter( + controller: controller, + character: _underscore, + formatStyle: DoubleCharacterFormatStyle.bold, + ), +); diff --git a/lib/src/editor/raw_editor/config/events/standard_char_shortcuts/single_character_shortcut_events.dart b/lib/src/editor/raw_editor/config/events/standard_char_shortcuts/single_character_shortcut_events.dart new file mode 100644 index 000000000..7e8297d9b --- /dev/null +++ b/lib/src/editor/raw_editor/config/events/standard_char_shortcuts/single_character_shortcut_events.dart @@ -0,0 +1,38 @@ +import '../character_shortcuts_events.dart'; +import '../format/format_single_character_handler.dart'; + +const _asterisk = '*'; +const _strikeChar = '~'; +const _codeChar = '`'; + +final CharacterShortcutEvent formatAsterisksToItalic = CharacterShortcutEvent( + key: 'Format single asterisks to italic', + character: _asterisk, + handler: (controller) => handleFormatByWrappingWithSingleCharacter( + controller: controller, + character: _asterisk, + formatStyle: SingleCharacterFormatStyle.italic, + ), +); + +final CharacterShortcutEvent formatStrikeToStrikethrough = + CharacterShortcutEvent( + key: 'Format single strikes to strike style', + character: _strikeChar, + handler: (controller) => handleFormatByWrappingWithSingleCharacter( + controller: controller, + character: _strikeChar, + formatStyle: SingleCharacterFormatStyle.strikethrough, + ), +); + +final CharacterShortcutEvent formatCodeCharToInlineCode = + CharacterShortcutEvent( + key: 'Format single code to inline code style', + character: _codeChar, + handler: (controller) => handleFormatByWrappingWithSingleCharacter( + controller: controller, + character: _codeChar, + formatStyle: SingleCharacterFormatStyle.code, + ), +); diff --git a/lib/src/editor/raw_editor/config/events/standard_char_shortcuts/standard_shortcut_events.dart b/lib/src/editor/raw_editor/config/events/standard_char_shortcuts/standard_shortcut_events.dart new file mode 100644 index 000000000..1b92d27d4 --- /dev/null +++ b/lib/src/editor/raw_editor/config/events/standard_char_shortcuts/standard_shortcut_events.dart @@ -0,0 +1,23 @@ +import '../events.dart'; + +/// These all the common CharacterShortcutEvents that are implemented +/// by the package and correspond with markdown syntax +final standardCharactersShortcutEvents = + List.unmodifiable([ + formatAsterisksToItalic, + formatStrikeToStrikethrough, + formatCodeCharToInlineCode, + formatDoubleAsterisksToBold, + formatDoubleUnderscoresToBold, +]); + +/// These all the common SpaceShortcutEvent that are implemented +/// by the package and correspond with markdown syntax +final standardSpaceShorcutEvents = + List.unmodifiable([ + formatOrderedNumberToList, + formatHyphenToBulletList, + formatHeaderToHeaderStyle, + formatHeader2ToHeaderStyle, + formatHeader3ToHeaderStyle, +]); diff --git a/lib/src/editor/raw_editor/config/raw_editor_configurations.dart b/lib/src/editor/raw_editor/config/raw_editor_configurations.dart index 6af6a4f93..959bf2570 100644 --- a/lib/src/editor/raw_editor/config/raw_editor_configurations.dart +++ b/lib/src/editor/raw_editor/config/raw_editor_configurations.dart @@ -37,6 +37,7 @@ import '../../../editor/widgets/delegate.dart'; import '../../../editor/widgets/link.dart'; import '../../../toolbar/theme/quill_dialog_theme.dart'; import '../builders/leading_block_builder.dart'; +import 'events/events.dart'; @immutable class QuillRawEditorConfigurations extends Equatable { @@ -49,6 +50,8 @@ class QuillRawEditorConfigurations extends Equatable { required this.selectionCtrls, required this.embedBuilder, required this.autoFocus, + required this.characterShortcutEvents, + required this.spaceShortcutEvents, @Deprecated( 'controller should be passed directly to the editor - this parameter will be removed in future versions.') this.controller, @@ -71,6 +74,8 @@ class QuillRawEditorConfigurations extends Equatable { this.customActions, this.expands = false, this.isOnTapOutsideEnabled = true, + @Deprecated( + 'Use space/char shortcut events instead - enableMarkdownStyleConversion will be removed in future releases') this.enableMarkdownStyleConversion = true, this.enableAlwaysIndentOnTab = false, this.onTapOutside, @@ -108,9 +113,57 @@ class QuillRawEditorConfigurations extends Equatable { final double scrollBottomInset; final LeadingBlockNodeBuilder? customLeadingBuilder; + /// Contains all the events that will be handled when + /// the exact characters satifies the condition. This mean + /// if you press asterisk key, if you have a `CharacterShortcutEvent` with + /// the asterisk then that event will be handled + /// + /// Supported by: + /// + /// - Web + /// - Desktop + /// ### Example + ///```dart + /// // you can get also the default implemented shortcuts + /// // calling [standardSpaceShorcutEvents] + ///final defaultShorcutsImplementation = + /// List.from([...standardCharactersShortcutEvents]) + /// + ///final boldFormat = CharacterShortcutEvent( + /// key: 'Shortcut event that will format current wrapped text in asterisk' + /// character: '*', + /// handler: (controller) {...your implementation} + ///); + ///``` + final List characterShortcutEvents; + + /// Contains all the events that will be handled when + /// space key is pressed + /// + /// Supported by: + /// + /// - Web + /// - Desktop + /// + /// ### Example + ///```dart + /// // you can get also the default implemented shortcuts + /// // calling [standardSpaceShorcutEvents] + ///final defaultShorcutsImplementation = + /// List.from([...standardSpaceShorcutEvents]) + /// + ///final spaceBulletList = SpaceShortcutEvent( + /// character: '-', + /// handler: (QuillText textNode, controller) {...your implementation} + ///); + ///``` + final List spaceShortcutEvents; + /// Additional space around the editor contents. final EdgeInsetsGeometry padding; + @Deprecated( + 'enableMarkdownStyleConversion is no longer used and will be removed in future releases. Use space/char shortcut events instead.') final bool enableMarkdownStyleConversion; /// Enables always indenting when the TAB key is pressed. diff --git a/lib/src/editor/raw_editor/raw_editor_state.dart b/lib/src/editor/raw_editor/raw_editor_state.dart index 9eba64052..6cdd6f6a0 100644 --- a/lib/src/editor/raw_editor/raw_editor_state.dart +++ b/lib/src/editor/raw_editor/raw_editor_state.dart @@ -532,14 +532,14 @@ class QuillRawEditorState extends EditorState data: _styles!, child: QuillKeyboardServiceWidget( actions: _actions, + characterEvents: widget.configurations.characterShortcutEvents, + spaceEvents: widget.configurations.spaceShortcutEvents, constraints: constraints, focusNode: widget.configurations.focusNode, controller: controller, readOnly: widget.configurations.readOnly, enableAlwaysIndentOnTab: widget.configurations.enableAlwaysIndentOnTab, - enableMdConversion: - widget.configurations.enableMarkdownStyleConversion, customShortcuts: widget.configurations.customShortcuts, customActions: widget.configurations.customActions, child: child, diff --git a/lib/src/editor/widgets/keyboard_service_widget.dart b/lib/src/editor/widgets/keyboard_service_widget.dart index c576cb71f..1231fe830 100644 --- a/lib/src/editor/widgets/keyboard_service_widget.dart +++ b/lib/src/editor/widgets/keyboard_service_widget.dart @@ -10,6 +10,8 @@ import '../../document/document.dart'; import '../../document/nodes/block.dart'; import '../../document/nodes/leaf.dart' as leaf; import '../../document/nodes/line.dart'; +import '../raw_editor/config/events/character_shortcuts_events.dart'; +import '../raw_editor/config/events/space_shortcut_events.dart'; import 'default_single_activator_actions.dart'; import 'keyboard_listener.dart'; @@ -22,7 +24,8 @@ class QuillKeyboardServiceWidget extends StatelessWidget { required this.controller, required this.readOnly, required this.enableAlwaysIndentOnTab, - required this.enableMdConversion, + required this.characterEvents, + required this.spaceEvents, this.customShortcuts, this.customActions, super.key, @@ -30,8 +33,9 @@ class QuillKeyboardServiceWidget extends StatelessWidget { final bool readOnly; final bool enableAlwaysIndentOnTab; - final bool enableMdConversion; final QuillController controller; + final List characterEvents; + final List spaceEvents; final Map? customShortcuts; final Map>? customActions; final Map> actions; @@ -74,22 +78,38 @@ class QuillKeyboardServiceWidget extends StatelessWidget { return KeyEventResult.ignored; } + final isTab = event.logicalKey == LogicalKeyboardKey.tab; + final isSpace = event.logicalKey == LogicalKeyboardKey.space; + final containsSelection = + controller.selection.baseOffset != controller.selection.extentOffset; + if (!isTab && !isSpace && event.character != '\n' && !containsSelection) { + for (final charEvents in characterEvents) { + if (event.character != null && + event.character == charEvents.character) { + final executed = charEvents.execute(controller); + if (executed) { + return KeyEventResult.handled; + } + } + } + } + if (event is! KeyDownEvent) { return KeyEventResult.ignored; } // Handle indenting blocks when pressing the tab key. - if (event.logicalKey == LogicalKeyboardKey.tab) { + if (isTab) { return _handleTabKey(event); } // Don't handle key if there is an active selection. - if (controller.selection.baseOffset != controller.selection.extentOffset) { + if (containsSelection) { return KeyEventResult.ignored; } // Handle inserting lists when space is pressed following // a list initiating phrase. - if (event.logicalKey == LogicalKeyboardKey.space) { + if (isSpace) { return _handleSpaceKey(event); } @@ -113,14 +133,15 @@ class QuillKeyboardServiceWidget extends StatelessWidget { return KeyEventResult.ignored; } - const olKeyPhrase = '1.'; - const ulKeyPhrase = '-'; - - if (text.value == olKeyPhrase && enableMdConversion) { - _updateSelectionForKeyPhrase(olKeyPhrase, Attribute.ol); - } else if (text.value == ulKeyPhrase && enableMdConversion) { - _updateSelectionForKeyPhrase(ulKeyPhrase, Attribute.ul); - } else { + if (spaceEvents.isNotEmpty) { + for (final spaceEvent in spaceEvents) { + if (spaceEvent.character == text.value) { + final executed = spaceEvent.execute(text, controller); + if (executed) return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + } else if (spaceEvents.isEmpty) { return KeyEventResult.ignored; } @@ -139,7 +160,14 @@ class QuillKeyboardServiceWidget extends StatelessWidget { controller.indentSelection(!HardwareKeyboard.instance.isShiftPressed); } else { controller.replaceText(controller.selection.baseOffset, 0, '\t', null); - _moveCursor(1); + final selection = controller.selection; + controller.updateSelection( + controller.selection.copyWith( + baseOffset: selection.baseOffset + 1, + extentOffset: selection.baseOffset + 1, + ), + ChangeSource.local, + ); } return KeyEventResult.handled; } @@ -191,32 +219,4 @@ class QuillKeyboardServiceWidget extends StatelessWidget { return insertTabCharacter(); } - - void _moveCursor(int chars) { - final selection = controller.selection; - controller.updateSelection( - controller.selection.copyWith( - baseOffset: selection.baseOffset + chars, - extentOffset: selection.baseOffset + chars), - ChangeSource.local); - } - - void _updateSelectionForKeyPhrase(String phrase, Attribute attribute) { - controller.replaceText(controller.selection.baseOffset - phrase.length, - phrase.length, '\n', null); - _moveCursor(-phrase.length); - controller - ..formatSelection(attribute) - // Remove the added newline. - ..replaceText(controller.selection.baseOffset + 1, 1, '', null); - // - final style = - controller.document.collectStyle(controller.selection.baseOffset, 0); - if (style.isNotEmpty) { - for (final attr in style.values) { - controller.formatSelection(attr); - } - controller.formatSelection(attribute); - } - } }