diff --git a/super_editor/example/lib/demos/flutter_features/textinputclient/barebones_ios_text_input_client.dart b/super_editor/example/lib/demos/flutter_features/textinputclient/barebones_ios_text_input_client.dart index 455d4be214..1b8448b21d 100644 --- a/super_editor/example/lib/demos/flutter_features/textinputclient/barebones_ios_text_input_client.dart +++ b/super_editor/example/lib/demos/flutter_features/textinputclient/barebones_ios_text_input_client.dart @@ -4,7 +4,7 @@ import 'package:super_text_layout/super_text_layout.dart'; /// Demo that displays a very limited iOS text field, constructed from /// the ground up, using [TextInput] for user interaction instead -/// of a [RawKeyboardListener] or similar. +/// of a [KeyboardListener] or similar. class BarebonesIosTextInputClientDemo extends StatefulWidget { @override State createState() => _BarebonesIosTextInputClientDemoState(); diff --git a/super_editor/example/lib/demos/flutter_features/textinputclient/basic_text_input_client.dart b/super_editor/example/lib/demos/flutter_features/textinputclient/basic_text_input_client.dart index 704c2a955c..aa78742c97 100644 --- a/super_editor/example/lib/demos/flutter_features/textinputclient/basic_text_input_client.dart +++ b/super_editor/example/lib/demos/flutter_features/textinputclient/basic_text_input_client.dart @@ -4,7 +4,7 @@ import 'package:super_text_layout/super_text_layout.dart'; /// Demo that displays a very limited text field, constructed from /// the ground up, and using [TextInput] for user interaction instead -/// of a [RawKeyboardListener] or similar. +/// of a [KeyboardListener] or similar. class BasicTextInputClientDemo extends StatefulWidget { @override State createState() => _BasicTextInputClientDemoState(); diff --git a/super_editor/lib/src/default_editor/blockquote.dart b/super_editor/lib/src/default_editor/blockquote.dart index bb5e998a5f..9d92ccacfa 100644 --- a/super_editor/lib/src/default_editor/blockquote.dart +++ b/super_editor/lib/src/default_editor/blockquote.dart @@ -245,13 +245,13 @@ class ConvertBlockquoteToParagraphCommand implements EditCommand { ExecutionInstruction insertNewlineInBlockquote({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { if (keyEvent.logicalKey != LogicalKeyboardKey.enter) { return ExecutionInstruction.continueExecution; } - if (!keyEvent.isShiftPressed) { + if (!HardwareKeyboard.instance.isShiftPressed) { return ExecutionInstruction.continueExecution; } @@ -277,7 +277,7 @@ ExecutionInstruction insertNewlineInBlockquote({ ExecutionInstruction splitBlockquoteWhenEnterPressed({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { if (keyEvent.logicalKey != LogicalKeyboardKey.enter) { return ExecutionInstruction.continueExecution; diff --git a/super_editor/lib/src/default_editor/document_gestures_mouse.dart b/super_editor/lib/src/default_editor/document_gestures_mouse.dart index 7ac62feb5e..579eaf9db8 100644 --- a/super_editor/lib/src/default_editor/document_gestures_mouse.dart +++ b/super_editor/lib/src/default_editor/document_gestures_mouse.dart @@ -164,9 +164,9 @@ class _DocumentMouseInteractorState extends State with } bool get _isShiftPressed => - (RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftLeft) || - RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftRight) || - RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shift)) && + (HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftLeft) || + HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftRight) || + HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shift)) && // TODO: this condition doesn't belong here. Move it to where it applies _currentSelection != null; diff --git a/super_editor/lib/src/default_editor/document_hardware_keyboard/document_keyboard_actions.dart b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_keyboard_actions.dart index f7564bbc4e..a54dcd41f2 100644 --- a/super_editor/lib/src/default_editor/document_hardware_keyboard/document_keyboard_actions.dart +++ b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_keyboard_actions.dart @@ -1,3 +1,6 @@ +import 'dart:math'; + +import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document.dart'; @@ -12,9 +15,156 @@ import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/keyboard.dart'; import 'package:super_editor/src/infrastructure/text_input.dart'; +/// Scrolls up by the viewport height, or as high as possible, +/// when the user presses the Page Up key. +ExecutionInstruction scrollOnPageUpKeyPress({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey.keyId != LogicalKeyboardKey.pageUp.keyId) { + return ExecutionInstruction.continueExecution; + } + + final scroller = editContext.scroller; + + scroller.animateTo( + max(scroller.scrollOffset - scroller.viewportDimension, scroller.minScrollExtent), + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); + + return ExecutionInstruction.haltExecution; +} + +/// Scrolls down by the viewport height, or as far as possible, +/// when the user presses the Page Down key. +ExecutionInstruction scrollOnPageDownKeyPress({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey.keyId != LogicalKeyboardKey.pageDown.keyId) { + return ExecutionInstruction.continueExecution; + } + + final scroller = editContext.scroller; + + scroller.animateTo( + min(scroller.scrollOffset + scroller.viewportDimension, scroller.maxScrollExtent), + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); + + return ExecutionInstruction.haltExecution; +} + +/// Scrolls the viewport to the top of the content, when the user presses +/// CMD + HOME on Mac, or CTRL + HOME on all other platforms. +ExecutionInstruction scrollOnCtrlOrCmdAndHomeKeyPress({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.home) { + return ExecutionInstruction.continueExecution; + } + + final isMacOrIos = defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.iOS; + + if (isMacOrIos && !HardwareKeyboard.instance.isMetaPressed) { + return ExecutionInstruction.continueExecution; + } + + if (!isMacOrIos && !HardwareKeyboard.instance.isControlPressed) { + return ExecutionInstruction.continueExecution; + } + + final scroller = editContext.scroller; + + scroller.animateTo( + scroller.minScrollExtent, + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); + + return ExecutionInstruction.haltExecution; +} + +/// Scrolls the viewport to the bottom of the content, when the user presses +/// CMD + END on Mac, or CTRL + END on all other platforms. +ExecutionInstruction scrollOnCtrlOrCmdAndEndKeyPress({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.end) { + return ExecutionInstruction.continueExecution; + } + + final isMacOrIos = defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.iOS; + + if (isMacOrIos && !HardwareKeyboard.instance.isMetaPressed) { + return ExecutionInstruction.continueExecution; + } + + if (!isMacOrIos && !HardwareKeyboard.instance.isControlPressed) { + return ExecutionInstruction.continueExecution; + } + + final scroller = editContext.scroller; + + if (!scroller.maxScrollExtent.isFinite) { + // Can't scroll to infinity, but we technically handled the task. + return ExecutionInstruction.haltExecution; + } + + scroller.animateTo( + scroller.maxScrollExtent, + duration: const Duration(milliseconds: 150), + curve: Curves.decelerate, + ); + + return ExecutionInstruction.haltExecution; +} + +/// Halt execution of the current key event if the key pressed is one of +/// the functions keys (F1, F2, F3, etc.), or the Page Up/Down, Home/End key. +/// +/// Without this action in place pressing one of the above mentioned keys +/// would display an unknown '?' character in the document. +ExecutionInstruction blockControlKeys({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent.logicalKey == LogicalKeyboardKey.escape || + keyEvent.logicalKey == LogicalKeyboardKey.pageUp || + keyEvent.logicalKey == LogicalKeyboardKey.pageDown || + keyEvent.logicalKey == LogicalKeyboardKey.home || + keyEvent.logicalKey == LogicalKeyboardKey.end || + (keyEvent.logicalKey.keyId >= LogicalKeyboardKey.f1.keyId && + keyEvent.logicalKey.keyId <= LogicalKeyboardKey.f23.keyId)) { + return ExecutionInstruction.haltExecution; + } + + return ExecutionInstruction.continueExecution; +} + ExecutionInstruction toggleInteractionModeWhenCmdOrCtrlPressed({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { if (keyEvent.isPrimaryShortcutKeyPressed && !editContext.composer.isInInteractionMode.value) { editorKeyLog.fine("Activating editor interaction mode"); @@ -37,9 +187,9 @@ ExecutionInstruction toggleInteractionModeWhenCmdOrCtrlPressed({ ExecutionInstruction doNothingWhenThereIsNoSelection({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -50,11 +200,49 @@ ExecutionInstruction doNothingWhenThereIsNoSelection({ } } +ExecutionInstruction sendKeyEventToMacOs({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (defaultTargetPlatform == TargetPlatform.macOS && !isWeb) { + // On macOS, we let the IME handle all key events. Then, the IME might generate + // selectors which express the user intent, e.g, moveLeftAndModifySelection:. + // + // For the full list of selectors handled by SuperEditor, see the MacOsSelectors class. + // + // This is needed for the interaction with the accent panel to work. + return ExecutionInstruction.blocked; + } + + return ExecutionInstruction.continueExecution; +} + +ExecutionInstruction deleteDownstreamCharacterWithCtrlDeleteOnMac({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (defaultTargetPlatform != TargetPlatform.macOS) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.delete || !HardwareKeyboard.instance.isControlPressed) { + return ExecutionInstruction.continueExecution; + } + + final didDelete = editContext.commonOps.deleteDownstream(); + + return didDelete ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; +} + ExecutionInstruction pasteWhenCmdVIsPressed({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -72,9 +260,9 @@ ExecutionInstruction pasteWhenCmdVIsPressed({ ExecutionInstruction selectAllWhenCmdAIsPressed({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -88,9 +276,9 @@ ExecutionInstruction selectAllWhenCmdAIsPressed({ ExecutionInstruction copyWhenCmdCIsPressed({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -112,9 +300,9 @@ ExecutionInstruction copyWhenCmdCIsPressed({ ExecutionInstruction cutWhenCmdXIsPressed({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -136,9 +324,9 @@ ExecutionInstruction cutWhenCmdXIsPressed({ ExecutionInstruction cmdBToToggleBold({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -157,9 +345,9 @@ ExecutionInstruction cmdBToToggleBold({ ExecutionInstruction cmdIToToggleItalics({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -178,9 +366,9 @@ ExecutionInstruction cmdIToToggleItalics({ ExecutionInstruction anyCharacterOrDestructiveKeyToDeleteSelection({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -190,7 +378,7 @@ ExecutionInstruction anyCharacterOrDestructiveKeyToDeleteSelection({ // Do nothing if CMD or CTRL are pressed because this signifies an attempted // shortcut. - if (keyEvent.isControlPressed || keyEvent.isMetaPressed) { + if (HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed) { return ExecutionInstruction.continueExecution; } @@ -204,7 +392,7 @@ ExecutionInstruction anyCharacterOrDestructiveKeyToDeleteSelection({ // needs to alter the selection, not delete content. We have to explicitly // look for this because when shift is pressed along with an arrow key, // Flutter reports a non-null character. - if (keyEvent.isShiftPressed) { + if (HardwareKeyboard.instance.isShiftPressed) { return ExecutionInstruction.continueExecution; } @@ -233,11 +421,11 @@ ExecutionInstruction anyCharacterOrDestructiveKeyToDeleteSelection({ return ExecutionInstruction.haltExecution; } -ExecutionInstruction backspaceToRemoveUpstreamContent({ +ExecutionInstruction deleteUpstreamContentWithBackspace({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -245,10 +433,6 @@ ExecutionInstruction backspaceToRemoveUpstreamContent({ return ExecutionInstruction.continueExecution; } - if (keyEvent.isMetaPressed || keyEvent.isAltPressed) { - return ExecutionInstruction.continueExecution; - } - final didDelete = editContext.commonOps.deleteUpstream(); return didDelete ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -256,9 +440,9 @@ ExecutionInstruction backspaceToRemoveUpstreamContent({ ExecutionInstruction mergeNodeWithNextWhenDeleteIsPressed({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } if (keyEvent.logicalKey != LogicalKeyboardKey.delete) { @@ -308,9 +492,9 @@ ExecutionInstruction mergeNodeWithNextWhenDeleteIsPressed({ ExecutionInstruction moveUpAndDownWithArrowKeys({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -322,19 +506,46 @@ ExecutionInstruction moveUpAndDownWithArrowKeys({ return ExecutionInstruction.continueExecution; } - if (defaultTargetPlatform == TargetPlatform.windows && keyEvent.isAltPressed) { + if (isWeb && (editContext.composer.composingRegion.value != null)) { + // We are composing a character on web. It's possible that a native element is being displayed, + // like an emoji picker or a character selection panel. + // We need to let the OS handle the key so the user can navigate + // on the list of possible characters. + // TODO: update this after https://github.com/flutter/flutter/issues/134268 is resolved. + return ExecutionInstruction.blocked; + } + + if (defaultTargetPlatform == TargetPlatform.windows && HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } - if (defaultTargetPlatform == TargetPlatform.linux && keyEvent.isAltPressed) { + if (defaultTargetPlatform == TargetPlatform.linux && HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } bool didMove = false; if (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp) { - didMove = editContext.commonOps.moveCaretUp(expand: keyEvent.isShiftPressed); + if (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isAltPressed) { + didMove = editContext.commonOps.moveCaretUpstream( + expand: HardwareKeyboard.instance.isShiftPressed, + movementModifier: MovementModifier.paragraph, + ); + } else if (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isMetaPressed) { + didMove = editContext.commonOps.moveSelectionToBeginningOfDocument(expand: HardwareKeyboard.instance.isShiftPressed); + } else { + didMove = editContext.commonOps.moveCaretUp(expand: HardwareKeyboard.instance.isShiftPressed); + } } else { - didMove = editContext.commonOps.moveCaretDown(expand: keyEvent.isShiftPressed); + if (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isAltPressed) { + didMove = editContext.commonOps.moveCaretDownstream( + expand: HardwareKeyboard.instance.isShiftPressed, + movementModifier: MovementModifier.paragraph, + ); + } else if (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isMetaPressed) { + didMove = editContext.commonOps.moveSelectionToEndOfDocument(expand: HardwareKeyboard.instance.isShiftPressed); + } else { + didMove = editContext.commonOps.moveCaretDown(expand: HardwareKeyboard.instance.isShiftPressed); + } } return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -342,9 +553,9 @@ ExecutionInstruction moveUpAndDownWithArrowKeys({ ExecutionInstruction moveLeftAndRightWithArrowKeys({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -356,31 +567,40 @@ ExecutionInstruction moveLeftAndRightWithArrowKeys({ return ExecutionInstruction.continueExecution; } - if (defaultTargetPlatform == TargetPlatform.windows && keyEvent.isAltPressed) { + if (isWeb && (editContext.composer.composingRegion.value != null)) { + // We are composing a character on web. It's possible that a native element is being displayed, + // like an emoji picker or a character selection panel. + // We need to let the OS handle the key so the user can navigate + // on the list of possible characters. + // TODO: update this after https://github.com/flutter/flutter/issues/134268 is resolved. + return ExecutionInstruction.blocked; + } + + if (defaultTargetPlatform == TargetPlatform.windows && HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } bool didMove = false; MovementModifier? movementModifier; if ((defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux) && - keyEvent.isControlPressed) { + HardwareKeyboard.instance.isControlPressed) { movementModifier = MovementModifier.word; - } else if (defaultTargetPlatform == TargetPlatform.macOS && keyEvent.isMetaPressed) { + } else if (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isMetaPressed) { movementModifier = MovementModifier.line; - } else if (defaultTargetPlatform == TargetPlatform.macOS && keyEvent.isAltPressed) { + } else if (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isAltPressed) { movementModifier = MovementModifier.word; } if (keyEvent.logicalKey == LogicalKeyboardKey.arrowLeft) { // Move the caret left/upstream. didMove = editContext.commonOps.moveCaretUpstream( - expand: keyEvent.isShiftPressed, + expand: HardwareKeyboard.instance.isShiftPressed, movementModifier: movementModifier, ); } else { // Move the caret right/downstream. didMove = editContext.commonOps.moveCaretDownstream( - expand: keyEvent.isShiftPressed, + expand: HardwareKeyboard.instance.isShiftPressed, movementModifier: movementModifier, ); } @@ -390,13 +610,13 @@ ExecutionInstruction moveLeftAndRightWithArrowKeys({ ExecutionInstruction doNothingWithLeftRightArrowKeysAtMiddleOfTextOnWeb({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { if (!isWeb) { return ExecutionInstruction.continueExecution; } - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -408,12 +628,12 @@ ExecutionInstruction doNothingWithLeftRightArrowKeysAtMiddleOfTextOnWeb({ return ExecutionInstruction.continueExecution; } - if (defaultTargetPlatform == TargetPlatform.windows && keyEvent.isAltPressed) { + if (defaultTargetPlatform == TargetPlatform.windows && HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } if (defaultTargetPlatform == TargetPlatform.linux && - keyEvent.isAltPressed && + HardwareKeyboard.instance.isAltPressed && (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp || keyEvent.logicalKey == LogicalKeyboardKey.arrowDown)) { return ExecutionInstruction.continueExecution; } @@ -456,9 +676,9 @@ ExecutionInstruction doNothingWithLeftRightArrowKeysAtMiddleOfTextOnWeb({ ExecutionInstruction moveToLineStartOrEndWithCtrlAOrE({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -466,21 +686,21 @@ ExecutionInstruction moveToLineStartOrEndWithCtrlAOrE({ return ExecutionInstruction.continueExecution; } - if (!keyEvent.isControlPressed) { + if (!HardwareKeyboard.instance.isControlPressed) { return ExecutionInstruction.continueExecution; } bool didMove = false; if (keyEvent.logicalKey == LogicalKeyboardKey.keyA) { didMove = editContext.commonOps.moveCaretUpstream( - expand: keyEvent.isShiftPressed, + expand: HardwareKeyboard.instance.isShiftPressed, movementModifier: MovementModifier.line, ); } if (keyEvent.logicalKey == LogicalKeyboardKey.keyE) { didMove = editContext.commonOps.moveCaretDownstream( - expand: keyEvent.isShiftPressed, + expand: HardwareKeyboard.instance.isShiftPressed, movementModifier: MovementModifier.line, ); } @@ -490,9 +710,9 @@ ExecutionInstruction moveToLineStartOrEndWithCtrlAOrE({ ExecutionInstruction moveToLineStartWithHome({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -503,7 +723,7 @@ ExecutionInstruction moveToLineStartWithHome({ bool didMove = false; if (keyEvent.logicalKey == LogicalKeyboardKey.home) { didMove = editContext.commonOps.moveCaretUpstream( - expand: keyEvent.isShiftPressed, + expand: HardwareKeyboard.instance.isShiftPressed, movementModifier: MovementModifier.line, ); } @@ -513,9 +733,9 @@ ExecutionInstruction moveToLineStartWithHome({ ExecutionInstruction moveToLineEndWithEnd({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -526,7 +746,7 @@ ExecutionInstruction moveToLineEndWithEnd({ bool didMove = false; if (keyEvent.logicalKey == LogicalKeyboardKey.end) { didMove = editContext.commonOps.moveCaretDownstream( - expand: keyEvent.isShiftPressed, + expand: HardwareKeyboard.instance.isShiftPressed, movementModifier: MovementModifier.line, ); } @@ -534,14 +754,17 @@ ExecutionInstruction moveToLineEndWithEnd({ return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; } -ExecutionInstruction deleteLineWithCmdBksp({ +ExecutionInstruction deleteToStartOfLineWithCmdBackspaceOnMac({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } + if (defaultTargetPlatform != TargetPlatform.macOS) { + return ExecutionInstruction.continueExecution; + } if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.backspace) { return ExecutionInstruction.continueExecution; } @@ -564,15 +787,51 @@ ExecutionInstruction deleteLineWithCmdBksp({ return ExecutionInstruction.continueExecution; } -ExecutionInstruction deleteWordWithAltBksp({ +ExecutionInstruction deleteToEndOfLineWithCmdDeleteOnMac({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (defaultTargetPlatform != TargetPlatform.macOS) { + return ExecutionInstruction.continueExecution; + } + if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.delete) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + + bool didMove = false; + + didMove = editContext.commonOps.moveCaretDownstream( + expand: true, + movementModifier: MovementModifier.line, + ); + + if (didMove) { + return editContext.commonOps.deleteSelection() + ? ExecutionInstruction.haltExecution + : ExecutionInstruction.continueExecution; + } + return ExecutionInstruction.continueExecution; +} + +ExecutionInstruction deleteWordUpstreamWithAltBackspaceOnMac({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } - if (!keyEvent.isAltPressed || keyEvent.logicalKey != LogicalKeyboardKey.backspace) { + if (defaultTargetPlatform != TargetPlatform.macOS) { + return ExecutionInstruction.continueExecution; + } + if (!HardwareKeyboard.instance.isAltPressed || keyEvent.logicalKey != LogicalKeyboardKey.backspace) { return ExecutionInstruction.continueExecution; } if (editContext.composer.selection == null) { @@ -594,14 +853,113 @@ ExecutionInstruction deleteWordWithAltBksp({ return ExecutionInstruction.continueExecution; } +ExecutionInstruction deleteWordUpstreamWithControlBackspaceOnWindowsAndLinux({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { + return ExecutionInstruction.continueExecution; + } + if (!HardwareKeyboard.instance.isControlPressed || keyEvent.logicalKey != LogicalKeyboardKey.backspace) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + + bool didMove = false; + + didMove = editContext.commonOps.moveCaretUpstream( + expand: true, + movementModifier: MovementModifier.word, + ); + + if (didMove) { + return editContext.commonOps.deleteSelection() + ? ExecutionInstruction.haltExecution + : ExecutionInstruction.continueExecution; + } + return ExecutionInstruction.continueExecution; +} + +ExecutionInstruction deleteWordDownstreamWithAltDeleteOnMac({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (defaultTargetPlatform != TargetPlatform.macOS) { + return ExecutionInstruction.continueExecution; + } + if (!HardwareKeyboard.instance.isAltPressed || keyEvent.logicalKey != LogicalKeyboardKey.delete) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + + bool didMove = false; + + didMove = editContext.commonOps.moveCaretDownstream( + expand: true, + movementModifier: MovementModifier.word, + ); + + if (didMove) { + return editContext.commonOps.deleteSelection() + ? ExecutionInstruction.haltExecution + : ExecutionInstruction.continueExecution; + } + return ExecutionInstruction.continueExecution; +} + +ExecutionInstruction deleteWordDownstreamWithControlDeleteOnWindowsAndLinux({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { + return ExecutionInstruction.continueExecution; + } + if (!HardwareKeyboard.instance.isControlPressed || keyEvent.logicalKey != LogicalKeyboardKey.delete) { + return ExecutionInstruction.continueExecution; + } + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + + bool didMove = false; + + didMove = editContext.commonOps.moveCaretDownstream( + expand: true, + movementModifier: MovementModifier.word, + ); + + if (didMove) { + return editContext.commonOps.deleteSelection() + ? ExecutionInstruction.haltExecution + : ExecutionInstruction.continueExecution; + } + return ExecutionInstruction.continueExecution; +} + /// When the ESC key is pressed, the editor should collapse the expanded selection. /// /// Do nothing if selection is already collapsed. ExecutionInstruction collapseSelectionWhenEscIsPressed({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } diff --git a/super_editor/lib/src/default_editor/document_hardware_keyboard/document_physical_keyboard.dart b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_physical_keyboard.dart index 827f6b542e..bb0cf754a3 100644 --- a/super_editor/lib/src/default_editor/document_hardware_keyboard/document_physical_keyboard.dart +++ b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_physical_keyboard.dart @@ -68,7 +68,7 @@ class _SuperEditorHardwareKeyHandlerState extends State keys) { return ({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { for (final key in keys) { - if (key.accepts(keyEvent, RawKeyboard.instance)) { + if (key.accepts(keyEvent, HardwareKeyboard.instance)) { return ExecutionInstruction.blocked; } } diff --git a/super_editor/lib/src/default_editor/list_items.dart b/super_editor/lib/src/default_editor/list_items.dart index 8765d6bbc9..0bb3436fa7 100644 --- a/super_editor/lib/src/default_editor/list_items.dart +++ b/super_editor/lib/src/default_editor/list_items.dart @@ -706,16 +706,16 @@ class SplitListItemCommand implements EditCommand { ExecutionInstruction tabToIndentListItem({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } if (keyEvent.logicalKey != LogicalKeyboardKey.tab) { return ExecutionInstruction.continueExecution; } - if (keyEvent.isShiftPressed) { + if (HardwareKeyboard.instance.isShiftPressed) { return ExecutionInstruction.continueExecution; } @@ -726,16 +726,16 @@ ExecutionInstruction tabToIndentListItem({ ExecutionInstruction shiftTabToUnIndentListItem({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } if (keyEvent.logicalKey != LogicalKeyboardKey.tab) { return ExecutionInstruction.continueExecution; } - if (!keyEvent.isShiftPressed) { + if (!HardwareKeyboard.instance.isShiftPressed) { return ExecutionInstruction.continueExecution; } @@ -746,9 +746,9 @@ ExecutionInstruction shiftTabToUnIndentListItem({ ExecutionInstruction backspaceToUnIndentListItem({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -778,9 +778,9 @@ ExecutionInstruction backspaceToUnIndentListItem({ ExecutionInstruction splitListItemWhenEnterPressed({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } diff --git a/super_editor/lib/src/default_editor/paragraph.dart b/super_editor/lib/src/default_editor/paragraph.dart index 633b30a2cc..47b1f9f701 100644 --- a/super_editor/lib/src/default_editor/paragraph.dart +++ b/super_editor/lib/src/default_editor/paragraph.dart @@ -3,15 +3,17 @@ import 'package:flutter/painting.dart'; import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document.dart'; import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/core/document_selection.dart'; import 'package:super_editor/src/core/edit_context.dart'; import 'package:super_editor/src/core/editor.dart'; import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; import 'package:super_editor/src/default_editor/text.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; import 'package:super_editor/src/infrastructure/keyboard.dart'; -import 'package:super_editor/src/infrastructure/raw_key_event_extensions.dart'; +import 'package:super_editor/src/infrastructure/key_event_extensions.dart'; import 'package:super_editor/src/infrastructure/text_input.dart'; import 'layout_single_column/layout_single_column.dart'; @@ -123,6 +125,8 @@ class ParagraphComponentViewModel extends SingleColumnLayoutComponentViewModel w }) : super(nodeId: nodeId, maxWidth: maxWidth, padding: padding); Attribution? blockType; + + @override AttributedText text; @override AttributionStyleBuilder textStyleBuilder; @@ -301,7 +305,7 @@ class CombineParagraphsCommand implements EditCommand { executor.logChanges([ DocumentEdit( - NodeRemovedEvent(secondNode.id), + NodeRemovedEvent(secondNode.id, secondNode), ), DocumentEdit( NodeChangeEvent(nodeAbove.id), @@ -316,14 +320,35 @@ class SplitParagraphRequest implements EditRequest { required this.splitPosition, required this.newNodeId, required this.replicateExistingMetadata, + this.attributionsToExtendToNewParagraph = defaultAttributionsToExtendToNewParagraph, }); final String nodeId; final TextPosition splitPosition; final String newNodeId; final bool replicateExistingMetadata; + // TODO: remove the attribution filter and move the decision to an EditReaction in #1296 + final AttributionFilter attributionsToExtendToNewParagraph; +} + +/// The default [Attribution]s, which will be carried over from the end of a paragraph +/// to the beginning of a new paragraph, when splitting a paragraph at the very end. +/// +/// In practice, this means that when a user places the caret at the end of paragraph +/// and presses ENTER, these [Attribution]s will be applied to the beginning of the +/// new paragraph. +// TODO: remove the attribution filter and move the decision to an EditReaction in #1296 +bool defaultAttributionsToExtendToNewParagraph(Attribution attribution) { + return _defaultAttributionsToExtend.contains(attribution); } +final _defaultAttributionsToExtend = { + boldAttribution, + italicsAttribution, + underlineAttribution, + strikethroughAttribution, +}; + /// Splits the `ParagraphNode` affiliated with the given `nodeId` at the /// given `splitPosition`, placing all text after `splitPosition` in a /// new `ParagraphNode` with the given `newNodeId`, inserted after the @@ -334,12 +359,15 @@ class SplitParagraphCommand implements EditCommand { required this.splitPosition, required this.newNodeId, required this.replicateExistingMetadata, + this.attributionsToExtendToNewParagraph = defaultAttributionsToExtendToNewParagraph, }); final String nodeId; final TextPosition splitPosition; final String newNodeId; final bool replicateExistingMetadata; + // TODO: remove the attribution filter and move the decision to an EditReaction in #1296 + final AttributionFilter attributionsToExtendToNewParagraph; @override void execute(EditContext context, CommandExecutor executor) { @@ -359,6 +387,29 @@ class SplitParagraphCommand implements EditCommand { editorDocLog.info(' - start text: "${startText.text}"'); editorDocLog.info(' - end text: "${endText.text}"'); + if (splitPosition.offset == text.text.length) { + // The paragraph was split at the very end, the user is creating a new, + // empty paragraph. We should only extend desired attributions from the end + // of one paragraph, to the beginning of a new paragraph. + final newParagraphAttributions = endText.getAttributionSpansInRange( + attributionFilter: (a) => true, + range: const SpanRange(0, 0), + ); + for (final attributionRange in newParagraphAttributions) { + if (attributionsToExtendToNewParagraph(attributionRange.attribution)) { + // This is an attribution that should continue into a new paragraph. + // Letting it stay. + continue; + } + + // This attribution shouldn't extend from one paragraph to another. Remove it. + endText.removeAttribution( + attributionRange.attribution, + SpanRange(attributionRange.start, attributionRange.end), + ); + } + } + // Change the current nodes content to just the text before the caret. editorDocLog.info(' - changing the original paragraph text due to split'); node.text = startText; @@ -428,6 +479,148 @@ class SplitParagraphCommand implements EditCommand { } } +class DeleteUpstreamAtBeginningOfParagraphCommand implements EditCommand { + DeleteUpstreamAtBeginningOfParagraphCommand(this.node); + + final DocumentNode node; + + @override + void execute(EditContext context, CommandExecutor executor) { + if (node is! ParagraphNode) { + return; + } + + final deletionPosition = DocumentPosition(nodeId: node.id, nodePosition: node.beginningPosition); + if (deletionPosition.nodePosition is! TextNodePosition) { + return; + } + + final document = context.find(Editor.documentKey); + final composer = context.find(Editor.composerKey); + final documentLayoutEditable = context.find(Editor.layoutKey); + + final paragraphNode = node as ParagraphNode; + if (paragraphNode.metadata["blockType"] != paragraphAttribution) { + executor.executeCommand( + ChangeParagraphBlockTypeCommand( + nodeId: node.id, + blockType: paragraphAttribution, + ), + ); + return; + } + + final nodeBefore = document.getNodeBefore(node); + if (nodeBefore == null) { + return; + } + + if (nodeBefore is TextNode) { + // The caret is at the beginning of one TextNode and is preceded by + // another TextNode. Merge the two TextNodes. + mergeTextNodeWithUpstreamTextNode(executor, document, composer); + return; + } + + final componentBefore = documentLayoutEditable.documentLayout.getComponentByNodeId(nodeBefore.id)!; + if (!componentBefore.isVisualSelectionSupported()) { + // The node/component above is not selectable. Delete it. + executor.executeCommand( + DeleteNodeCommand(nodeId: nodeBefore.id), + ); + return; + } + + moveSelectionToEndOfPrecedingNode(executor, document, composer); + + if ((node as TextNode).text.text.isEmpty) { + // The caret is at the beginning of an empty TextNode and the preceding + // node is not a TextNode. Delete the current TextNode and move the + // selection up to the preceding node if exist. + executor.executeCommand( + DeleteNodeCommand(nodeId: node.id), + ); + } + } + + bool mergeTextNodeWithUpstreamTextNode( + CommandExecutor executor, + MutableDocument document, + MutableDocumentComposer composer, + ) { + final node = document.getNodeById(composer.selection!.extent.nodeId); + if (node == null) { + return false; + } + + final nodeAbove = document.getNodeBefore(node); + if (nodeAbove == null) { + return false; + } + if (nodeAbove is! TextNode) { + return false; + } + + final aboveParagraphLength = nodeAbove.text.text.length; + + // Send edit command. + executor + ..executeCommand( + CombineParagraphsCommand( + firstNodeId: nodeAbove.id, + secondNodeId: node.id, + ), + ) + ..executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: nodeAbove.id, + nodePosition: TextNodePosition(offset: aboveParagraphLength), + ), + ), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, + ), + ); + + return true; + } + + void moveSelectionToEndOfPrecedingNode( + CommandExecutor executor, + MutableDocument document, + MutableDocumentComposer composer, + ) { + if (composer.selection == null) { + return; + } + + final node = document.getNodeById(composer.selection!.extent.nodeId); + if (node == null) { + return; + } + + final nodeBefore = document.getNodeBefore(node); + if (nodeBefore == null) { + return; + } + + executor.executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: nodeBefore.id, + nodePosition: nodeBefore.endPosition, + ), + ), + SelectionChangeType.collapseSelection, + SelectionReason.userInteraction, + ), + ); + } +} + class Intention implements EditEvent { Intention.start() : _isStart = true; @@ -454,7 +647,7 @@ class SubmitParagraphIntention extends Intention { ExecutionInstruction anyCharacterToInsertInParagraph({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { if (editContext.composer.selection == null) { return ExecutionInstruction.continueExecution; @@ -462,7 +655,7 @@ ExecutionInstruction anyCharacterToInsertInParagraph({ // Do nothing if CMD or CTRL are pressed because this signifies an attempted // shortcut. - if (keyEvent.isControlPressed || keyEvent.isMetaPressed) { + if (HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed) { return ExecutionInstruction.continueExecution; } @@ -517,7 +710,7 @@ class DeleteParagraphCommand implements EditCommand { executor.logChanges([ DocumentEdit( - NodeRemovedEvent(node.id), + NodeRemovedEvent(node.id, node), ) ]); } @@ -528,8 +721,12 @@ class DeleteParagraphCommand implements EditCommand { /// header 1, header 2, blockquote. ExecutionInstruction backspaceToClearParagraphBlockType({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { return ExecutionInstruction.continueExecution; } @@ -558,9 +755,9 @@ ExecutionInstruction backspaceToClearParagraphBlockType({ ExecutionInstruction enterToInsertBlockNewline({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -575,7 +772,7 @@ ExecutionInstruction enterToInsertBlockNewline({ ExecutionInstruction moveParagraphSelectionUpWhenBackspaceIsPressed({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { return ExecutionInstruction.continueExecution; @@ -620,9 +817,9 @@ ExecutionInstruction moveParagraphSelectionUpWhenBackspaceIsPressed({ ExecutionInstruction doNothingWithEnterOnWeb({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -642,9 +839,9 @@ ExecutionInstruction doNothingWithEnterOnWeb({ ExecutionInstruction doNothingWithBackspaceOnWeb({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -664,9 +861,9 @@ ExecutionInstruction doNothingWithBackspaceOnWeb({ ExecutionInstruction doNothingWithDeleteOnWeb({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } diff --git a/super_editor/lib/src/default_editor/tasks.dart b/super_editor/lib/src/default_editor/tasks.dart index 7697aa3554..c95f5da554 100644 --- a/super_editor/lib/src/default_editor/tasks.dart +++ b/super_editor/lib/src/default_editor/tasks.dart @@ -138,8 +138,9 @@ class TaskComponentViewModel extends SingleColumnLayoutComponentViewModel with T bool isComplete; void Function(bool) setComplete; - AttributedText text; + @override + AttributedText text; @override AttributionStyleBuilder textStyleBuilder; @override @@ -229,6 +230,19 @@ class _TaskComponentState extends State with ProxyDocumentCompone @override TextComposable get childTextComposable => childDocumentComponentKey.currentState as TextComposable; + /// Computes the [TextStyle] for this task's inner [TextComponent]. + TextStyle _computeStyles(Set attributions) { + // Show a strikethrough across the entire task if it's complete. + final style = widget.viewModel.textStyleBuilder(attributions); + return widget.viewModel.isComplete + ? style.copyWith( + decoration: style.decoration == null + ? TextDecoration.lineThrough + : TextDecoration.combine([TextDecoration.lineThrough, style.decoration!]), + ) + : style; + } + @override Widget build(BuildContext context) { return Row( @@ -247,17 +261,7 @@ class _TaskComponentState extends State with ProxyDocumentCompone child: TextComponent( key: _textKey, text: widget.viewModel.text, - textStyleBuilder: (attributions) { - // Show a strikethrough across the entire task if it's complete. - final style = widget.viewModel.textStyleBuilder(attributions); - return widget.viewModel.isComplete - ? style.copyWith( - decoration: style.decoration == null - ? TextDecoration.lineThrough - : TextDecoration.combine([TextDecoration.lineThrough, style.decoration!]), - ) - : style; - }, + textStyleBuilder: _computeStyles, textSelection: widget.viewModel.selection, selectionColor: widget.viewModel.selectionColor, highlightWhenEmpty: widget.viewModel.highlightWhenEmpty, @@ -271,9 +275,9 @@ class _TaskComponentState extends State with ProxyDocumentCompone ExecutionInstruction enterToInsertNewTask({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -298,7 +302,7 @@ ExecutionInstruction enterToInsertNewTask({ editContext.editor.execute([ SplitExistingTaskRequest( - nodeId: node.id, + existingNodeId: node.id, splitOffset: splitOffset, ), ]); @@ -306,6 +310,42 @@ ExecutionInstruction enterToInsertNewTask({ return ExecutionInstruction.haltExecution; } +ExecutionInstruction backspaceToConvertTaskToParagraph({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { + return ExecutionInstruction.continueExecution; + } + + if (editContext.composer.selection == null) { + return ExecutionInstruction.continueExecution; + } + if (!editContext.composer.selection!.isCollapsed) { + return ExecutionInstruction.continueExecution; + } + + final node = editContext.document.getNodeById(editContext.composer.selection!.extent.nodeId); + if (node is! TaskNode) { + return ExecutionInstruction.continueExecution; + } + + if ((editContext.composer.selection!.extent.nodePosition as TextPosition).offset > 0) { + // The selection isn't at the beginning. + return ExecutionInstruction.continueExecution; + } + + editContext.editor.execute([ + DeleteUpstreamAtBeginningOfNodeRequest(node), + ]); + + return ExecutionInstruction.haltExecution; +} + class ChangeTaskCompletionRequest implements EditRequest { ChangeTaskCompletionRequest({required this.nodeId, required this.isComplete}); @@ -399,24 +439,60 @@ class ConvertParagraphToTaskCommand implements EditCommand { } } +class ConvertTaskToParagraphCommand implements EditCommand { + const ConvertTaskToParagraphCommand({ + required this.nodeId, + this.paragraphMetadata, + }); + + final String nodeId; + final Map? paragraphMetadata; + + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.find(Editor.documentKey); + final node = document.getNodeById(nodeId); + final taskNode = node as TaskNode; + final newMetadata = Map.from(paragraphMetadata ?? {}); + newMetadata["blockType"] = paragraphAttribution; + + final newParagraphNode = ParagraphNode( + id: taskNode.id, + text: taskNode.text, + metadata: newMetadata, + ); + document.replaceNode(oldNode: taskNode, newNode: newParagraphNode); + + executor.logChanges([ + DocumentEdit( + NodeChangeEvent(taskNode.id), + ) + ]); + } +} + class SplitExistingTaskRequest implements EditRequest { const SplitExistingTaskRequest({ - required this.nodeId, + required this.existingNodeId, required this.splitOffset, + this.newNodeId, }); - final String nodeId; + final String existingNodeId; final int splitOffset; + final String? newNodeId; } class SplitExistingTaskCommand implements EditCommand { const SplitExistingTaskCommand({ required this.nodeId, required this.splitOffset, + this.newNodeId, }); final String nodeId; final int splitOffset; + final String? newNodeId; @override void execute(EditContext editContext, CommandExecutor executor) { @@ -441,7 +517,7 @@ class SplitExistingTaskCommand implements EditCommand { } final newTaskNode = TaskNode( - id: Editor.createNodeId(), + id: newNodeId ?? Editor.createNodeId(), text: node.text.copyText(splitOffset), isComplete: false, ); diff --git a/super_editor/lib/src/default_editor/text.dart b/super_editor/lib/src/default_editor/text.dart index 07f1bd8a9e..8382c1bb64 100644 --- a/super_editor/lib/src/default_editor/text.dart +++ b/super_editor/lib/src/default_editor/text.dart @@ -18,7 +18,7 @@ import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; import 'package:super_editor/src/infrastructure/composable_text.dart'; import 'package:super_editor/src/infrastructure/keyboard.dart'; -import 'package:super_editor/src/infrastructure/raw_key_event_extensions.dart'; +import 'package:super_editor/src/infrastructure/key_event_extensions.dart'; import 'package:super_editor/src/infrastructure/strings.dart'; import 'package:super_editor/super_editor.dart'; import 'package:super_text_layout/super_text_layout.dart'; @@ -1424,15 +1424,15 @@ class InsertAttributedTextCommand implements EditCommand { ExecutionInstruction anyCharacterToInsertInTextContent({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } // Do nothing if CMD or CTRL are pressed because this signifies an attempted // shortcut. - if (keyEvent.isControlPressed || keyEvent.isMetaPressed) { + if (HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed) { return ExecutionInstruction.continueExecution; } if (editContext.composer.selection == null) { @@ -1833,7 +1833,7 @@ void _convertToParagraph({ ExecutionInstruction deleteCharacterWhenBackspaceIsPressed({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { return ExecutionInstruction.continueExecution; @@ -1861,9 +1861,9 @@ ExecutionInstruction deleteCharacterWhenBackspaceIsPressed({ ExecutionInstruction deleteToRemoveDownstreamContent({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } @@ -1878,16 +1878,16 @@ ExecutionInstruction deleteToRemoveDownstreamContent({ ExecutionInstruction shiftEnterToInsertNewlineInBlock({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } if (keyEvent.logicalKey != LogicalKeyboardKey.enter && keyEvent.logicalKey != LogicalKeyboardKey.numpadEnter) { return ExecutionInstruction.continueExecution; } - if (!keyEvent.isShiftPressed) { + if (!HardwareKeyboard.instance.isShiftPressed) { return ExecutionInstruction.continueExecution; } diff --git a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart index 6702a6e3ec..7484c20b98 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart @@ -88,9 +88,9 @@ class ActionTagsPlugin extends SuperEditorPlugin { List get keyboardActions => [_cancelOnEscape]; ExecutionInstruction _cancelOnEscape({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is RawKeyDownEvent) { + if (keyEvent is KeyDownEvent || keyEvent is KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } diff --git a/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart index 5781973c28..641e7dd6fb 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart @@ -108,9 +108,9 @@ class StableTagPlugin extends SuperEditorPlugin { List get keyboardActions => [_cancelOnEscape]; ExecutionInstruction _cancelOnEscape({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is RawKeyDownEvent) { + if (keyEvent is KeyDownEvent || keyEvent is KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } diff --git a/super_editor/lib/src/infrastructure/focus.dart b/super_editor/lib/src/infrastructure/focus.dart index efa60cfaf8..5cf0b8d61b 100644 --- a/super_editor/lib/src/infrastructure/focus.dart +++ b/super_editor/lib/src/infrastructure/focus.dart @@ -18,15 +18,16 @@ class NonReparentingFocus extends StatefulWidget { const NonReparentingFocus({ Key? key, required this.focusNode, - this.onKey, + this.onKeyEvent, required this.child, }) : super(key: key); /// The [FocusNode] that sends key events to [onKey]. final FocusNode focusNode; - /// The callback invoked whenever [focusNode] receives key events. - final FocusOnKeyCallback? onKey; + + /// The callback invoked whenever [focusNode] receives [KeyEvent] events. + final FocusOnKeyEventCallback? onKeyEvent; /// The child of this widget. final Widget child; @@ -41,7 +42,7 @@ class _NonReparentingFocusState extends State { @override void initState() { super.initState(); - _keyboardFocusAttachment = widget.focusNode.attach(context, onKey: _onKey); + _keyboardFocusAttachment = widget.focusNode.attach(context, onKeyEvent: _onKeyEvent); } @override @@ -56,7 +57,7 @@ class _NonReparentingFocusState extends State { if (widget.focusNode != oldWidget.focusNode) { _keyboardFocusAttachment.detach(); - _keyboardFocusAttachment = widget.focusNode.attach(context, onKey: widget.onKey); + _keyboardFocusAttachment = widget.focusNode.attach(context, onKeyEvent: _onKeyEvent); _reparentIfMissingParent(); } } @@ -73,8 +74,8 @@ class _NonReparentingFocusState extends State { } } - KeyEventResult _onKey(FocusNode focusNode, RawKeyEvent event) { - return widget.onKey?.call(focusNode, event) ?? KeyEventResult.ignored; + KeyEventResult _onKeyEvent(FocusNode focusNode, KeyEvent event) { + return widget.onKeyEvent?.call(focusNode, event) ?? KeyEventResult.ignored; } @override diff --git a/super_editor/lib/src/infrastructure/raw_key_event_extensions.dart b/super_editor/lib/src/infrastructure/key_event_extensions.dart similarity index 85% rename from super_editor/lib/src/infrastructure/raw_key_event_extensions.dart rename to super_editor/lib/src/infrastructure/key_event_extensions.dart index 0b5664e83a..b0e22299db 100644 --- a/super_editor/lib/src/infrastructure/raw_key_event_extensions.dart +++ b/super_editor/lib/src/infrastructure/key_event_extensions.dart @@ -1,6 +1,6 @@ import 'package:flutter/services.dart'; -extension IsArrowKeyExtension on RawKeyEvent { +extension IsArrowKeyExtension on KeyEvent { bool get isArrowKeyPressed => logicalKey == LogicalKeyboardKey.arrowUp || logicalKey == LogicalKeyboardKey.arrowDown || diff --git a/super_editor/lib/src/infrastructure/keyboard.dart b/super_editor/lib/src/infrastructure/keyboard.dart index c24314b179..7597aadf9c 100644 --- a/super_editor/lib/src/infrastructure/keyboard.dart +++ b/super_editor/lib/src/infrastructure/keyboard.dart @@ -2,8 +2,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; -import 'package:super_editor/src/infrastructure/platforms/platform.dart'; enum ExecutionInstruction { /// The handler has no relation to the key event and @@ -32,9 +32,10 @@ enum ExecutionInstruction { haltExecution, } -extension PrimaryShortcutKey on RawKeyEvent { +extension PrimaryShortcutKey on KeyEvent { bool get isPrimaryShortcutKeyPressed => - (CurrentPlatform.isApple && isMetaPressed) || (!CurrentPlatform.isApple && isControlPressed); + (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isMetaPressed) || + (defaultTargetPlatform != TargetPlatform.macOS && HardwareKeyboard.instance.isControlPressed); } /// Whether the given [character] should be ignored when it's received within diff --git a/super_editor/lib/src/super_reader/read_only_document_keyboard_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_keyboard_interactor.dart index d08c476aa0..e7bca53677 100644 --- a/super_editor/lib/src/super_reader/read_only_document_keyboard_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_keyboard_interactor.dart @@ -5,7 +5,6 @@ import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/document_operations/selection_operations.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/keyboard.dart'; -import 'package:super_editor/src/infrastructure/platforms/platform.dart'; import 'reader_context.dart'; @@ -59,7 +58,7 @@ class ReadOnlyDocumentKeyboardInteractor extends StatelessWidget { /// somewhere in the sub-tree. final Widget child; - KeyEventResult _onKeyPressed(FocusNode node, RawKeyEvent keyEvent) { + KeyEventResult _onKeyEventPressed(FocusNode node, KeyEvent keyEvent) { readerKeyLog.info("Handling key press: $keyEvent"); ExecutionInstruction instruction = ExecutionInstruction.continueExecution; int index = 0; @@ -84,7 +83,7 @@ class ReadOnlyDocumentKeyboardInteractor extends StatelessWidget { Widget build(BuildContext context) { return Focus( focusNode: focusNode, - onKey: _onKeyPressed, + onKeyEvent: _onKeyEventPressed, autofocus: autofocus, child: child, ); @@ -102,7 +101,7 @@ class ReadOnlyDocumentKeyboardInteractor extends StatelessWidget { /// [ExecutionInstruction.haltExecution] to prevent further execution. typedef ReadOnlyDocumentKeyboardAction = ExecutionInstruction Function({ required SuperReaderContext documentContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }); /// Keyboard actions for the standard [SuperReader]. @@ -136,7 +135,7 @@ final readOnlyDefaultKeyboardActions = [ final removeCollapsedSelectionWhenShiftIsReleased = createShortcut( ({ required SuperReaderContext documentContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { final selection = documentContext.selection.value; if (selection == null || !selection.isCollapsed) { @@ -157,7 +156,7 @@ final removeCollapsedSelectionWhenShiftIsReleased = createShortcut( final scrollUpWithArrowKey = createShortcut( ({ required SuperReaderContext documentContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { documentContext.scroller.jumpBy(-20); return ExecutionInstruction.haltExecution; @@ -169,7 +168,7 @@ final scrollUpWithArrowKey = createShortcut( final scrollDownWithArrowKey = createShortcut( ({ required SuperReaderContext documentContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { documentContext.scroller.jumpBy(20); return ExecutionInstruction.haltExecution; @@ -181,14 +180,14 @@ final scrollDownWithArrowKey = createShortcut( final expandSelectionWithLeftArrow = createShortcut( ({ required SuperReaderContext documentContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (defaultTargetPlatform == TargetPlatform.windows && keyEvent.isAltPressed) { + if (defaultTargetPlatform == TargetPlatform.windows && HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } if (defaultTargetPlatform == TargetPlatform.linux && - keyEvent.isAltPressed && + HardwareKeyboard.instance.isAltPressed && (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp || keyEvent.logicalKey == LogicalKeyboardKey.arrowDown)) { return ExecutionInstruction.continueExecution; } @@ -199,7 +198,7 @@ final expandSelectionWithLeftArrow = createShortcut( documentLayout: documentContext.documentLayout, selectionNotifier: documentContext.selection, movementModifier: _getHorizontalMovementModifier(keyEvent), - retainCollapsedSelection: keyEvent.isShiftPressed, + retainCollapsedSelection: HardwareKeyboard.instance.isShiftPressed, ); return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -210,14 +209,14 @@ final expandSelectionWithLeftArrow = createShortcut( final expandSelectionWithRightArrow = createShortcut( ({ required SuperReaderContext documentContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (defaultTargetPlatform == TargetPlatform.windows && keyEvent.isAltPressed) { + if (defaultTargetPlatform == TargetPlatform.windows && HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } if (defaultTargetPlatform == TargetPlatform.linux && - keyEvent.isAltPressed && + HardwareKeyboard.instance.isAltPressed && (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp || keyEvent.logicalKey == LogicalKeyboardKey.arrowDown)) { return ExecutionInstruction.continueExecution; } @@ -228,7 +227,7 @@ final expandSelectionWithRightArrow = createShortcut( documentLayout: documentContext.documentLayout, selectionNotifier: documentContext.selection, movementModifier: _getHorizontalMovementModifier(keyEvent), - retainCollapsedSelection: keyEvent.isShiftPressed, + retainCollapsedSelection: HardwareKeyboard.instance.isShiftPressed, ); return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -236,13 +235,13 @@ final expandSelectionWithRightArrow = createShortcut( keyPressedOrReleased: LogicalKeyboardKey.arrowRight, ); -MovementModifier? _getHorizontalMovementModifier(RawKeyEvent keyEvent) { +MovementModifier? _getHorizontalMovementModifier(KeyEvent keyEvent) { if ((defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux) && - keyEvent.isControlPressed) { + HardwareKeyboard.instance.isControlPressed) { return MovementModifier.word; - } else if (CurrentPlatform.isApple && keyEvent.isMetaPressed) { + } else if (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isMetaPressed) { return MovementModifier.line; - } else if (CurrentPlatform.isApple && keyEvent.isAltPressed) { + } else if (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isAltPressed) { return MovementModifier.word; } @@ -252,13 +251,13 @@ MovementModifier? _getHorizontalMovementModifier(RawKeyEvent keyEvent) { final expandSelectionWithUpArrow = createShortcut( ({ required SuperReaderContext documentContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (defaultTargetPlatform == TargetPlatform.windows && keyEvent.isAltPressed) { + if (defaultTargetPlatform == TargetPlatform.windows && HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } - if (defaultTargetPlatform == TargetPlatform.linux && keyEvent.isAltPressed) { + if (defaultTargetPlatform == TargetPlatform.linux && HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } @@ -266,7 +265,7 @@ final expandSelectionWithUpArrow = createShortcut( document: documentContext.document, documentLayout: documentContext.documentLayout, selectionNotifier: documentContext.selection, - retainCollapsedSelection: keyEvent.isShiftPressed, + retainCollapsedSelection: HardwareKeyboard.instance.isShiftPressed, ); return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -277,13 +276,13 @@ final expandSelectionWithUpArrow = createShortcut( final expandSelectionWithDownArrow = createShortcut( ({ required SuperReaderContext documentContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (defaultTargetPlatform == TargetPlatform.windows && keyEvent.isAltPressed) { + if (defaultTargetPlatform == TargetPlatform.windows && HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } - if (defaultTargetPlatform == TargetPlatform.linux && keyEvent.isAltPressed) { + if (defaultTargetPlatform == TargetPlatform.linux && HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } @@ -291,7 +290,7 @@ final expandSelectionWithDownArrow = createShortcut( document: documentContext.document, documentLayout: documentContext.documentLayout, selectionNotifier: documentContext.selection, - retainCollapsedSelection: keyEvent.isShiftPressed, + retainCollapsedSelection: HardwareKeyboard.instance.isShiftPressed, ); return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -302,14 +301,14 @@ final expandSelectionWithDownArrow = createShortcut( final expandSelectionToLineStartWithHomeOnWindowsAndLinux = createShortcut( ({ required SuperReaderContext documentContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { final didMove = moveCaretUpstream( document: documentContext.document, documentLayout: documentContext.documentLayout, selectionNotifier: documentContext.selection, movementModifier: MovementModifier.line, - retainCollapsedSelection: keyEvent.isShiftPressed, + retainCollapsedSelection: HardwareKeyboard.instance.isShiftPressed, ); return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -322,14 +321,14 @@ final expandSelectionToLineStartWithHomeOnWindowsAndLinux = createShortcut( final expandSelectionToLineEndWithEndOnWindowsAndLinux = createShortcut( ({ required SuperReaderContext documentContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { final didMove = moveCaretDownstream( document: documentContext.document, documentLayout: documentContext.documentLayout, selectionNotifier: documentContext.selection, movementModifier: MovementModifier.line, - retainCollapsedSelection: keyEvent.isShiftPressed, + retainCollapsedSelection: HardwareKeyboard.instance.isShiftPressed, ); return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -342,14 +341,14 @@ final expandSelectionToLineEndWithEndOnWindowsAndLinux = createShortcut( final expandSelectionToLineStartWithCtrlAOnWindowsAndLinux = createShortcut( ({ required SuperReaderContext documentContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { final didMove = moveCaretUpstream( document: documentContext.document, documentLayout: documentContext.documentLayout, selectionNotifier: documentContext.selection, movementModifier: MovementModifier.line, - retainCollapsedSelection: keyEvent.isShiftPressed, + retainCollapsedSelection: HardwareKeyboard.instance.isShiftPressed, ); return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -363,14 +362,14 @@ final expandSelectionToLineStartWithCtrlAOnWindowsAndLinux = createShortcut( final expandSelectionToLineEndWithCtrlEOnWindowsAndLinux = createShortcut( ({ required SuperReaderContext documentContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { final didMove = moveCaretDownstream( document: documentContext.document, documentLayout: documentContext.documentLayout, selectionNotifier: documentContext.selection, movementModifier: MovementModifier.line, - retainCollapsedSelection: keyEvent.isShiftPressed, + retainCollapsedSelection: HardwareKeyboard.instance.isShiftPressed, ); return didMove ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -384,7 +383,7 @@ final expandSelectionToLineEndWithCtrlEOnWindowsAndLinux = createShortcut( final selectAllWhenCmdAIsPressedOnMac = createShortcut( ({ required SuperReaderContext documentContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { final didSelectAll = selectAll(documentContext.document, documentContext.selection); return didSelectAll ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -397,7 +396,7 @@ final selectAllWhenCmdAIsPressedOnMac = createShortcut( final selectAllWhenCtlAIsPressedOnWindowsAndLinux = createShortcut( ({ required SuperReaderContext documentContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { final didSelectAll = selectAll(documentContext.document, documentContext.selection); return didSelectAll ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; @@ -415,7 +414,7 @@ final selectAllWhenCtlAIsPressedOnWindowsAndLinux = createShortcut( final copyWhenCmdCIsPressedOnMac = createShortcut( ({ required SuperReaderContext documentContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { if (documentContext.selection.value == null) { return ExecutionInstruction.continueExecution; @@ -440,7 +439,7 @@ final copyWhenCmdCIsPressedOnMac = createShortcut( final copyWhenCtlCIsPressedOnWindowsAndLinux = createShortcut( ({ required SuperReaderContext documentContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { if (documentContext.selection.value == null) { return ExecutionInstruction.continueExecution; @@ -495,31 +494,31 @@ ReadOnlyDocumentKeyboardAction createShortcut( "Invalid shortcut definition. Both onKeyUp and onKeyDown are false. This shortcut will never be triggered."); } - return ({required SuperReaderContext documentContext, required RawKeyEvent keyEvent}) { - if (keyEvent is RawKeyUpEvent && !onKeyUp) { + return ({required SuperReaderContext documentContext, required KeyEvent keyEvent}) { + if (keyEvent is KeyUpEvent && !onKeyUp) { return ExecutionInstruction.continueExecution; } - if (keyEvent is RawKeyDownEvent && !onKeyDown) { + if ((keyEvent is KeyDownEvent || keyEvent is KeyRepeatEvent) && !onKeyDown) { return ExecutionInstruction.continueExecution; } - if (isCmdPressed != null && isCmdPressed != keyEvent.isMetaPressed) { + if (isCmdPressed != null && isCmdPressed != HardwareKeyboard.instance.isMetaPressed) { return ExecutionInstruction.continueExecution; } - if (isCtlPressed != null && isCtlPressed != keyEvent.isControlPressed) { + if (isCtlPressed != null && isCtlPressed != HardwareKeyboard.instance.isControlPressed) { return ExecutionInstruction.continueExecution; } - if (isAltPressed != null && isAltPressed != keyEvent.isAltPressed) { + if (isAltPressed != null && isAltPressed != HardwareKeyboard.instance.isAltPressed) { return ExecutionInstruction.continueExecution; } if (isShiftPressed != null) { - if (isShiftPressed && !keyEvent.isShiftPressed) { + if (isShiftPressed && !HardwareKeyboard.instance.isShiftPressed) { return ExecutionInstruction.continueExecution; - } else if (!isShiftPressed && keyEvent.isShiftPressed) { + } else if (!isShiftPressed && HardwareKeyboard.instance.isShiftPressed) { return ExecutionInstruction.continueExecution; } } @@ -542,7 +541,7 @@ ReadOnlyDocumentKeyboardAction createShortcut( if (triggers != null) { for (final key in triggers) { - if (!keyEvent.isKeyPressed(key)) { + if (!HardwareKeyboard.instance.isLogicalKeyPressed(key)) { // Manually account for the fact that Flutter pretends that different // shift keys mean different things. if (key == LogicalKeyboardKey.shift || diff --git a/super_editor/lib/src/super_reader/read_only_document_mouse_interactor.dart b/super_editor/lib/src/super_reader/read_only_document_mouse_interactor.dart index 108b29fcb0..61e7477f99 100644 --- a/super_editor/lib/src/super_reader/read_only_document_mouse_interactor.dart +++ b/super_editor/lib/src/super_reader/read_only_document_mouse_interactor.dart @@ -140,9 +140,9 @@ class _ReadOnlyDocumentMouseInteractorState extends State (RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftLeft) || - RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftRight) || - RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shift)); + bool get _isShiftPressed => (HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftLeft) || + HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shiftRight) || + HardwareKeyboard.instance.logicalKeysPressed.contains(LogicalKeyboardKey.shift)); void _onSelectionChange() { if (mounted) { diff --git a/super_editor/lib/src/super_textfield/android/android_textfield.dart b/super_editor/lib/src/super_textfield/android/android_textfield.dart index 30544aa906..b63ddc09e5 100644 --- a/super_editor/lib/src/super_textfield/android/android_textfield.dart +++ b/super_editor/lib/src/super_textfield/android/android_textfield.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/flutter/text_input_configuration.dart'; import 'package:super_editor/src/infrastructure/focus.dart'; import 'package:super_editor/src/infrastructure/ime_input_owner.dart'; import 'package:super_editor/src/infrastructure/platforms/android/toolbar.dart'; +import 'package:super_editor/src/infrastructure/signal_notifier.dart'; import 'package:super_editor/src/infrastructure/touch_controls.dart'; import 'package:super_editor/src/super_textfield/android/_editing_controls.dart'; import 'package:super_editor/src/super_textfield/android/_user_interaction.dart'; @@ -25,6 +28,7 @@ class SuperAndroidTextField extends StatefulWidget { const SuperAndroidTextField({ Key? key, this.focusNode, + this.tapRegionGroupId, this.textController, this.textAlign = TextAlign.left, this.textStyleBuilder = defaultTextFieldStyleBuilder, @@ -34,9 +38,11 @@ class SuperAndroidTextField extends StatefulWidget { this.maxLines = 1, this.lineHeight, required this.caretStyle, + this.blinkTimingMode = BlinkTimingMode.ticker, required this.selectionColor, required this.handlesColor, - this.textInputAction = TextInputAction.done, + this.textInputAction, + this.imeConfiguration, this.popoverToolbarBuilder = _defaultAndroidToolbarBuilder, this.showDebugPaint = false, this.padding, @@ -45,6 +51,9 @@ class SuperAndroidTextField extends StatefulWidget { /// [FocusNode] attached to this text field. final FocusNode? focusNode; + /// {@macro super_text_field_tap_region_group_id} + final String? tapRegionGroupId; + /// Controller that owns the text content and text selection for /// this text field. final ImeAttributedTextEditingController? textController; @@ -67,6 +76,11 @@ class SuperAndroidTextField extends StatefulWidget { /// The visual representation of the caret. final CaretStyle caretStyle; + /// The timing mechanism used to blink, e.g., `Ticker` or `Timer`. + /// + /// `Timer`s are not expected to work in tests. + final BlinkTimingMode blinkTimingMode; + /// Color of the selection rectangle for selected text. final Color selectionColor; @@ -113,7 +127,13 @@ class SuperAndroidTextField extends StatefulWidget { /// The type of action associated with the action button on the mobile /// keyboard. - final TextInputAction textInputAction; + /// + /// This property is ignored when an [imeConfiguration] is provided. + @Deprecated('This will be removed in a future release. Use imeConfiguration instead') + final TextInputAction? textInputAction; + + /// Preferences for how the platform IME should look and behave during editing. + final TextInputConfiguration? imeConfiguration; /// Whether to paint debug guides. final bool showDebugPaint; @@ -150,10 +170,12 @@ class SuperAndroidTextFieldState extends State late TextScrollController _textScrollController; - // OverlayEntry that displays the toolbar and magnifier, and - // positions the invisible touch targets for base/extent - // dragging. - OverlayEntry? _controlsOverlayEntry; + /// Opens/closes the popover that displays the toolbar and magnifier, and + // positions the invisible touch targets for base/extent dragging. + final _popoverController = OverlayPortalController(); + + /// Notifies the popover toolbar to rebuild itself. + final _popoverRebuildSignal = SignalNotifier(); @override void initState() { @@ -178,9 +200,7 @@ class SuperAndroidTextFieldState extends State if (_focusNode.hasFocus) { // The given FocusNode already has focus, we need to update selection and attach to IME. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _updateSelectionAndImeConnectionOnFocusChange(); - }); + onNextFrame((_) => _updateSelectionAndImeConnectionOnFocusChange()); } } @@ -193,13 +213,29 @@ class SuperAndroidTextFieldState extends State _focusNode = (widget.focusNode ?? FocusNode())..addListener(_updateSelectionAndImeConnectionOnFocusChange); } - if (widget.textInputAction != oldWidget.textInputAction && _textEditingController.isAttachedToIme) { + if (widget.textInputAction != oldWidget.textInputAction && + widget.textInputAction != null && + _textEditingController.isAttachedToIme) { _textEditingController.updateTextInputConfiguration( - textInputAction: widget.textInputAction, + textInputAction: widget.textInputAction!, textInputType: _isMultiline ? TextInputType.multiline : TextInputType.text, ); } + if (widget.imeConfiguration != oldWidget.imeConfiguration && + widget.imeConfiguration != null && + (oldWidget.imeConfiguration == null || !widget.imeConfiguration!.isEquivalentTo(oldWidget.imeConfiguration!)) && + _textEditingController.isAttachedToIme) { + _textEditingController.updateTextInputConfiguration( + textInputAction: widget.imeConfiguration!.inputAction, + textInputType: widget.imeConfiguration!.inputType, + autocorrect: widget.imeConfiguration!.autocorrect, + enableSuggestions: widget.imeConfiguration!.enableSuggestions, + keyboardAppearance: widget.imeConfiguration!.keyboardAppearance, + textCapitalization: widget.imeConfiguration!.textCapitalization, + ); + } + if (widget.textController != oldWidget.textController) { _textEditingController.removeListener(_onTextOrSelectionChange); if (_textEditingController.onPerformActionPressed == _onPerformActionPressed) { @@ -216,9 +252,7 @@ class SuperAndroidTextFieldState extends State } if (widget.showDebugPaint != oldWidget.showDebugPaint) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _rebuildEditingOverlayControls(); - }); + onNextFrame((_) => _rebuildEditingOverlayControls()); } } @@ -232,9 +266,7 @@ class SuperAndroidTextFieldState extends State // available upon Hot Reload. Accessing it results in an exception. _removeEditingOverlayControls(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _showEditingControlsOverlay(); - }); + onNextFrame((_) => _showEditingControlsOverlay()); } @override @@ -267,6 +299,8 @@ class SuperAndroidTextFieldState extends State ..removeListener(_onTextScrollChange) ..dispose(); + _popoverRebuildSignal.dispose(); + WidgetsBinding.instance.removeObserver(this); super.dispose(); @@ -276,10 +310,12 @@ class SuperAndroidTextFieldState extends State void didChangeMetrics() { // The available screen dimensions may have changed, e.g., due to keyboard // appearance/disappearance. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted && _focusNode.hasFocus) { - _autoScrollToKeepTextFieldVisible(); + onNextFrame((_) { + if (!_focusNode.hasFocus) { + return; } + + _autoScrollToKeepTextFieldVisible(); }); } @@ -300,10 +336,14 @@ class SuperAndroidTextFieldState extends State _textEditingController.selection = TextSelection.collapsed(offset: _textEditingController.text.text.length); } - _textEditingController.attachToIme( - textInputAction: widget.textInputAction, - textInputType: _isMultiline ? TextInputType.multiline : TextInputType.text, - ); + if (widget.imeConfiguration != null) { + _textEditingController.attachToImeWithConfig(widget.imeConfiguration!); + } else { + _textEditingController.attachToIme( + textInputAction: widget.textInputAction ?? TextInputAction.done, + textInputType: _isMultiline ? TextInputType.multiline : TextInputType.text, + ); + } _autoScrollToKeepTextFieldVisible(); _showEditingControlsOverlay(); @@ -327,45 +367,32 @@ class SuperAndroidTextFieldState extends State } void _onTextScrollChange() { - if (_controlsOverlayEntry != null) { + if (_popoverController.isShowing) { _rebuildEditingOverlayControls(); } } - /// Displays [AndroidEditingOverlayControls] in the app's [Overlay], if not already + /// Displays [AndroidEditingOverlayControls] in the [OverlayPortal], if not already /// displayed. void _showEditingControlsOverlay() { - if (_controlsOverlayEntry == null) { - _controlsOverlayEntry = OverlayEntry(builder: (overlayContext) { - return AndroidEditingOverlayControls( - editingController: _editingOverlayController, - textScrollController: _textScrollController, - textFieldLayerLink: _textFieldLayerLink, - textFieldKey: _textFieldKey, - textContentLayerLink: _textContentLayerLink, - textContentKey: _textContentKey, - handleColor: widget.handlesColor, - popoverToolbarBuilder: widget.popoverToolbarBuilder, - showDebugPaint: widget.showDebugPaint, - ); - }); - - Overlay.of(context).insert(_controlsOverlayEntry!); + if (!_popoverController.isShowing) { + _popoverController.show(); } } - /// Rebuilds the [AndroidEditingControls] in the app's [Overlay], if + /// Rebuilds the [AndroidEditingOverlayControls] in the [OverlayPortal], if /// they're currently displayed. void _rebuildEditingOverlayControls() { - _controlsOverlayEntry?.markNeedsBuild(); + if (_popoverController.isShowing) { + _popoverRebuildSignal.notifyListeners(); + } } - /// Removes [AndroidEditingControls] from the app's [Overlay], if they're + /// Hides the [AndroidEditingOverlayControls] in the [OverlayPortal], if they're /// currently displayed. void _removeEditingOverlayControls() { - if (_controlsOverlayEntry != null) { - _controlsOverlayEntry!.remove(); - _controlsOverlayEntry = null; + if (_popoverController.isShowing) { + _popoverController.hide(); } } @@ -390,10 +417,10 @@ class SuperAndroidTextFieldState extends State /// /// Some third party keyboards report backspace as a key press /// rather than a deletion delta, so we need to handle them manually - KeyEventResult _onKeyPressed(FocusNode focusNode, RawKeyEvent keyEvent) { - _log.finer('_onKeyPressed - keyEvent: ${keyEvent.character}'); - if (keyEvent is! RawKeyDownEvent) { - _log.finer('_onKeyPressed - not a "down" event. Ignoring.'); + KeyEventResult _onKeyEventPressed(FocusNode focusNode, KeyEvent keyEvent) { + _log.finer('_onKeyEventPressed - keyEvent: ${keyEvent.character}'); + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + _log.finer('_onKeyEventPressed - not a "down" event. Ignoring.'); return KeyEventResult.ignored; } if (keyEvent.logicalKey != LogicalKeyboardKey.backspace) { @@ -464,54 +491,65 @@ class SuperAndroidTextFieldState extends State @override Widget build(BuildContext context) { - return NonReparentingFocus( - key: _textFieldKey, - focusNode: _focusNode, - onKey: _onKeyPressed, - child: CompositedTransformTarget( - link: _textFieldLayerLink, - child: AndroidTextFieldTouchInteractor( - focusNode: _focusNode, - textKey: _textContentKey, - textFieldLayerLink: _textFieldLayerLink, - textController: _textEditingController, - editingOverlayController: _editingOverlayController, - textScrollController: _textScrollController, - isMultiline: _isMultiline, - handleColor: widget.handlesColor, - showDebugPaint: widget.showDebugPaint, - child: TextScrollView( - key: _scrollKey, - textScrollController: _textScrollController, + return OverlayPortal( + controller: _popoverController, + overlayChildBuilder: _buildPopoverToolbar, + child: _buildTextField(), + ); + } + + Widget _buildTextField() { + return TapRegion( + groupId: widget.tapRegionGroupId, + child: NonReparentingFocus( + key: _textFieldKey, + focusNode: _focusNode, + onKeyEvent: _onKeyEventPressed, + child: CompositedTransformTarget( + link: _textFieldLayerLink, + child: AndroidTextFieldTouchInteractor( + focusNode: _focusNode, textKey: _textContentKey, - textEditingController: _textEditingController, - textAlign: widget.textAlign, - minLines: widget.minLines, - maxLines: widget.maxLines, - lineHeight: widget.lineHeight, - perLineAutoScrollDuration: const Duration(milliseconds: 100), + textFieldLayerLink: _textFieldLayerLink, + textController: _textEditingController, + editingOverlayController: _editingOverlayController, + textScrollController: _textScrollController, + isMultiline: _isMultiline, + handleColor: widget.handlesColor, showDebugPaint: widget.showDebugPaint, - padding: widget.padding, - child: ListenableBuilder( - listenable: _textEditingController, - builder: (context, _) { - final isTextEmpty = _textEditingController.text.text.isEmpty; - final showHint = widget.hintBuilder != null && - ((isTextEmpty && widget.hintBehavior == HintBehavior.displayHintUntilTextEntered) || - (isTextEmpty && - !_focusNode.hasFocus && - widget.hintBehavior == HintBehavior.displayHintUntilFocus)); - - return CompositedTransformTarget( - link: _textContentLayerLink, - child: Stack( - children: [ - if (showHint) widget.hintBuilder!(context), - _buildSelectableText(), - ], - ), - ); - }, + child: TextScrollView( + key: _scrollKey, + textScrollController: _textScrollController, + textKey: _textContentKey, + textEditingController: _textEditingController, + textAlign: widget.textAlign, + minLines: widget.minLines, + maxLines: widget.maxLines, + lineHeight: widget.lineHeight, + perLineAutoScrollDuration: const Duration(milliseconds: 100), + showDebugPaint: widget.showDebugPaint, + padding: widget.padding, + child: ListenableBuilder( + listenable: _textEditingController, + builder: (context, _) { + final isTextEmpty = _textEditingController.text.text.isEmpty; + final showHint = widget.hintBuilder != null && + ((isTextEmpty && widget.hintBehavior == HintBehavior.displayHintUntilTextEntered) || + (isTextEmpty && + !_focusNode.hasFocus && + widget.hintBehavior == HintBehavior.displayHintUntilFocus)); + + return CompositedTransformTarget( + link: _textContentLayerLink, + child: Stack( + children: [ + if (showHint) widget.hintBuilder!(context), + _buildSelectableText(), + ], + ), + ); + }, + ), ), ), ), @@ -537,14 +575,38 @@ class SuperAndroidTextFieldState extends State caretStyle: widget.caretStyle, selection: _textEditingController.selection, hasCaret: _focusNode.hasFocus, + blinkTimingMode: widget.blinkTimingMode, ), ), ); } + + Widget _buildPopoverToolbar(BuildContext context) { + return ListenableBuilder( + listenable: _popoverRebuildSignal, + builder: (context, _) { + return AndroidEditingOverlayControls( + editingController: _editingOverlayController, + textScrollController: _textScrollController, + textFieldLayerLink: _textFieldLayerLink, + textFieldKey: _textFieldKey, + textContentLayerLink: _textContentLayerLink, + textContentKey: _textContentKey, + tapRegionGroupId: widget.tapRegionGroupId, + handleColor: widget.handlesColor, + popoverToolbarBuilder: widget.popoverToolbarBuilder, + showDebugPaint: widget.showDebugPaint, + ); + }, + ); + } } Widget _defaultAndroidToolbarBuilder( - BuildContext context, AndroidEditingOverlayController controller, ToolbarConfig config) { + BuildContext context, + AndroidEditingOverlayController controller, + ToolbarConfig config, +) { return AndroidTextEditingFloatingToolbar( onCutPressed: () { final textController = controller.textController; diff --git a/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart index b46c8fe1c0..83b5147ddc 100644 --- a/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart +++ b/super_editor/lib/src/super_textfield/desktop/desktop_textfield.dart @@ -9,13 +9,17 @@ import 'package:flutter/services.dart'; import 'package:super_editor/src/core/document_layout.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; +import 'package:super_editor/src/infrastructure/flutter/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/flutter/text_input_configuration.dart'; import 'package:super_editor/src/infrastructure/focus.dart'; import 'package:super_editor/src/infrastructure/ime_input_owner.dart'; import 'package:super_editor/src/infrastructure/keyboard.dart'; import 'package:super_editor/src/infrastructure/multi_listenable_builder.dart'; import 'package:super_editor/src/infrastructure/multi_tap_gesture.dart'; -import 'package:super_editor/src/super_textfield/super_textfield.dart'; +import 'package:super_editor/src/infrastructure/platforms/mac/mac_ime.dart'; import 'package:super_editor/src/infrastructure/text_input.dart'; +import 'package:super_editor/src/super_textfield/infrastructure/text_field_scroller.dart'; +import 'package:super_editor/src/super_textfield/super_textfield.dart'; import 'package:super_text_layout/super_text_layout.dart'; import '../infrastructure/fill_width_if_constrained.dart'; @@ -39,6 +43,7 @@ class SuperDesktopTextField extends StatefulWidget { const SuperDesktopTextField({ Key? key, this.focusNode, + this.tapRegionGroupId, this.textController, this.textStyleBuilder = defaultTextFieldStyleBuilder, this.textAlign = TextAlign.left, @@ -52,12 +57,16 @@ class SuperDesktopTextField extends StatefulWidget { width: 1, borderRadius: BorderRadius.zero, ), + this.blinkTimingMode = BlinkTimingMode.ticker, this.padding = EdgeInsets.zero, this.minLines, this.maxLines = 1, this.decorationBuilder, this.onRightClick, this.inputSource = TextInputSource.keyboard, + this.textInputAction, + this.imeConfiguration, + this.selectorHandlers, List? keyboardHandlers, }) : keyboardHandlers = keyboardHandlers ?? (inputSource == TextInputSource.keyboard @@ -67,6 +76,9 @@ class SuperDesktopTextField extends StatefulWidget { final FocusNode? focusNode; + /// {@macro super_text_field_tap_region_group_id} + final String? tapRegionGroupId; + final AttributedTextEditingController? textController; /// Text style factory that creates styles for the content in @@ -90,6 +102,11 @@ class SuperDesktopTextField extends StatefulWidget { /// The visual representation of the caret in this `SelectableText` widget. final CaretStyle caretStyle; + /// The timing mechanism used to blink, e.g., `Ticker` or `Timer`. + /// + /// `Timer`s are not expected to work in tests. + final BlinkTimingMode blinkTimingMode; + final EdgeInsetsGeometry padding; final int? minLines; @@ -110,6 +127,21 @@ class SuperDesktopTextField extends StatefulWidget { /// that input text based on individual character key presses. final List keyboardHandlers; + /// Handlers for all Mac OS "selectors" reported by the IME. + /// + /// The IME reports selectors as unique `String`s, therefore selector handlers are + /// defined as a mapping from selector names to handler functions. + final Map? selectorHandlers; + + /// The type of action associated with ENTER key. + /// + /// This property is ignored when an [imeConfiguration] is provided. + @Deprecated('This will be removed in a future release. Use imeConfiguration instead') + final TextInputAction? textInputAction; + + /// Preferences for how the platform IME should look and behave during editing. + final TextInputConfiguration? imeConfiguration; + @override SuperDesktopTextFieldState createState() => SuperDesktopTextFieldState(); } @@ -120,8 +152,10 @@ class SuperDesktopTextFieldState extends State implements late FocusNode _focusNode; bool _hasFocus = false; // cache whether we have focus so we know when it changes + late SuperTextFieldContext _textFieldContext; late ImeAttributedTextEditingController _controller; late ScrollController _scrollController; + late TextFieldScroller _textFieldScroller; double? _viewportHeight; @@ -141,6 +175,10 @@ class SuperDesktopTextFieldState extends State implements _controller.addListener(_onSelectionOrContentChange); _scrollController = ScrollController(); + _textFieldScroller = TextFieldScroller() // + ..attach(_scrollController); + + _createTextFieldContext(); // Check if we need to update the selection. _updateSelectionOnFocusChange(); @@ -175,6 +213,8 @@ class SuperDesktopTextFieldState extends State implements : ImeAttributedTextEditingController(); _controller.addListener(_onSelectionOrContentChange); + + _createTextFieldContext(); } if (widget.padding != oldWidget.padding || @@ -186,6 +226,7 @@ class SuperDesktopTextFieldState extends State implements @override void dispose() { + _textFieldScroller.detach(); _scrollController.dispose(); _focusNode.removeListener(_updateSelectionOnFocusChange); if (widget.focusNode == null) { @@ -201,6 +242,16 @@ class SuperDesktopTextFieldState extends State implements super.dispose(); } + void _createTextFieldContext() { + _textFieldContext = SuperTextFieldContext( + textFieldBuildContext: context, + focusNode: _focusNode, + controller: _controller, + getTextLayout: () => textLayout, + scroller: _textFieldScroller, + ); + } + @override ProseTextLayout get textLayout => _textKey.currentState!.textLayout; @@ -233,11 +284,7 @@ class SuperDesktopTextFieldState extends State implements // Use a post-frame callback to "ensure selection extent is visible" // so that any pending visual content changes can happen before // attempting to calculate the visual position of the selection extent. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) { - _updateViewportHeight(); - } - }); + onNextFrame((_) => _updateViewportHeight()); } /// Returns true if the viewport height changed, false otherwise. @@ -306,57 +353,58 @@ class SuperDesktopTextFieldState extends State implements // The text hasn't been laid out yet, which means our calculations // for text height is probably wrong. Schedule a post frame callback // to re-calculate the height after initial layout. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) { - setState(() { - _updateViewportHeight(); - }); - } + scheduleBuildAfterBuild(() { + _updateViewportHeight(); }); } final isMultiline = widget.minLines != 1 || widget.maxLines != 1; - return _buildTextInputSystem( - isMultiline: isMultiline, - child: SuperTextFieldGestureInteractor( - focusNode: _focusNode, - textController: _controller, - textKey: _textKey, - textScrollKey: _textScrollKey, + return TapRegion( + groupId: widget.tapRegionGroupId, + child: _buildTextInputSystem( isMultiline: isMultiline, - onRightClick: widget.onRightClick, - child: MultiListenableBuilder( - listenables: { - _focusNode, - _controller, - }, - builder: (context) { - final isTextEmpty = _controller.text.text.isEmpty; - final showHint = widget.hintBuilder != null && - ((isTextEmpty && widget.hintBehavior == HintBehavior.displayHintUntilTextEntered) || - (isTextEmpty && !_focusNode.hasFocus && widget.hintBehavior == HintBehavior.displayHintUntilFocus)); - - return _buildDecoration( - child: SuperTextFieldScrollview( - key: _textScrollKey, - textKey: _textKey, - textController: _controller, - textAlign: widget.textAlign, - scrollController: _scrollController, - viewportHeight: _viewportHeight, - estimatedLineHeight: _getEstimatedLineHeight(), - padding: widget.padding, - isMultiline: isMultiline, - child: Stack( - children: [ - if (showHint) widget.hintBuilder!(context), - _buildSelectableText(), - ], + child: SuperTextFieldGestureInteractor( + focusNode: _focusNode, + textController: _controller, + textKey: _textKey, + textScrollKey: _textScrollKey, + isMultiline: isMultiline, + onRightClick: widget.onRightClick, + child: MultiListenableBuilder( + listenables: { + _focusNode, + _controller, + }, + builder: (context) { + final isTextEmpty = _controller.text.text.isEmpty; + final showHint = widget.hintBuilder != null && + ((isTextEmpty && widget.hintBehavior == HintBehavior.displayHintUntilTextEntered) || + (isTextEmpty && + !_focusNode.hasFocus && + widget.hintBehavior == HintBehavior.displayHintUntilFocus)); + + return _buildDecoration( + child: SuperTextFieldScrollview( + key: _textScrollKey, + textKey: _textKey, + textController: _controller, + textAlign: widget.textAlign, + scrollController: _scrollController, + viewportHeight: _viewportHeight, + estimatedLineHeight: _getEstimatedLineHeight(), + padding: widget.padding, + isMultiline: isMultiline, + child: Stack( + children: [ + if (showHint) widget.hintBuilder!(context), + _buildSelectableText(), + ], + ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -372,19 +420,29 @@ class SuperDesktopTextFieldState extends State implements required bool isMultiline, required Widget child, }) { - return SuperTextFieldKeyboardInteractor( - focusNode: _focusNode, - textController: _controller, - textKey: _textKey, - keyboardActions: widget.keyboardHandlers, - child: widget.inputSource == TextInputSource.ime - ? SuperTextFieldImeInteractor( - focusNode: _focusNode, - textController: _controller, - isMultiline: isMultiline, - child: child, - ) - : child, + return Actions( + actions: defaultTargetPlatform == TargetPlatform.macOS ? disabledMacIntents : {}, + child: SuperTextFieldKeyboardInteractor( + focusNode: _focusNode, + textFieldContext: _textFieldContext, + textKey: _textKey, + keyboardActions: widget.keyboardHandlers, + child: widget.inputSource == TextInputSource.ime + ? SuperTextFieldImeInteractor( + textKey: _textKey, + focusNode: _focusNode, + textFieldContext: _textFieldContext, + isMultiline: isMultiline, + selectorHandlers: widget.selectorHandlers ?? defaultTextFieldSelectorHandlers, + textInputAction: widget.textInputAction, + imeConfiguration: widget.imeConfiguration, + textStyleBuilder: widget.textStyleBuilder, + textAlign: widget.textAlign, + textDirection: Directionality.of(context), + child: child, + ) + : child, + ), ); } @@ -400,6 +458,7 @@ class SuperDesktopTextFieldState extends State implements caretStyle: widget.caretStyle, selection: _controller.selection, hasCaret: _focusNode.hasFocus, + blinkTimingMode: widget.blinkTimingMode, ), ), ); @@ -484,9 +543,9 @@ class _SuperTextFieldGestureInteractorState extends State { - KeyEventResult _onKeyPressed(FocusNode focusNode, RawKeyEvent keyEvent) { + @override + void initState() { + super.initState(); + widget.focusNode.addListener(_onFocusChange); + } + + @override + void didUpdateWidget(SuperTextFieldKeyboardInteractor oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode.removeListener(_onFocusChange); + widget.focusNode.addListener(_onFocusChange); + } + } + + @override + void dispose() { + widget.focusNode.removeListener(_onFocusChange); + super.dispose(); + } + + void _onFocusChange() { + if (widget.focusNode.hasFocus) { + return; + } + + _log.fine("Clearing selection because SuperTextField lost focus"); + widget.textFieldContext.controller.selection = const TextSelection.collapsed(offset: -1); + } + + KeyEventResult _onKeyPressed(FocusNode focusNode, KeyEvent keyEvent) { _log.fine('_onKeyPressed - keyEvent: ${keyEvent.logicalKey}, character: ${keyEvent.character}'); - if (keyEvent is! RawKeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { _log.finer('_onKeyPressed - not a "down" event. Ignoring.'); return KeyEventResult.ignored; } @@ -895,22 +984,29 @@ class _SuperTextFieldKeyboardInteractorState extends State textKey; + + /// Handlers for all Mac OS "selectors" reported by the IME. + /// + /// The IME reports selectors as unique `String`s, therefore selector handlers are + /// defined as a mapping from selector names to handler functions. + final Map selectorHandlers; + + /// The type of action associated with ENTER key. + final TextInputAction? textInputAction; + + /// Preferences for how the platform IME should look and behave during editing. + final TextInputConfiguration? imeConfiguration; + + /// Text style factory that creates styles for the content in + /// [textController] based on the attributions in that content. + /// + /// On web, we can't set the position of IME popovers (e.g, emoji picker, + /// character selection panel) ourselves. Because of that, we need + /// to report to the IME what is our text style, so the browser can position + /// the popovers based on text metrics computed for the given style. + /// + /// This should be the same [AttributionStyleBuilder] used to + /// render the text. + final AttributionStyleBuilder textStyleBuilder; + + final TextAlign? textAlign; + + final TextDirection? textDirection; + /// The rest of the subtree for this text field. final Widget child; @@ -953,16 +1087,21 @@ class SuperTextFieldImeInteractor extends StatefulWidget { } class _SuperTextFieldImeInteractorState extends State { + late ImeAttributedTextEditingController _textController; + @override void initState() { super.initState(); widget.focusNode.addListener(_updateSelectionAndImeConnectionOnFocusChange); + _textController = widget.textFieldContext.imeController! + ..inputConnectionNotifier.addListener(_onImeConnectionChanged) + ..onPerformActionPressed ??= _onPerformAction + ..onPerformSelector ??= _onPerformSelector; + if (widget.focusNode.hasFocus) { // We got an already focused FocusNode, we need to attach to the IME. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _updateSelectionAndImeConnectionOnFocusChange(); - }); + onNextFrame((_) => _updateSelectionAndImeConnectionOnFocusChange()); } } @@ -975,42 +1114,232 @@ class _SuperTextFieldImeInteractorState extends State _updateSelectionAndImeConnectionOnFocusChange()); } } + + if (widget.textFieldContext.imeController != _textController) { + if (_textController.onPerformActionPressed == _onPerformAction) { + _textController.onPerformActionPressed = null; + } + if (_textController.onPerformSelector == _onPerformSelector) { + _textController.onPerformSelector = null; + } + _textController.inputConnectionNotifier.removeListener(_onImeConnectionChanged); + + _textController = widget.textFieldContext.imeController! + ..inputConnectionNotifier.addListener(_onImeConnectionChanged) + ..onPerformActionPressed ??= _onPerformAction + ..onPerformSelector ??= _onPerformSelector; + } + + if (widget.imeConfiguration != oldWidget.imeConfiguration && + widget.imeConfiguration != null && + (oldWidget.imeConfiguration == null || !widget.imeConfiguration!.isEquivalentTo(oldWidget.imeConfiguration!)) && + _textController.isAttachedToIme) { + _textController.updateTextInputConfiguration( + textInputAction: widget.imeConfiguration!.inputAction, + textInputType: widget.imeConfiguration!.inputType, + autocorrect: widget.imeConfiguration!.autocorrect, + enableSuggestions: widget.imeConfiguration!.enableSuggestions, + keyboardAppearance: widget.imeConfiguration!.keyboardAppearance, + textCapitalization: widget.imeConfiguration!.textCapitalization, + ); + } } @override void dispose() { widget.focusNode.removeListener(_updateSelectionAndImeConnectionOnFocusChange); + _textController.inputConnectionNotifier.removeListener(_onImeConnectionChanged); + if (_textController.onPerformSelector == _onPerformSelector) { + _textController.onPerformSelector = null; + } + if (_textController.onPerformActionPressed == _onPerformAction) { + _textController.onPerformActionPressed = null; + } super.dispose(); } void _updateSelectionAndImeConnectionOnFocusChange() { if (widget.focusNode.hasFocus) { - if (!widget.textController.isAttachedToIme) { + if (!_textController.isAttachedToIme) { _log.info('Attaching TextInputClient to TextInput'); setState(() { - if (!widget.textController.selection.isValid) { - widget.textController.selection = TextSelection.collapsed(offset: widget.textController.text.text.length); + if (!_textController.selection.isValid) { + _textController.selection = TextSelection.collapsed(offset: _textController.text.text.length); } - widget.textController.attachToIme( - textInputType: widget.isMultiline ? TextInputType.multiline : TextInputType.text, - ); + if (widget.imeConfiguration != null) { + _textController.attachToImeWithConfig(widget.imeConfiguration!); + } else { + _textController.attachToIme( + textInputType: widget.isMultiline ? TextInputType.multiline : TextInputType.text, + textInputAction: + widget.textInputAction ?? (widget.isMultiline ? TextInputAction.newline : TextInputAction.done), + ); + } }); } } else { _log.info('Lost focus. Detaching TextInputClient from TextInput.'); setState(() { - widget.textController.detachFromIme(); - widget.textController.selection = const TextSelection.collapsed(offset: -1); + _textController.detachFromIme(); + _textController.selection = const TextSelection.collapsed(offset: -1); }); } } + void _onImeConnectionChanged() { + if (!_textController.isAttachedToIme) { + return; + } + + _reportVisualInformationToIme(); + } + + /// Report our size, transform to the root node coordinates, and caret rect to the IME. + /// + /// This is needed to display the OS emoji & symbols panel at the text field selected position. + /// + /// This methods is re-scheduled to run at the end of every frame while we are attached to the IME. + void _reportVisualInformationToIme() { + if (!_textController.isAttachedToIme) { + return; + } + + _reportSizeAndTransformToIme(); + _reportCaretRectToIme(); + _reportTextStyleToIme(); + + // Without showing the keyboard, the panel is always positioned at the screen center after the first time. + // I'm not sure why this is needed in SuperTextField, but not in SuperEditor. + _textController.showKeyboard(); + + // There are some operations that might affect our transform or the caret rect but we can't react to them. + // For example, the text field might be resized or moved around the screen. + // Because of this, we update our size, transform and caret rect at every frame. + onNextFrame((_) => _reportVisualInformationToIme()); + } + + /// Report the global size and transform of the text field to the IME. + /// + /// This is needed to display the OS emoji & symbols panel at the selected position. + void _reportSizeAndTransformToIme() { + final renderBox = widget.textKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) { + return; + } + + _textController.inputConnectionNotifier.value! + .setEditableSizeAndTransform(renderBox.size, renderBox.getTransformTo(null)); + } + + void _reportCaretRectToIme() { + if (isWeb) { + // On web, setting the caret rect isn't supported. + // To position the IME popovers, we report our size, transform and text style + // and let the browser position the popovers. + return; + } + + final caretRect = _computeCaretRectInContentSpace(); + if (caretRect != null) { + _textController.inputConnectionNotifier.value!.setCaretRect(caretRect); + } + } + + /// Report our text style to the IME. + /// + /// This is used on web to set the text style of the hidden native input, + /// to try to match the text size on the browser with our text size. + /// + /// As our content can have multiple styles, the sizes won't be 100% in sync. + void _reportTextStyleToIme() { + late TextStyle textStyle; + + final selection = _textController.selection; + if (!selection.isValid) { + return; + } + + // We have a selection, compute the style based on the attributions present + // at the selection extent. + final text = _textController.text; + final attributions = text.getAllAttributionsAt(selection.extentOffset); + textStyle = widget.textStyleBuilder(attributions); + + _textController.inputConnectionNotifier.value!.setStyle( + fontFamily: textStyle.fontFamily, + fontSize: textStyle.fontSize, + fontWeight: textStyle.fontWeight, + textDirection: widget.textDirection ?? TextDirection.ltr, + textAlign: widget.textAlign ?? TextAlign.left, + ); + } + + Rect? _computeCaretRectInContentSpace() { + final text = widget.textKey.currentState; + if (text == null) { + return null; + } + + final selection = _textController.selection; + if (!selection.isValid) { + return null; + } + + final renderBox = context.findRenderObject() as RenderBox; + + // Compute the caret rect in the text layout space. + final position = TextPosition(offset: selection.baseOffset); + final textLayout = text.textLayout; + final caretOffset = textLayout.getOffsetForCaret(position); + final caretHeight = textLayout.getHeightForCaret(position) ?? textLayout.estimatedLineHeight; + final caretRect = caretOffset & Size(1, caretHeight); + + // Convert the coordinates from the text layout space to the text field space. + final textRenderBox = text.context.findRenderObject() as RenderBox; + final textOffset = renderBox.globalToLocal(textRenderBox.localToGlobal(Offset.zero)); + final caretOffsetInTextFieldSpace = caretRect.shift(textOffset); + + return caretOffsetInTextFieldSpace; + } + + void _onPerformSelector(String selectorName) { + final handler = widget.selectorHandlers[selectorName]; + if (handler == null) { + editorImeLog.warning("No handler found for $selectorName"); + return; + } + + handler(textFieldContext: widget.textFieldContext); + } + + /// Handles actions from the IME. + void _onPerformAction(TextInputAction action) { + switch (action) { + case TextInputAction.newline: + // Do nothing for IME newline actions. + // + // Mac: Key presses flow, unhandled, to the OS and turn into IME selectors. We handle newlines there. + // Windows/Linux: Key presses flow, unhandled, to the OS and turn into text deltas. We handle newlines there. + // Android/iOS: This text field implementation is only for desktop, mobile is handled elsewhere. + break; + case TextInputAction.done: + widget.focusNode.unfocus(); + break; + case TextInputAction.next: + widget.focusNode.nextFocus(); + break; + case TextInputAction.previous: + widget.focusNode.previousFocus(); + break; + default: + _log.warning("User pressed unhandled action button: $action"); + } + } + @override Widget build(BuildContext context) { return widget.child; @@ -1102,11 +1431,7 @@ class SuperTextFieldScrollviewState extends State with if (widget.viewportHeight != oldWidget.viewportHeight) { // After the current layout, ensure that the current text // selection is visible. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) { - _ensureSelectionExtentIsVisible(); - } - }); + onNextFrame((_) => _ensureSelectionExtentIsVisible()); } } @@ -1122,11 +1447,7 @@ class SuperTextFieldScrollviewState extends State with // Use a post-frame callback to "ensure selection extent is visible" // so that any pending visual content changes can happen before // attempting to calculate the visual position of the selection extent. - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (mounted) { - _ensureSelectionExtentIsVisible(); - } - }); + onNextFrame((_) => _ensureSelectionExtentIsVisible()); } void _ensureSelectionExtentIsVisible() { @@ -1366,6 +1687,20 @@ enum TextFieldKeyboardHandlerResult { /// listeners. blocked, + /// The handler recognized the key event but chose to + /// take no action. + /// + /// No other handler should receive the key event. + /// + /// The key event shouldn't bubble up the Flutter tree, + /// but it should be sent to the operating system (rather + /// than being consumed and disposed). + /// + /// Use this result, for example, when Mac OS needs to + /// convert a key event into a selector, and send that + /// selector through the IME. + sendToOperatingSystem, + /// The handler has no relation to the key event and /// took no action. /// @@ -1375,21 +1710,19 @@ enum TextFieldKeyboardHandlerResult { } typedef TextFieldKeyboardHandler = TextFieldKeyboardHandlerResult Function({ - required AttributedTextEditingController controller, - required ProseTextLayout textLayout, - required RawKeyEvent keyEvent, + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, }); /// A [TextFieldKeyboardHandler] that reports [TextFieldKeyboardHandlerResult.blocked] /// for any key combination that matches one of the given [keys]. TextFieldKeyboardHandler ignoreTextFieldKeyCombos(List keys) { return ({ - required AttributedTextEditingController controller, - required ProseTextLayout textLayout, - required RawKeyEvent keyEvent, + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, }) { for (final key in keys) { - if (key.accepts(keyEvent, RawKeyboard.instance)) { + if (key.accepts(keyEvent, HardwareKeyboard.instance)) { return TextFieldKeyboardHandlerResult.blocked; } } @@ -1453,6 +1786,11 @@ const defaultTextFieldImeKeyboardHandlers = [ DefaultSuperTextFieldKeyboardHandlers.copyTextWhenCmdCIsPressed, DefaultSuperTextFieldKeyboardHandlers.pasteTextWhenCmdVIsPressed, DefaultSuperTextFieldKeyboardHandlers.selectAllTextFieldWhenCmdAIsPressed, + // WARNING: No keyboard handlers below this point will run on Mac. On Mac, most + // common shortcuts are recognized by the OS. This line short circuits SuperTextField + // handlers, passing the key combo to the OS on Mac. Place all custom Mac key + // combos above this handler. + DefaultSuperTextFieldKeyboardHandlers.sendKeyEventToMacOs, DefaultSuperTextFieldKeyboardHandlers.moveCaretToStartOrEnd, DefaultSuperTextFieldKeyboardHandlers.moveUpDownLeftAndRightWithArrowKeys, DefaultSuperTextFieldKeyboardHandlers.moveToLineStartWithHome, @@ -1461,16 +1799,14 @@ const defaultTextFieldImeKeyboardHandlers = [ DefaultSuperTextFieldKeyboardHandlers.deleteWordWhenCtlBackSpaceIsPressedOnWindowsAndLinux, DefaultSuperTextFieldKeyboardHandlers.deleteTextOnLineBeforeCaretWhenShortcutKeyAndBackspaceIsPressed, DefaultSuperTextFieldKeyboardHandlers.deleteTextWhenBackspaceOrDeleteIsPressed, - DefaultSuperTextFieldKeyboardHandlers.insertNewlineWhenEnterIsPressed, ]; class DefaultSuperTextFieldKeyboardHandlers { /// [copyTextWhenCmdCIsPressed] copies text to clipboard when primary shortcut key /// (CMD on Mac, CTL on Windows) + C is pressed. static TextFieldKeyboardHandlerResult copyTextWhenCmdCIsPressed({ - required AttributedTextEditingController controller, - ProseTextLayout? textLayout, - required RawKeyEvent keyEvent, + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, }) { if (!keyEvent.isPrimaryShortcutKeyPressed) { return TextFieldKeyboardHandlerResult.notHandled; @@ -1478,11 +1814,11 @@ class DefaultSuperTextFieldKeyboardHandlers { if (keyEvent.logicalKey != LogicalKeyboardKey.keyC) { return TextFieldKeyboardHandlerResult.notHandled; } - if (controller.selection.extentOffset == -1) { + if (textFieldContext.controller.selection.extentOffset == -1) { return TextFieldKeyboardHandlerResult.notHandled; } - controller.copySelectedTextToClipboard(); + textFieldContext.controller.copySelectedTextToClipboard(); return TextFieldKeyboardHandlerResult.handled; } @@ -1490,9 +1826,8 @@ class DefaultSuperTextFieldKeyboardHandlers { /// [pasteTextWhenCmdVIsPressed] pastes text from clipboard to document when primary shortcut key /// (CMD on Mac, CTL on Windows) + V is pressed. static TextFieldKeyboardHandlerResult pasteTextWhenCmdVIsPressed({ - required AttributedTextEditingController controller, - ProseTextLayout? textLayout, - required RawKeyEvent keyEvent, + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, }) { if (!keyEvent.isPrimaryShortcutKeyPressed) { return TextFieldKeyboardHandlerResult.notHandled; @@ -1500,15 +1835,15 @@ class DefaultSuperTextFieldKeyboardHandlers { if (keyEvent.logicalKey != LogicalKeyboardKey.keyV) { return TextFieldKeyboardHandlerResult.notHandled; } - if (controller.selection.extentOffset == -1) { + if (textFieldContext.controller.selection.extentOffset == -1) { return TextFieldKeyboardHandlerResult.notHandled; } - if (!controller.selection.isCollapsed) { - controller.deleteSelectedText(); + if (!textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteSelectedText(); } - controller.pasteClipboard(); + textFieldContext.controller.pasteClipboard(); return TextFieldKeyboardHandlerResult.handled; } @@ -1516,9 +1851,8 @@ class DefaultSuperTextFieldKeyboardHandlers { /// [selectAllTextFieldWhenCmdAIsPressed] selects all text when primary shortcut key /// (CMD on Mac, CTL on Windows) + A is pressed. static TextFieldKeyboardHandlerResult selectAllTextFieldWhenCmdAIsPressed({ - required AttributedTextEditingController controller, - ProseTextLayout? textLayout, - required RawKeyEvent keyEvent, + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, }) { if (!keyEvent.isPrimaryShortcutKeyPressed) { return TextFieldKeyboardHandlerResult.notHandled; @@ -1527,7 +1861,7 @@ class DefaultSuperTextFieldKeyboardHandlers { return TextFieldKeyboardHandlerResult.notHandled; } - controller.selectAll(); + textFieldContext.controller.selectAll(); return TextFieldKeyboardHandlerResult.handled; } @@ -1535,12 +1869,11 @@ class DefaultSuperTextFieldKeyboardHandlers { /// [moveCaretToStartOrEnd] moves caret to start (using CTL+A) or end of line (using CTL+E) /// on MacOS platforms. This is part of expected behavior on MacOS. Not applicable to Windows. static TextFieldKeyboardHandlerResult moveCaretToStartOrEnd({ - required AttributedTextEditingController controller, - ProseTextLayout? textLayout, - required RawKeyEvent keyEvent, + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, }) { bool moveLeft = false; - if (!keyEvent.isControlPressed) { + if (!HardwareKeyboard.instance.isControlPressed) { return TextFieldKeyboardHandlerResult.notHandled; } if (defaultTargetPlatform != TargetPlatform.macOS) { @@ -1549,7 +1882,7 @@ class DefaultSuperTextFieldKeyboardHandlers { if (keyEvent.logicalKey != LogicalKeyboardKey.keyA && keyEvent.logicalKey != LogicalKeyboardKey.keyE) { return TextFieldKeyboardHandlerResult.notHandled; } - if (controller.selection.extentOffset == -1) { + if (textFieldContext.controller.selection.extentOffset == -1) { return TextFieldKeyboardHandlerResult.notHandled; } @@ -1559,8 +1892,8 @@ class DefaultSuperTextFieldKeyboardHandlers { ? moveLeft = false : null; - controller.moveCaretHorizontally( - textLayout: textLayout!, + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), expandSelection: false, moveLeft: moveLeft, movementModifier: MovementModifier.line, @@ -1572,9 +1905,8 @@ class DefaultSuperTextFieldKeyboardHandlers { /// [moveUpDownLeftAndRightWithArrowKeys] moves caret according to the directional key which was pressed. /// If there is no caret selection. it does nothing. static TextFieldKeyboardHandlerResult moveUpDownLeftAndRightWithArrowKeys({ - required AttributedTextEditingController controller, - required ProseTextLayout textLayout, - required RawKeyEvent keyEvent, + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, }) { const arrowKeys = [ LogicalKeyboardKey.arrowLeft, @@ -1585,19 +1917,29 @@ class DefaultSuperTextFieldKeyboardHandlers { if (!arrowKeys.contains(keyEvent.logicalKey)) { return TextFieldKeyboardHandlerResult.notHandled; } - if (controller.selection.extentOffset == -1) { + + if (isWeb && (textFieldContext.controller.composingRegion.isValid)) { + // We are composing a character on web. It's possible that a native element is being displayed, + // like an emoji picker or a character selection panel. + // We need to let the OS handle the key so the user can navigate + // on the list of possible characters. + // TODO: update this after https://github.com/flutter/flutter/issues/134268 is resolved. + return TextFieldKeyboardHandlerResult.blocked; + } + + if (textFieldContext.controller.selection.extentOffset == -1) { // The result is reported as "handled" because an arrow // key was pressed, but we return early because there is // nowhere to move without a selection. return TextFieldKeyboardHandlerResult.handled; } - if (defaultTargetPlatform == TargetPlatform.windows && keyEvent.isAltPressed) { + if (defaultTargetPlatform == TargetPlatform.windows && HardwareKeyboard.instance.isAltPressed) { return TextFieldKeyboardHandlerResult.notHandled; } if (defaultTargetPlatform == TargetPlatform.linux && - keyEvent.isAltPressed && + HardwareKeyboard.instance.isAltPressed && (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp || keyEvent.logicalKey == LogicalKeyboardKey.arrowDown)) { return TextFieldKeyboardHandlerResult.notHandled; } @@ -1607,17 +1949,17 @@ class DefaultSuperTextFieldKeyboardHandlers { MovementModifier? movementModifier; if ((defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux) && - keyEvent.isControlPressed) { + HardwareKeyboard.instance.isControlPressed) { movementModifier = MovementModifier.word; - } else if (defaultTargetPlatform == TargetPlatform.macOS && keyEvent.isMetaPressed) { + } else if (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isMetaPressed) { movementModifier = MovementModifier.line; - } else if (defaultTargetPlatform == TargetPlatform.macOS && keyEvent.isAltPressed) { + } else if (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isAltPressed) { movementModifier = MovementModifier.word; } - controller.moveCaretHorizontally( - textLayout: textLayout, - expandSelection: keyEvent.isShiftPressed, + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + expandSelection: HardwareKeyboard.instance.isShiftPressed, moveLeft: true, movementModifier: movementModifier, ); @@ -1626,32 +1968,32 @@ class DefaultSuperTextFieldKeyboardHandlers { MovementModifier? movementModifier; if ((defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux) && - keyEvent.isControlPressed) { + HardwareKeyboard.instance.isControlPressed) { movementModifier = MovementModifier.word; - } else if (defaultTargetPlatform == TargetPlatform.macOS && keyEvent.isMetaPressed) { + } else if (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isMetaPressed) { movementModifier = MovementModifier.line; - } else if (defaultTargetPlatform == TargetPlatform.macOS && keyEvent.isAltPressed) { + } else if (defaultTargetPlatform == TargetPlatform.macOS && HardwareKeyboard.instance.isAltPressed) { movementModifier = MovementModifier.word; } - controller.moveCaretHorizontally( - textLayout: textLayout, - expandSelection: keyEvent.isShiftPressed, + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + expandSelection: HardwareKeyboard.instance.isShiftPressed, moveLeft: false, movementModifier: movementModifier, ); } else if (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp) { _log.finer('moveUpDownLeftAndRightWithArrowKeys - handling up arrow key'); - controller.moveCaretVertically( - textLayout: textLayout, - expandSelection: keyEvent.isShiftPressed, + textFieldContext.controller.moveCaretVertically( + textLayout: textFieldContext.getTextLayout(), + expandSelection: HardwareKeyboard.instance.isShiftPressed, moveUp: true, ); } else if (keyEvent.logicalKey == LogicalKeyboardKey.arrowDown) { _log.finer('moveUpDownLeftAndRightWithArrowKeys - handling down arrow key'); - controller.moveCaretVertically( - textLayout: textLayout, - expandSelection: keyEvent.isShiftPressed, + textFieldContext.controller.moveCaretVertically( + textLayout: textFieldContext.getTextLayout(), + expandSelection: HardwareKeyboard.instance.isShiftPressed, moveUp: false, ); } @@ -1660,18 +2002,17 @@ class DefaultSuperTextFieldKeyboardHandlers { } static TextFieldKeyboardHandlerResult moveToLineStartWithHome({ - required AttributedTextEditingController controller, - required ProseTextLayout textLayout, - required RawKeyEvent keyEvent, + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, }) { if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { return TextFieldKeyboardHandlerResult.notHandled; } if (keyEvent.logicalKey == LogicalKeyboardKey.home) { - controller.moveCaretHorizontally( - textLayout: textLayout, - expandSelection: keyEvent.isShiftPressed, + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + expandSelection: HardwareKeyboard.instance.isShiftPressed, moveLeft: true, movementModifier: MovementModifier.line, ); @@ -1682,18 +2023,17 @@ class DefaultSuperTextFieldKeyboardHandlers { } static TextFieldKeyboardHandlerResult moveToLineEndWithEnd({ - required AttributedTextEditingController controller, - required ProseTextLayout textLayout, - required RawKeyEvent keyEvent, + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, }) { if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { return TextFieldKeyboardHandlerResult.notHandled; } if (keyEvent.logicalKey == LogicalKeyboardKey.end) { - controller.moveCaretHorizontally( - textLayout: textLayout, - expandSelection: keyEvent.isShiftPressed, + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + expandSelection: HardwareKeyboard.instance.isShiftPressed, moveLeft: false, movementModifier: MovementModifier.line, ); @@ -1707,11 +2047,10 @@ class DefaultSuperTextFieldKeyboardHandlers { /// Certain keys are currently checked against a blacklist of characters for web /// since their behavior is unexpected. Check definition for more details. static TextFieldKeyboardHandlerResult insertCharacterWhenKeyIsPressed({ - required AttributedTextEditingController controller, - ProseTextLayout? textLayout, - required RawKeyEvent keyEvent, + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, }) { - if (keyEvent.isMetaPressed || keyEvent.isControlPressed) { + if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) { return TextFieldKeyboardHandlerResult.notHandled; } @@ -1734,7 +2073,7 @@ class DefaultSuperTextFieldKeyboardHandlers { return TextFieldKeyboardHandlerResult.notHandled; } - controller.insertCharacter(keyEvent.character!); + textFieldContext.controller.insertCharacter(keyEvent.character!); return TextFieldKeyboardHandlerResult.handled; } @@ -1742,53 +2081,56 @@ class DefaultSuperTextFieldKeyboardHandlers { /// Deletes text between the beginning of the line and the caret, when the user /// presses CMD + Backspace, or CTL + Backspace. static TextFieldKeyboardHandlerResult deleteTextOnLineBeforeCaretWhenShortcutKeyAndBackspaceIsPressed({ - required AttributedTextEditingController controller, - required ProseTextLayout textLayout, - required RawKeyEvent keyEvent, + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, }) { if (!keyEvent.isPrimaryShortcutKeyPressed || keyEvent.logicalKey != LogicalKeyboardKey.backspace) { return TextFieldKeyboardHandlerResult.notHandled; } - if (controller.selection.extentOffset < 0) { + if (textFieldContext.controller.selection.extentOffset < 0) { return TextFieldKeyboardHandlerResult.notHandled; } - if (!controller.selection.isCollapsed) { - controller.deleteSelection(); + if (!textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteSelection(); return TextFieldKeyboardHandlerResult.handled; } - if (textLayout.getPositionAtStartOfLine(controller.selection.extent).offset == controller.selection.extentOffset) { + if (textFieldContext + .getTextLayout() + .getPositionAtStartOfLine(textFieldContext.controller.selection.extent) + .offset == + textFieldContext.controller.selection.extentOffset) { // The caret is sitting at the beginning of a line. There's nothing for us to // delete upstream on this line. But we also don't want a regular BACKSPACE to // run, either. Report this key combination as handled. return TextFieldKeyboardHandlerResult.handled; } - controller.deleteTextOnLineBeforeCaret(textLayout: textLayout); + textFieldContext.controller.deleteTextOnLineBeforeCaret(textLayout: textFieldContext.getTextLayout()); return TextFieldKeyboardHandlerResult.handled; } /// [deleteTextWhenBackspaceOrDeleteIsPressed] deletes single characters when delete or backspace is pressed. static TextFieldKeyboardHandlerResult deleteTextWhenBackspaceOrDeleteIsPressed({ - required AttributedTextEditingController controller, + required SuperTextFieldContext textFieldContext, ProseTextLayout? textLayout, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { final isBackspace = keyEvent.logicalKey == LogicalKeyboardKey.backspace; final isDelete = keyEvent.logicalKey == LogicalKeyboardKey.delete; if (!isBackspace && !isDelete) { return TextFieldKeyboardHandlerResult.notHandled; } - if (controller.selection.extentOffset < 0) { + if (textFieldContext.controller.selection.extentOffset < 0) { return TextFieldKeyboardHandlerResult.notHandled; } - if (controller.selection.isCollapsed) { - controller.deleteCharacter(isBackspace ? TextAffinity.upstream : TextAffinity.downstream); + if (textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteCharacter(isBackspace ? TextAffinity.upstream : TextAffinity.downstream); } else { - controller.deleteSelectedText(); + textFieldContext.controller.deleteSelectedText(); } return TextFieldKeyboardHandlerResult.handled; @@ -1796,44 +2138,42 @@ class DefaultSuperTextFieldKeyboardHandlers { /// [deleteWordWhenAltBackSpaceIsPressedOnMac] deletes single words when Alt+Backspace is pressed on Mac. static TextFieldKeyboardHandlerResult deleteWordWhenAltBackSpaceIsPressedOnMac({ - required AttributedTextEditingController controller, - required ProseTextLayout textLayout, - required RawKeyEvent keyEvent, + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, }) { if (defaultTargetPlatform != TargetPlatform.macOS) { return TextFieldKeyboardHandlerResult.notHandled; } - if (keyEvent.logicalKey != LogicalKeyboardKey.backspace || !keyEvent.isAltPressed) { + if (keyEvent.logicalKey != LogicalKeyboardKey.backspace || !HardwareKeyboard.instance.isAltPressed) { return TextFieldKeyboardHandlerResult.notHandled; } - if (controller.selection.extentOffset < 0) { + if (textFieldContext.controller.selection.extentOffset < 0) { return TextFieldKeyboardHandlerResult.notHandled; } - _deleteUpstreamWord(controller, textLayout); + _deleteUpstreamWord(textFieldContext.controller, textFieldContext.getTextLayout()); return TextFieldKeyboardHandlerResult.handled; } /// [deleteWordWhenAltBackSpaceIsPressedOnMac] deletes single words when Ctl+Backspace is pressed on Windows/Linux. static TextFieldKeyboardHandlerResult deleteWordWhenCtlBackSpaceIsPressedOnWindowsAndLinux({ - required AttributedTextEditingController controller, - required ProseTextLayout textLayout, - required RawKeyEvent keyEvent, + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, }) { if (defaultTargetPlatform != TargetPlatform.windows && defaultTargetPlatform != TargetPlatform.linux) { return TextFieldKeyboardHandlerResult.notHandled; } - if (keyEvent.logicalKey != LogicalKeyboardKey.backspace || !keyEvent.isControlPressed) { + if (keyEvent.logicalKey != LogicalKeyboardKey.backspace || !HardwareKeyboard.instance.isControlPressed) { return TextFieldKeyboardHandlerResult.notHandled; } - if (controller.selection.extentOffset < 0) { + if (textFieldContext.controller.selection.extentOffset < 0) { return TextFieldKeyboardHandlerResult.notHandled; } - _deleteUpstreamWord(controller, textLayout); + _deleteUpstreamWord(textFieldContext.controller, textFieldContext.getTextLayout()); return TextFieldKeyboardHandlerResult.handled; } @@ -1855,22 +2195,39 @@ class DefaultSuperTextFieldKeyboardHandlers { /// [insertNewlineWhenEnterIsPressed] inserts a new line character when the enter key is pressed. static TextFieldKeyboardHandlerResult insertNewlineWhenEnterIsPressed({ - required AttributedTextEditingController controller, + required SuperTextFieldContext textFieldContext, ProseTextLayout? textLayout, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { if (keyEvent.logicalKey != LogicalKeyboardKey.enter && keyEvent.logicalKey != LogicalKeyboardKey.numpadEnter) { return TextFieldKeyboardHandlerResult.notHandled; } - if (!controller.selection.isCollapsed) { + if (!textFieldContext.controller.selection.isCollapsed) { return TextFieldKeyboardHandlerResult.notHandled; } - controller.insertNewline(); + textFieldContext.controller.insertNewline(); return TextFieldKeyboardHandlerResult.handled; } + static TextFieldKeyboardHandlerResult sendKeyEventToMacOs({ + required SuperTextFieldContext textFieldContext, + required KeyEvent keyEvent, + }) { + if (defaultTargetPlatform == TargetPlatform.macOS && !isWeb) { + // On macOS, we let the IME handle all key events. Then, the IME might generate + // selectors which express the user intent, e.g, moveLeftAndModifySelection:. + // + // For the full list of selectors handled by SuperEditor, see the MacOsSelectors class. + // + // This is needed for the interaction with the accent panel to work. + return TextFieldKeyboardHandlerResult.sendToOperatingSystem; + } + + return TextFieldKeyboardHandlerResult.notHandled; + } + DefaultSuperTextFieldKeyboardHandlers._(); } @@ -1913,3 +2270,321 @@ class _EstimatedLineHeight { return _lastLineHeight!; } } + +/// A callback to handle a `performSelector` call. +typedef SuperTextFieldSelectorHandler = void Function({ + required SuperTextFieldContext textFieldContext, +}); + +const defaultTextFieldSelectorHandlers = { + // Control. + MacOsSelectors.insertTab: _moveFocusNext, + MacOsSelectors.cancelOperation: _giveUpFocus, + + // Caret movement. + MacOsSelectors.moveLeft: _moveCaretUpstream, + MacOsSelectors.moveRight: _moveCaretDownstream, + MacOsSelectors.moveUp: _moveCaretUp, + MacOsSelectors.moveDown: _moveCaretDown, + MacOsSelectors.moveForward: _moveCaretDownstream, + MacOsSelectors.moveBackward: _moveCaretUpstream, + MacOsSelectors.moveWordLeft: _moveWordUpstream, + MacOsSelectors.moveWordRight: _moveWordDownstream, + MacOsSelectors.moveToLeftEndOfLine: _moveLineBeginning, + MacOsSelectors.moveToRightEndOfLine: _moveLineEnd, + + // Selection expanding. + MacOsSelectors.moveLeftAndModifySelection: _expandSelectionUpstream, + MacOsSelectors.moveRightAndModifySelection: _expandSelectionDownstream, + MacOsSelectors.moveUpAndModifySelection: _expandSelectionLineUp, + MacOsSelectors.moveDownAndModifySelection: _expandSelectionLineDown, + MacOsSelectors.moveWordLeftAndModifySelection: _expandSelectionWordUpstream, + MacOsSelectors.moveWordRightAndModifySelection: _expandSelectionWordDownstream, + MacOsSelectors.moveToLeftEndOfLineAndModifySelection: _expandSelectionLineUpstream, + MacOsSelectors.moveToRightEndOfLineAndModifySelection: _expandSelectionLineDownstream, + + // Deletion. + MacOsSelectors.deleteBackward: _deleteUpstream, + MacOsSelectors.deleteForward: _deleteDownstream, + MacOsSelectors.deleteWordBackward: _deleteWordUpstream, + MacOsSelectors.deleteWordForward: _deleteWordDownstream, + MacOsSelectors.deleteToBeginningOfLine: _deleteToBeginningOfLine, + MacOsSelectors.deleteToEndOfLine: _deleteToEndOfLine, + MacOsSelectors.deleteBackwardByDecomposingPreviousCharacter: _deleteUpstream, +}; + +void _giveUpFocus({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.focusNode.unfocus(); +} + +void _moveFocusNext({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.focusNode.nextFocus(); +} + +void _moveCaretUpstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: true, + expandSelection: false, + movementModifier: null, + ); +} + +void _moveCaretDownstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: false, + expandSelection: false, + movementModifier: null, + ); +} + +void _moveCaretUp({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretVertically( + textLayout: textFieldContext.getTextLayout(), + moveUp: true, + expandSelection: false, + ); +} + +void _moveCaretDown({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretVertically( + textLayout: textFieldContext.getTextLayout(), + moveUp: false, + expandSelection: false, + ); +} + +void _moveWordUpstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: true, + expandSelection: false, + movementModifier: MovementModifier.word, + ); +} + +void _moveWordDownstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: false, + expandSelection: false, + movementModifier: MovementModifier.word, + ); +} + +void _moveLineBeginning({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: true, + expandSelection: false, + movementModifier: MovementModifier.line, + ); +} + +void _moveLineEnd({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: false, + expandSelection: false, + movementModifier: MovementModifier.line, + ); +} + +void _expandSelectionUpstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: true, + expandSelection: true, + movementModifier: null, + ); +} + +void _expandSelectionDownstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: false, + expandSelection: true, + movementModifier: null, + ); +} + +void _expandSelectionLineUp({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretVertically( + textLayout: textFieldContext.getTextLayout(), + moveUp: true, + expandSelection: true, + ); +} + +void _expandSelectionLineDown({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretVertically( + textLayout: textFieldContext.getTextLayout(), + moveUp: false, + expandSelection: true, + ); +} + +void _expandSelectionWordUpstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: true, + expandSelection: true, + movementModifier: MovementModifier.word, + ); +} + +void _expandSelectionWordDownstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: false, + expandSelection: true, + movementModifier: MovementModifier.word, + ); +} + +void _expandSelectionLineUpstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: true, + expandSelection: true, + movementModifier: MovementModifier.line, + ); +} + +void _expandSelectionLineDownstream({ + required SuperTextFieldContext textFieldContext, +}) { + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + moveLeft: false, + expandSelection: true, + movementModifier: MovementModifier.line, + ); +} + +void _deleteUpstream({ + required SuperTextFieldContext textFieldContext, +}) { + if (textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteCharacter(TextAffinity.upstream); + } else { + textFieldContext.controller.deleteSelectedText(); + } +} + +void _deleteDownstream({ + required SuperTextFieldContext textFieldContext, +}) { + if (textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteCharacter(TextAffinity.downstream); + } else { + textFieldContext.controller.deleteSelectedText(); + } +} + +void _deleteWordUpstream({ + required SuperTextFieldContext textFieldContext, +}) { + if (!textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteSelectedText(); + return; + } + + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + expandSelection: true, + moveLeft: true, + movementModifier: MovementModifier.word, + ); + textFieldContext.controller.deleteSelectedText(); +} + +void _deleteWordDownstream({ + required SuperTextFieldContext textFieldContext, +}) { + if (!textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteSelectedText(); + return; + } + + textFieldContext.controller.moveCaretHorizontally( + textLayout: textFieldContext.getTextLayout(), + expandSelection: true, + moveLeft: false, + movementModifier: MovementModifier.word, + ); + + textFieldContext.controller.deleteSelectedText(); +} + +void _deleteToBeginningOfLine({ + required SuperTextFieldContext textFieldContext, +}) { + if (!textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteSelection(); + return; + } + + if (textFieldContext.getTextLayout().getPositionAtStartOfLine(textFieldContext.controller.selection.extent).offset == + textFieldContext.controller.selection.extentOffset) { + // The caret is sitting at the beginning of a line. There's nothing for us to + // delete upstream on this line. But we also don't want a regular BACKSPACE to + // run, either. Report this key combination as handled. + return; + } + + textFieldContext.controller.deleteTextOnLineBeforeCaret(textLayout: textFieldContext.getTextLayout()); +} + +void _deleteToEndOfLine({ + required SuperTextFieldContext textFieldContext, +}) { + if (!textFieldContext.controller.selection.isCollapsed) { + textFieldContext.controller.deleteSelection(); + return; + } + + if (textFieldContext.getTextLayout().getPositionAtEndOfLine(textFieldContext.controller.selection.extent).offset == + textFieldContext.controller.selection.extentOffset) { + // The caret is sitting at the end of a line. There's nothing for us to + // delete downstream on this line. + return; + } + + textFieldContext.controller.deleteTextOnLineAfterCaret(textLayout: textFieldContext.getTextLayout()); +} diff --git a/super_editor/test/super_editor/text_entry/tagging/action_tags_test.dart b/super_editor/test/super_editor/text_entry/tagging/action_tags_test.dart index 7cef71864f..8327a63621 100644 --- a/super_editor/test/super_editor/text_entry/tagging/action_tags_test.dart +++ b/super_editor/test/super_editor/text_entry/tagging/action_tags_test.dart @@ -678,9 +678,9 @@ Future _pumpTestEditor( ExecutionInstruction _submitOnEnter({ required SuperEditorContext editContext, - required RawKeyEvent keyEvent, + required KeyEvent keyEvent, }) { - if (keyEvent is KeyDownEvent) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { return ExecutionInstruction.continueExecution; } if (keyEvent.logicalKey != LogicalKeyboardKey.enter) { diff --git a/super_editor/test/super_editor/text_entry/text_binding_test.dart b/super_editor/test/super_editor/text_entry/text_binding_test.dart new file mode 100644 index 0000000000..2a39a31365 --- /dev/null +++ b/super_editor/test/super_editor/text_entry/text_binding_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; + +import '../supereditor_test_tools.dart'; + +Future main() async { + // Replace the default test binding with our fake so we can override the + // keyboard modifier state. + // + // This affects all the tests in this file, and can't be reset, so only tests + // that use this binding are in this test file. + final FakeServicesBinding binding = FakeServicesBinding(); + + group('text.dart', () { + group('TextComposable text entry', () { + test('it does nothing when meta is pressed', () { + // Make sure we're using our fake binding so we can override keyboard + // modifier state. + assert(ServicesBinding.instance == binding); + final editContext = _createEditContext(); + + // Press just the meta key. + var result = anyCharacterToInsertInTextContent( + editContext: editContext, + keyEvent: const KeyDownEvent( + logicalKey: LogicalKeyboardKey.meta, + physicalKey: PhysicalKeyboardKey.metaLeft, + timeStamp: Duration.zero, + ), + ); + + // The handler should pass on handling the key. + expect(result, ExecutionInstruction.continueExecution); + + // Press "a" + meta key + binding.fakeKeyboard.isMetaPressed = true; + expect(HardwareKeyboard.instance.isMetaPressed, isTrue); + result = anyCharacterToInsertInTextContent( + editContext: editContext, + keyEvent: const KeyDownEvent( + logicalKey: LogicalKeyboardKey.keyA, + physicalKey: PhysicalKeyboardKey.keyA, + timeStamp: Duration.zero, + ), + ); + + binding.fakeKeyboard.isMetaPressed = false; + // The handler should pass on handling the key. + expect(result, ExecutionInstruction.continueExecution); + }); + }); + }); +} + +SuperEditorContext _createEditContext() { + final document = MutableDocument(); + final composer = MutableDocumentComposer(); + final documentEditor = createDefaultDocumentEditor(document: document, composer: composer); + final fakeLayout = FakeDocumentLayout(); + return SuperEditorContext( + editor: documentEditor, + document: document, + getDocumentLayout: () => fakeLayout, + composer: composer, + scroller: FakeSuperEditorScroller(), + commonOps: CommonEditorOperations( + editor: documentEditor, + document: document, + composer: composer, + documentLayoutResolver: () => fakeLayout, + ), + ); +} + +class FakeServicesBinding extends AutomatedTestWidgetsFlutterBinding { + @override + void initInstances() { + fakeKeyboard = FakeHardwareKeyboard(); + super.initInstances(); + } + + late final FakeHardwareKeyboard fakeKeyboard; + + @override + HardwareKeyboard get keyboard => fakeKeyboard; +} + +class FakeHardwareKeyboard extends HardwareKeyboard { + FakeHardwareKeyboard({ + this.isAltPressed = false, + this.isControlPressed = false, + this.isMetaPressed = false, + this.isShiftPressed = false, + }); + + @override + bool isMetaPressed; + @override + bool isControlPressed; + @override + bool isAltPressed; + @override + bool isShiftPressed; + + @override + bool isLogicalKeyPressed(LogicalKeyboardKey key) { + return switch (key) { + LogicalKeyboardKey.shift || LogicalKeyboardKey.shiftLeft || LogicalKeyboardKey.shiftRight => isShiftPressed, + LogicalKeyboardKey.alt || LogicalKeyboardKey.altLeft || LogicalKeyboardKey.altRight => isAltPressed, + LogicalKeyboardKey.control || LogicalKeyboardKey.controlLeft || LogicalKeyboardKey.controlRight => isControlPressed, + LogicalKeyboardKey.meta || LogicalKeyboardKey.metaLeft || LogicalKeyboardKey.metaRight => isMetaPressed, + _ => super.isLogicalKeyPressed(key) + }; + } +} diff --git a/super_editor/test/super_editor/text_entry/text_test.dart b/super_editor/test/super_editor/text_entry/text_test.dart index 3f2e4d4291..906a240858 100644 --- a/super_editor/test/super_editor/text_entry/text_test.dart +++ b/super_editor/test/super_editor/text_entry/text_test.dart @@ -2,10 +2,9 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:super_editor/super_editor.dart'; -import '../_document_test_tools.dart'; -import '../_text_entry_test_tools.dart'; +import '../supereditor_test_tools.dart'; -void main() { +Future main() async { group('text.dart', () { group('ToggleTextAttributionsCommand', () { test('it toggles selected text and nothing more', () { @@ -21,7 +20,7 @@ void main() { final editor = createDefaultDocumentEditor(document: document, composer: composer); final request = ToggleTextAttributionsRequest( - documentSelection: const DocumentSelection( + documentRange: const DocumentSelection( base: DocumentPosition( nodeId: 'paragraph', nodePosition: TextNodePosition(offset: 1), @@ -50,53 +49,17 @@ void main() { }); group('TextComposable text entry', () { - test('it does nothing when meta is pressed', () { - final editContext = _createEditContext(); - - // Press just the meta key. - var result = anyCharacterToInsertInTextContent( - editContext: editContext, - keyEvent: const FakeRawKeyDownEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.meta, - physicalKey: PhysicalKeyboardKey.metaLeft, - isMetaPressed: true, - isModifierKeyPressed: false, - ), - ), - ); - // The handler should pass on handling the key. - expect(result, ExecutionInstruction.continueExecution); - - // Press "a" + meta key - result = anyCharacterToInsertInTextContent( - editContext: editContext, - keyEvent: const FakeRawKeyDownEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - isMetaPressed: true, - isModifierKeyPressed: false, - ), - ), - ); - - // The handler should pass on handling the key. - expect(result, ExecutionInstruction.continueExecution); - }); - - test('it does nothing when nothing is selected', () { + test('it does nothing when nothing is selected', () async { final editContext = _createEditContext(); // Try to type a character. var result = anyCharacterToInsertInTextContent( editContext: editContext, - keyEvent: const FakeRawKeyDownEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - ), + keyEvent: const KeyDownEvent( + logicalKey: LogicalKeyboardKey.keyA, + physicalKey: PhysicalKeyboardKey.keyA, + timeStamp: Duration.zero, ), ); @@ -136,11 +99,10 @@ void main() { // Try to type a character. var result = anyCharacterToInsertInTextContent( editContext: editContext, - keyEvent: const FakeRawKeyDownEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - ), + keyEvent: const KeyDownEvent( + logicalKey: LogicalKeyboardKey.keyA, + physicalKey: PhysicalKeyboardKey.keyA, + timeStamp: Duration.zero, ), ); @@ -173,11 +135,10 @@ void main() { // Try to type a character. var result = anyCharacterToInsertInTextContent( editContext: editContext, - keyEvent: const FakeRawKeyDownEvent( - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - ), + keyEvent: const KeyDownEvent( + logicalKey: LogicalKeyboardKey.keyA, + physicalKey: PhysicalKeyboardKey.keyA, + timeStamp: Duration.zero, ), ); @@ -185,7 +146,7 @@ void main() { expect(result, ExecutionInstruction.continueExecution); }); - test('it does nothing when the key doesn\'t have a character', () { + testWidgets('it does nothing when the key doesn\'t have a character', (WidgetTester tester) async { final editContext = _createEditContext(); // Add a paragraph to the document. @@ -213,13 +174,11 @@ void main() { // Press the "alt" key var result = anyCharacterToInsertInTextContent( editContext: editContext, - keyEvent: const FakeRawKeyDownEvent( + keyEvent: const KeyDownEvent( character: null, - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.alt, - physicalKey: PhysicalKeyboardKey.altLeft, - isModifierKeyPressed: true, - ), + logicalKey: LogicalKeyboardKey.alt, + physicalKey: PhysicalKeyboardKey.altLeft, + timeStamp: Duration.zero, ), ); @@ -229,12 +188,11 @@ void main() { // Press the "enter" key result = anyCharacterToInsertInTextContent( editContext: editContext, - keyEvent: const FakeRawKeyDownEvent( + keyEvent: const KeyDownEvent( character: '', // Empirically, pressing enter sends '' as the character instead of null - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.enter, - physicalKey: PhysicalKeyboardKey.enter, - ), + logicalKey: LogicalKeyboardKey.enter, + physicalKey: PhysicalKeyboardKey.enter, + timeStamp: Duration.zero, ), ); @@ -270,12 +228,11 @@ void main() { // Press the "a" key var result = anyCharacterToInsertInTextContent( editContext: editContext, - keyEvent: const FakeRawKeyDownEvent( + keyEvent: const KeyDownEvent( character: 'a', - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - ), + logicalKey: LogicalKeyboardKey.keyA, + physicalKey: PhysicalKeyboardKey.keyA, + timeStamp: Duration.zero, ), ); @@ -287,7 +244,7 @@ void main() { ); }); - test('it inserts a non-English character', () { + testWidgets('it inserts a non-English character', (WidgetTester tester) async { final editContext = _createEditContext(); // Add a paragraph to the document. @@ -315,12 +272,11 @@ void main() { // Type a non-English character var result = anyCharacterToInsertInTextContent( editContext: editContext, - keyEvent: const FakeRawKeyDownEvent( + keyEvent: const KeyDownEvent( character: 'ß', - data: FakeRawKeyEventData( - logicalKey: LogicalKeyboardKey.keyA, - physicalKey: PhysicalKeyboardKey.keyA, - ), + logicalKey: LogicalKeyboardKey.keyA, + physicalKey: PhysicalKeyboardKey.keyA, + timeStamp: Duration.zero, ), ); @@ -416,6 +372,7 @@ SuperEditorContext _createEditContext() { document: document, getDocumentLayout: () => fakeLayout, composer: composer, + scroller: FakeSuperEditorScroller(), commonOps: CommonEditorOperations( editor: documentEditor, document: document, diff --git a/super_editor/test/test_tools_user_input.dart b/super_editor/test/test_tools_user_input.dart index 7b03688098..418f390766 100644 --- a/super_editor/test/test_tools_user_input.dart +++ b/super_editor/test/test_tools_user_input.dart @@ -45,90 +45,3 @@ class ImeConnectionWithUpdateCount extends TextInputConnectionDecorator { _contentUpdateCount += 1; } } - -/// Concrete version of [RawKeyEvent] used to manually simulate -/// a specific key event sent from Flutter. -/// -/// [FakeRawKeyDownEvent] does not validate its configuration. It will -/// reflect whatever information you provide in the constructor, even -/// if that configuration couldn't exist in reality. -/// -/// [FakeRawKeyDownEvent] might lack some controls or functionality. It's -/// a tool designed to meet the needs of specific tests. If new tests -/// require broader functionality, then that functionality should be -/// added to [FakeRawKeyDownEvent] and other associated classes. -class FakeRawKeyDownEvent extends RawKeyDownEvent { - const FakeRawKeyDownEvent({ - required RawKeyEventData data, - String? character, - }) : super(data: data, character: character); - - @override - bool get isMetaPressed => data.isMetaPressed; - - @override - bool get isAltPressed => data.isAltPressed; - - @override - bool get isControlPressed => data.isControlPressed; - - @override - bool get isShiftPressed => data.isShiftPressed; -} - -/// Concrete version of [FakeRawKeyEventData] used to manually simulate -/// a specific key event sent from Flutter. -/// -/// [FakeRawKeyEventData] does not validate its configuration. It will -/// reflect whatever information you provide in the constructor, even -/// if that configuration couldn't exist in reality. -/// -/// [FakeRawKeyEventData] might lack some controls or functionality. It's -/// a tool designed to meet the needs of specific tests. If new tests -/// require broader functionality, then that functionality should be -/// added to [FakeRawKeyEventData] and other associated classes. -class FakeRawKeyEventData extends RawKeyEventData { - const FakeRawKeyEventData({ - this.keyLabel = 'fake_key_event', - required this.logicalKey, - required this.physicalKey, - this.isMetaPressed = false, - this.isControlPressed = false, - this.isAltPressed = false, - this.isModifierKeyPressed = false, - this.isShiftPressed = false, - }); - - @override - final String keyLabel; - - @override - final LogicalKeyboardKey logicalKey; - - @override - final PhysicalKeyboardKey physicalKey; - - final bool isModifierKeyPressed; - - @override - final bool isMetaPressed; - - @override - final bool isAltPressed; - - @override - final bool isControlPressed; - - @override - final bool isShiftPressed; - - @override - bool isModifierPressed(ModifierKey key, {KeyboardSide side = KeyboardSide.any}) { - return isModifierKeyPressed; - } - - @override - KeyboardSide? getModifierSide(ModifierKey key) { - throw UnimplementedError(); - } -} diff --git a/super_text_layout/example/pubspec.lock b/super_text_layout/example/pubspec.lock index 37faa6683b..308eb7082c 100644 --- a/super_text_layout/example/pubspec.lock +++ b/super_text_layout/example/pubspec.lock @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: collection - sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.17.2" + version: "1.18.0" convert: dependency: transitive description: @@ -85,10 +85,10 @@ packages: dependency: transitive description: name: coverage - sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097" + sha256: ac86d3abab0f165e4b8f561280ff4e066bceaac83c424dd19f1ae2c2fcd12ca9 url: "https://pub.dev" source: hosted - version: "1.6.3" + version: "1.7.1" crypto: dependency: transitive description: @@ -163,6 +163,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" io: dependency: transitive description: @@ -179,6 +187,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.7" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "2c30f27ada446b4d36307aade893faaaeb6d8f55b2362b7f424a9668fcc43f4d" + url: "https://pub.dev" + source: hosted + version: "9.0.14" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: b06739349ec2477e943055aea30172c5c7000225f79dad4702e2ec0eda79a6ff + url: "https://pub.dev" + source: hosted + version: "1.0.5" lints: dependency: transitive description: @@ -207,18 +231,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.8.0" meta: dependency: transitive description: name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.11.0" mime: dependency: transitive description: @@ -371,10 +395,10 @@ packages: dependency: transitive description: name: test - sha256: "67ec5684c7a19b2aba91d2831f3d305a6fd8e1504629c5818f8d64478abf4f38" + sha256: a1f7595805820fcc05e5c52e3a231aedd0b72972cb333e8c738a8b1239448b6f url: "https://pub.dev" source: hosted - version: "1.24.4" + version: "1.24.9" test_api: dependency: transitive description: @@ -387,10 +411,10 @@ packages: dependency: transitive description: name: test_core - sha256: "6b753899253c38ca0523bb0eccff3934ec83d011705dae717c61ecf209e333c9" + sha256: a757b14fc47507060a162cc2530d9a4a2f92f5100a952c7443b5cad5ef5b106a url: "https://pub.dev" source: hosted - version: "0.5.4" + version: "0.5.9" typed_data: dependency: transitive description: @@ -411,10 +435,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f3743ca475e0c9ef71df4ba15eb2d7684eecd5c8ba20a462462e4e8b561b2e11 + sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 url: "https://pub.dev" source: hosted - version: "11.6.0" + version: "13.0.0" watcher: dependency: transitive description: @@ -427,10 +451,10 @@ packages: dependency: transitive description: name: web - sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 url: "https://pub.dev" source: hosted - version: "0.1.4-beta" + version: "0.3.0" web_socket_channel: dependency: transitive description: @@ -456,5 +480,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.1.0-185.0.dev <4.0.0" + dart: ">=3.2.0-194.0.dev <4.0.0" flutter: ">=1.17.0" diff --git a/website/lib/homepage/editor_toolbar.dart b/website/lib/homepage/editor_toolbar.dart index 79c50be250..6cce40c2b3 100644 --- a/website/lib/homepage/editor_toolbar.dart +++ b/website/lib/homepage/editor_toolbar.dart @@ -236,7 +236,7 @@ class _EditorToolbarState extends State { widget.editor.execute( [ ToggleTextAttributionsRequest( - documentSelection: widget.composer.selection!, + documentRange: widget.composer.selection!, attributions: {boldAttribution}, ), ], @@ -248,7 +248,7 @@ class _EditorToolbarState extends State { widget.editor.execute( [ ToggleTextAttributionsRequest( - documentSelection: widget.composer.selection!, + documentRange: widget.composer.selection!, attributions: {italicsAttribution}, ), ], @@ -260,7 +260,7 @@ class _EditorToolbarState extends State { widget.editor.execute( [ ToggleTextAttributionsRequest( - documentSelection: widget.composer.selection!, + documentRange: widget.composer.selection!, attributions: {strikethroughAttribution}, ), ], diff --git a/website/pubspec.lock b/website/pubspec.lock index ec64dd99fd..14203db393 100644 --- a/website/pubspec.lock +++ b/website/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: collection - sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.17.2" + version: "1.18.0" convert: dependency: transitive description: @@ -93,10 +93,10 @@ packages: dependency: transitive description: name: coverage - sha256: ad538fa2e8f6b828d54c04a438af816ce814de404690136d3b9dfb3a436cd01c + sha256: ac86d3abab0f165e4b8f561280ff4e066bceaac83c424dd19f1ae2c2fcd12ca9 url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.7.1" crypto: dependency: transitive description: @@ -135,10 +135,10 @@ packages: dependency: transitive description: name: flutter_test_robots - sha256: e43d82bce5c17813b1b47b8228a2369936cfe7d73981002bc919b510de350d08 + sha256: a95004a952e079abafabda4685615f8cafe4603bf100eda937a0a038998b7533 url: "https://pub.dev" source: hosted - version: "0.0.18" + version: "0.0.22" flutter_web_plugins: dependency: "direct main" description: flutter @@ -148,10 +148,10 @@ packages: dependency: transitive description: name: follow_the_leader - sha256: "4e74bcf1ed3b4ce9c6743ee9f24bc68c0cf017ca3731d849018eb083c60687fe" + sha256: "7e8937e811a02a2892e6c07552a918a4b1e2dec756aa87042f49777ae1ca1a09" url: "https://pub.dev" source: hosted - version: "0.0.4+2" + version: "0.0.4+6" frontend_server_client: dependency: transitive description: @@ -192,6 +192,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + intl: + dependency: transitive + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" io: dependency: transitive description: @@ -208,14 +216,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.7" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "2c30f27ada446b4d36307aade893faaaeb6d8f55b2362b7f424a9668fcc43f4d" + url: "https://pub.dev" + source: hosted + version: "9.0.14" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: b06739349ec2477e943055aea30172c5c7000225f79dad4702e2ec0eda79a6ff + url: "https://pub.dev" + source: hosted + version: "1.0.5" linkify: dependency: transitive description: name: linkify - sha256: f27f2930577bb65a5d8f2b0676404f3c89a1f354fcb1cf14b96df34e50cddf43 + sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "5.0.0" lint: dependency: "direct dev" description: @@ -244,18 +268,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.8.0" meta: dependency: transitive description: name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.11.0" mime: dependency: transitive description: @@ -276,10 +300,10 @@ packages: dependency: transitive description: name: overlord - sha256: "7fa6a83455b7da5c66a16320c02783d110c574a6e6c511750c662dbadfe9399f" + sha256: "311b50446ec227beafc114968101ae623046cf27887f43c916fa7c5131a145b6" url: "https://pub.dev" source: hosted - version: "0.0.3+2" + version: "0.0.3+4" package_config: dependency: transitive description: @@ -393,18 +417,18 @@ packages: dependency: transitive description: name: stack_trace - sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.11.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" string_scanner: dependency: transitive description: @@ -424,10 +448,10 @@ packages: dependency: "direct main" description: name: super_text_layout - sha256: "8afed48db5e15c9c3488ca9f24b24cd24a4867a6d9d2dd2ba540ac363f50b60d" + sha256: "2f2a8b36553f775c390924f079b5a8ba6c717b0885f44d80a9602bfa182b6f9f" url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "0.1.7" term_glyph: dependency: transitive description: @@ -440,26 +464,26 @@ packages: dependency: transitive description: name: test - sha256: "13b41f318e2a5751c3169137103b60c584297353d4b1761b66029bae6411fe46" + sha256: a1f7595805820fcc05e5c52e3a231aedd0b72972cb333e8c738a8b1239448b6f url: "https://pub.dev" source: hosted - version: "1.24.3" + version: "1.24.9" test_api: dependency: transitive description: name: test_api - sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.6.1" test_core: dependency: transitive description: name: test_core - sha256: "99806e9e6d95c7b059b7a0fc08f07fc53fabe54a829497f0d9676299f1e8637e" + sha256: a757b14fc47507060a162cc2530d9a4a2f92f5100a952c7443b5cad5ef5b106a url: "https://pub.dev" source: hosted - version: "0.5.3" + version: "0.5.9" typed_data: dependency: transitive description: @@ -552,10 +576,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "35ef1bbae978d7158e09c98dcdfe8673b58a30eb53e82833cc027e0aab2d5213" + sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 url: "https://pub.dev" source: hosted - version: "7.5.0" + version: "13.0.0" watcher: dependency: transitive description: @@ -564,6 +588,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + web: + dependency: transitive + description: + name: web + sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + url: "https://pub.dev" + source: hosted + version: "0.3.0" web_socket_channel: dependency: transitive description: @@ -589,5 +621,5 @@ packages: source: hosted version: "3.1.0" sdks: - dart: ">=3.0.0 <4.0.0" + dart: ">=3.2.0-194.0.dev <4.0.0" flutter: ">=3.3.0"