diff --git a/super_editor/.run/Panel Behind Keyboard.run.xml b/super_editor/.run/Panel Behind Keyboard.run.xml new file mode 100644 index 000000000..29b59a5cd --- /dev/null +++ b/super_editor/.run/Panel Behind Keyboard.run.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_android.dart b/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_android.dart index 1e7056927..ca126f766 100644 --- a/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_android.dart +++ b/super_editor/example/lib/demos/editor_configs/demo_mobile_editing_android.dart @@ -21,9 +21,9 @@ class _MobileEditingAndroidDemoState extends State { late DocumentEditor _docEditor; late DocumentComposer _composer; late CommonEditorOperations _docOps; - late SoftwareKeyboardHandler _softwareKeyboardHandler; FocusNode? _editorFocusNode; + SuperEditorImeConfiguration _imeConfiguration = const SuperEditorImeConfiguration(); @override void initState() { @@ -36,11 +36,6 @@ class _MobileEditingAndroidDemoState extends State { composer: _composer, documentLayoutResolver: () => _docLayoutKey.currentState as DocumentLayout, ); - _softwareKeyboardHandler = SoftwareKeyboardHandler( - editor: _docEditor, - composer: _composer, - commonOps: _docOps, - ); _editorFocusNode = FocusNode(); } @@ -53,23 +48,29 @@ class _MobileEditingAndroidDemoState extends State { void _configureImeActionButton() { if (_composer.selection == null || !_composer.selection!.isCollapsed) { - _composer.imeConfiguration.value = _composer.imeConfiguration.value.copyWith( - keyboardActionButton: TextInputAction.newline, - ); + setState(() { + _imeConfiguration = _imeConfiguration.copyWith( + keyboardActionButton: TextInputAction.newline, + ); + }); return; } final selectedNode = _doc.getNodeById(_composer.selection!.extent.nodeId); if (selectedNode is ListItemNode) { - _composer.imeConfiguration.value = _composer.imeConfiguration.value.copyWith( - keyboardActionButton: TextInputAction.done, - ); + setState(() { + _imeConfiguration = _imeConfiguration.copyWith( + keyboardActionButton: TextInputAction.done, + ); + }); return; } - _composer.imeConfiguration.value = _composer.imeConfiguration.value.copyWith( - keyboardActionButton: TextInputAction.newline, - ); + setState(() { + _imeConfiguration = _imeConfiguration.copyWith( + keyboardActionButton: TextInputAction.newline, + ); + }); } @override @@ -83,9 +84,9 @@ class _MobileEditingAndroidDemoState extends State { documentLayoutKey: _docLayoutKey, editor: _docEditor, composer: _composer, - softwareKeyboardHandler: _softwareKeyboardHandler, gestureMode: DocumentGestureMode.android, inputSource: TextInputSource.ime, + imeConfiguration: _imeConfiguration, androidToolbarBuilder: (_) => AndroidTextEditingFloatingToolbar( onCutPressed: () => _docOps.cut(), onCopyPressed: () => _docOps.copy(), diff --git a/super_editor/example/lib/demos/experiments/demo_panel_behind_keyboard.dart b/super_editor/example/lib/demos/experiments/demo_panel_behind_keyboard.dart new file mode 100644 index 000000000..d5123f736 --- /dev/null +++ b/super_editor/example/lib/demos/experiments/demo_panel_behind_keyboard.dart @@ -0,0 +1,316 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:super_editor/super_editor.dart'; + +// This demo is for Android, only. You need to make changes to the Android +// Activity config for this demo to work. +// - In AndroidManifest.xml, find the MainActivity declaration. +// set `android:windowSoftInputMode="adjustResize"` + +class PanelBehindKeyboardDemo extends StatefulWidget { + const PanelBehindKeyboardDemo({ + Key? key, + }) : super(key: key); + + @override + State createState() => _PanelBehindKeyboardDemoState(); +} + +class _PanelBehindKeyboardDemoState extends State { + late final FocusNode _focusNode; + late DocumentEditor _editor; + late DocumentComposer _composer; + final _keyboardController = SoftwareKeyboardController(); + final _keyboardState = ValueNotifier(_InputState.closed); + final _nonKeyboardEditorState = ValueNotifier(_InputState.closed); + + @override + void initState() { + super.initState(); + + _focusNode = FocusNode(); + + _editor = DocumentEditor( + document: MutableDocument(nodes: [ + ParagraphNode( + id: DocumentEditor.createNodeId(), + text: AttributedText(text: "Example Doc"), + metadata: {"blockType": header1Attribution}, + ), + HorizontalRuleNode(id: DocumentEditor.createNodeId()), + ParagraphNode( + id: DocumentEditor.createNodeId(), + text: AttributedText(text: "Unordered list:"), + ), + ListItemNode( + id: DocumentEditor.createNodeId(), + itemType: ListItemType.unordered, + text: AttributedText(text: "Unordered 1"), + ), + ListItemNode( + id: DocumentEditor.createNodeId(), + itemType: ListItemType.unordered, + text: AttributedText(text: "Unordered 2"), + ), + ParagraphNode( + id: DocumentEditor.createNodeId(), + text: AttributedText(text: "Ordered list:"), + ), + ListItemNode( + id: DocumentEditor.createNodeId(), + itemType: ListItemType.unordered, + text: AttributedText(text: "Ordered 1"), + ), + ListItemNode( + id: DocumentEditor.createNodeId(), + itemType: ListItemType.unordered, + text: AttributedText(text: "Ordered 2"), + ), + ParagraphNode( + id: DocumentEditor.createNodeId(), + text: AttributedText(text: 'A blockquote:'), + ), + ParagraphNode( + id: DocumentEditor.createNodeId(), + text: AttributedText(text: 'This is a blockquote.'), + metadata: {"blockType": blockquoteAttribution}, + ), + ParagraphNode( + id: DocumentEditor.createNodeId(), + text: AttributedText(text: 'Some code:'), + ), + ParagraphNode( + id: DocumentEditor.createNodeId(), + text: AttributedText(text: '{\n // This is come code.\n}'), + ), + ParagraphNode( + id: DocumentEditor.createNodeId(), + text: AttributedText(text: "Header"), + metadata: {"blockType": header2Attribution}, + ), + ParagraphNode( + id: DocumentEditor.createNodeId(), + text: AttributedText(text: 'More stuff 1'), + ), + ParagraphNode( + id: DocumentEditor.createNodeId(), + text: AttributedText(text: 'More stuff 2'), + ), + ]), + ); + + _composer = DocumentComposer() // + ..selectionNotifier.addListener(_onSelectionChange); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + // Check the IME connection at the end of the frame so that SuperEditor has + // an opportunity to connect to our software keyboard controller. + _keyboardState.value = _keyboardController.isConnectedToIme ? _InputState.open : _InputState.closed; + }); + } + + @override + void dispose() { + _closeKeyboard(); + _composer.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _onSelectionChange() { + print("Demo: _onSelectionChange()"); + print(" - selection: ${_composer.selection}"); + if (_nonKeyboardEditorState.value == _InputState.open) { + // If the user is currently editing with the non-keyboard editing + // panel, don't open the keyboard to cover it. + return; + } + + if (_composer.selection == null) { + // If there's no selection, we don't want to pop open the keyboard. + return; + } + + print("Opening keyboard from _onSelectionChange()"); + _openKeyboard(); + } + + void _openKeyboard() { + print("Opening keyboard (also connecting to IME, if needed)"); + _keyboardController.open(); + } + + void _closeKeyboard() { + print("Closing keyboard (and disconnecting from IME)"); + _keyboardController.close(); + } + + void _endEditing() { + print("End editing"); + _keyboardController.close(); + _composer.selection = null; + + // If we clear SuperEditor's selection, but leave SuperEditor focused, then + // SuperEditor will automatically place the caret at the end of the document. + // This is because SuperEditor always expects a place for text input when it + // has focus. To prevent this from happening, we explicitly remove focus + // from SuperEditor. + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: false, + body: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: MediaQuery.of(context).viewInsets, + child: SuperEditor( + focusNode: _focusNode, + editor: _editor, + composer: _composer, + softwareKeyboardController: _keyboardController, + imePolicies: SuperEditorImePolicies( + openKeyboardOnSelectionChange: false, + clearSelectionWhenImeDisconnects: false, + ), + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: BehindKeyboardPanel( + keyboardState: _keyboardState, + nonKeyboardEditorState: _nonKeyboardEditorState, + onOpenKeyboard: _openKeyboard, + onCloseKeyboard: _closeKeyboard, + onEndEditing: _endEditing, + ), + ), + ], + ), + ); + } +} + +class BehindKeyboardPanel extends StatefulWidget { + const BehindKeyboardPanel({ + Key? key, + required this.keyboardState, + required this.nonKeyboardEditorState, + required this.onOpenKeyboard, + required this.onCloseKeyboard, + required this.onEndEditing, + }) : super(key: key); + + final ValueNotifier<_InputState> keyboardState; + final ValueNotifier<_InputState> nonKeyboardEditorState; + final VoidCallback onOpenKeyboard; + final VoidCallback onCloseKeyboard; + final VoidCallback onEndEditing; + + @override + State createState() => _BehindKeyboardPanelState(); +} + +class _BehindKeyboardPanelState extends State { + double _maxBottomInsets = 0.0; + double _latestBottomInsets = 0.0; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final newBottomInset = MediaQuery.of(context).viewInsets.bottom; + print("BehindKeyboardPanel didChangeDependencies() - bottom inset: $newBottomInset"); + if (newBottomInset > _maxBottomInsets) { + print("Setting max bottom insets to: $newBottomInset"); + _maxBottomInsets = newBottomInset; + widget.nonKeyboardEditorState.value = _InputState.open; + + if (widget.keyboardState.value != _InputState.open) { + setState(() { + widget.keyboardState.value = _InputState.open; + }); + } + } else if (newBottomInset > _latestBottomInsets) { + print("Keyboard is opening. We're already expanded"); + // The keyboard is expanding, but we're already expanded. Make sure + // that our internal accounting for keyboard state is updated. + if (widget.keyboardState.value != _InputState.open) { + setState(() { + widget.keyboardState.value = _InputState.open; + }); + } + } else if (widget.nonKeyboardEditorState.value == _InputState.closed) { + // We don't want to be expanded. Follow the keyboard back down. + _maxBottomInsets = newBottomInset; + } else { + // The keyboard is collapsing, but we want to stay expanded. Make sure + // our internal accounting for keyboard state is updated. + if (widget.keyboardState.value == _InputState.open) { + setState(() { + widget.keyboardState.value = _InputState.closed; + }); + } + } + + _latestBottomInsets = newBottomInset; + } + + void _closeKeyboardAndPanel() { + setState(() { + widget.nonKeyboardEditorState.value = _InputState.closed; + _maxBottomInsets = min(_latestBottomInsets, _maxBottomInsets); + }); + + widget.onEndEditing(); + } + + @override + Widget build(BuildContext context) { + print("Building toolbar. Is expanded? ${widget.keyboardState.value == _InputState.open}"); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.infinity, + height: 54, + color: Colors.grey.shade100, + child: Row( + children: [ + const SizedBox(width: 24), + GestureDetector( + onTap: _closeKeyboardAndPanel, + child: Icon(Icons.close), + ), + Spacer(), + GestureDetector( + onTap: widget.keyboardState.value == _InputState.open ? widget.onCloseKeyboard : widget.onOpenKeyboard, + child: Icon(widget.keyboardState.value == _InputState.open ? Icons.keyboard_hide : Icons.keyboard), + ), + const SizedBox(width: 24), + ], + ), + ), + SizedBox( + width: double.infinity, + height: _maxBottomInsets, + child: ColoredBox( + color: Colors.grey.shade300, + ), + ), + ], + ); + } +} + +enum _InputState { + open, + closed, +} diff --git a/super_editor/example/lib/main.dart b/super_editor/example/lib/main.dart index 8be004722..34cee5409 100644 --- a/super_editor/example/lib/main.dart +++ b/super_editor/example/lib/main.dart @@ -38,7 +38,7 @@ Future main() async { initLoggers(Level.FINEST, { // editorScrollingLog, // editorGesturesLog, - // editorImeLog, + editorImeLog, // editorKeyLog, // editorOpsLog, // editorLayoutLog, diff --git a/super_editor/example/lib/main_panel_behind_keyboard.dart b/super_editor/example/lib/main_panel_behind_keyboard.dart new file mode 100644 index 000000000..505faaf81 --- /dev/null +++ b/super_editor/example/lib/main_panel_behind_keyboard.dart @@ -0,0 +1,24 @@ +import 'package:example/demos/experiments/demo_panel_behind_keyboard.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:super_editor/super_editor.dart'; + +/// Demo with a panel that appears behind the keyboard. +/// +/// Initially, the panel isn't visible. Then the user opens the keyboard, and the +/// panel is hidden behind the keyboard. Then the user closes the keyboard, and +/// exposes the panel. +/// +/// This demo sits in its own entrypoint because it needs to alter the standard +/// `Scaffold` behavior for insets, which is hard-coded for all demos in the +/// regular example entrypoint. +void main() { + initLoggers(Level.FINEST, { + editorGesturesLog, + editorImeLog, + }); + + runApp(MaterialApp( + home: PanelBehindKeyboardDemo(), + )); +} diff --git a/super_editor/example/pubspec.lock b/super_editor/example/pubspec.lock index d4c806182..46622a97b 100644 --- a/super_editor/example/pubspec.lock +++ b/super_editor/example/pubspec.lock @@ -313,10 +313,10 @@ packages: dependency: transitive description: name: matcher - sha256: c94db23593b89766cda57aab9ac311e3616cf87c6fa4e9749df032f66f30dcb8 + sha256: "16db949ceee371e9b99d22f88fa3a73c4e59fd0afed0bd25fc336eb76c198b72" url: "https://pub.dev" source: hosted - version: "0.12.14" + version: "0.12.13" material_color_utilities: dependency: transitive description: @@ -361,10 +361,10 @@ packages: dependency: transitive description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: db9d4f58c908a4ba5953fcee2ae317c94889433e5024c27ce74a37f94267945b url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.8.2" path_provider: dependency: transitive description: @@ -579,26 +579,26 @@ packages: dependency: transitive description: name: test - sha256: "98403d1090ac0aa9e33dfc8bf45cc2e0c1d5c58d7cb832cee1e50bf14f37961d" + sha256: a5fcd2d25eeadbb6589e80198a47d6a464ba3e2049da473943b8af9797900c2d url: "https://pub.dev" source: hosted - version: "1.22.1" + version: "1.22.0" test_api: dependency: transitive description: name: test_api - sha256: c9282698e2982b6c3817037554e52f99d4daba493e8028f8112a83d68ccd0b12 + sha256: ad540f65f92caa91bf21dfc8ffb8c589d6e4dc0c2267818b4cc2792857706206 url: "https://pub.dev" source: hosted - version: "0.4.17" + version: "0.4.16" test_core: dependency: transitive description: name: test_core - sha256: c9e4661a5e6285b795d47ba27957ed8b6f980fc020e98b218e276e88aff02168 + sha256: "0ef9755ec6d746951ba0aabe62f874b707690b5ede0fecc818b138fcc9b14888" url: "https://pub.dev" source: hosted - version: "0.4.21" + version: "0.4.20" typed_data: dependency: transitive description: diff --git a/super_editor/lib/src/core/document.dart b/super_editor/lib/src/core/document.dart index 813175242..b0e3bb4d8 100644 --- a/super_editor/lib/src/core/document.dart +++ b/super_editor/lib/src/core/document.dart @@ -86,7 +86,7 @@ class DocumentRange { /// /// The [start] position must come before the [end] position in /// the document. - DocumentRange({ + const DocumentRange({ required this.start, required this.end, }); diff --git a/super_editor/lib/src/core/document_composer.dart b/super_editor/lib/src/core/document_composer.dart index e66ab58fe..43137da11 100644 --- a/super_editor/lib/src/core/document_composer.dart +++ b/super_editor/lib/src/core/document_composer.dart @@ -3,9 +3,9 @@ import 'dart:async'; import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/foundation.dart'; import 'package:super_editor/src/core/document.dart'; -import 'package:super_editor/src/default_editor/document_input_ime.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; +import '../default_editor/document_ime/document_input_ime.dart'; import 'document_selection.dart'; /// Maintains a [DocumentSelection] within a [Document] and @@ -17,9 +17,8 @@ class DocumentComposer with ChangeNotifier { /// desired. DocumentComposer({ DocumentSelection? initialSelection, - ImeConfiguration? imeConfiguration, - }) : imeConfiguration = ValueNotifier(imeConfiguration ?? const ImeConfiguration()), - _preferences = ComposerPreferences() { + SuperEditorImeConfiguration? imeConfiguration, + }) : _preferences = ComposerPreferences() { _streamController = StreamController.broadcast(); selectionNotifier.addListener(_onSelectionChangedBySelectionNotifier); selectionNotifier.value = initialSelection; @@ -43,6 +42,7 @@ class DocumentComposer with ChangeNotifier { set selection(DocumentSelection? newSelection) { if (newSelection != selectionNotifier.value) { selectionNotifier.value = newSelection; + notifyListeners(); } } @@ -55,6 +55,7 @@ class DocumentComposer with ChangeNotifier { selection: newSelection, reason: reason, ); + _streamController.sink.add(_latestSelectionChange); // Remove the listener, so we don't emit another DocumentSelectionChange. @@ -103,10 +104,20 @@ class DocumentComposer with ChangeNotifier { selection: selectionNotifier.value, reason: SelectionReason.userInteraction, ); + + // Reset the composing region whenever the selection changes. + // After a selection change, if the IME wants a composing region, + // we expect the IME to call us back with that region. + composingRegion.value = null; + _streamController.sink.add(_latestSelectionChange); } - final ValueNotifier imeConfiguration; + /// The current composing region, which signifies spans of text + /// that the IME is thinking about changing. + /// + /// Only valid when editing a document with an IME input method. + final composingRegion = ValueNotifier(null); final ComposerPreferences _preferences; diff --git a/super_editor/lib/src/default_editor/common_editor_operations.dart b/super_editor/lib/src/default_editor/common_editor_operations.dart index 478d6fa27..ff8a53931 100644 --- a/super_editor/lib/src/default_editor/common_editor_operations.dart +++ b/super_editor/lib/src/default_editor/common_editor_operations.dart @@ -1345,6 +1345,7 @@ class CommonEditorOperations { final initialTextOffset = (composer.selection!.extent.nodePosition as TextNodePosition).offset; editorOpsLog.fine("Executing text insertion command."); + editorOpsLog.finer("Text before insertion: '${textNode.text.text}'"); editor.executeCommand( InsertTextCommand( documentPosition: composer.selection!.extent, @@ -1352,6 +1353,7 @@ class CommonEditorOperations { attributions: composer.preferences.currentAttributions, ), ); + editorOpsLog.finer("Text after insertion: '${textNode.text.text}'"); editorOpsLog.fine("Updating Document Composer selection after text insertion."); composer.selection = DocumentSelection.collapsed( 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 2a27f377c..0924b6272 100644 --- a/super_editor/lib/src/default_editor/document_gestures_mouse.dart +++ b/super_editor/lib/src/default_editor/document_gestures_mouse.dart @@ -174,6 +174,10 @@ class _DocumentMouseInteractorState extends State // so that any pending visual document changes can happen before // attempting to calculate the visual position of the selection extent. WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (!mounted) { + return; + } + editorGesturesLog.finer("Ensuring selection extent is visible because the doc selection changed"); final globalExtentRect = _getSelectionExtentAsGlobalRect(); diff --git a/super_editor/lib/src/default_editor/document_gestures_touch_android.dart b/super_editor/lib/src/default_editor/document_gestures_touch_android.dart index 570d150b8..0d621cd90 100644 --- a/super_editor/lib/src/default_editor/document_gestures_touch_android.dart +++ b/super_editor/lib/src/default_editor/document_gestures_touch_android.dart @@ -356,6 +356,10 @@ class _AndroidDocumentTouchInteractorState extends State // The selection change might correspond to new content that's not // laid out yet. Wait until the next frame to update visuals. WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (!mounted) { + return; + } + _updateHandlesAfterSelectionOrLayoutChange(); }); } @@ -540,8 +544,6 @@ class _IOSDocumentTouchInteractorState extends State return; } - widget.selection.value = null; - bool didSelectContent = _selectWordAt( docPosition: docPosition, docLayout: _docLayout, @@ -604,8 +606,6 @@ class _IOSDocumentTouchInteractorState extends State return; } - widget.selection.value = null; - final didSelectParagraph = _selectParagraphAt( docPosition: docPosition, docLayout: _docLayout, diff --git a/super_editor/lib/src/default_editor/document_hardware_keyboard/document_input_keyboard.dart b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_input_keyboard.dart new file mode 100644 index 000000000..eccfe9d7f --- /dev/null +++ b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_input_keyboard.dart @@ -0,0 +1,10 @@ +export 'document_physical_keyboard.dart'; +export 'document_keyboard_actions.dart'; + +/// This file exports various document hardware keyboard tools. +/// +/// A document editor might be configured to work exclusively with +/// hardware keyboard keys. Or, what's more likely, is that a document +/// editor is configured to process Input Method Engine (IME) input +/// for most content editing, but the editor still receives and processes +/// hardware key events for things like arrow keys, tab keys, etc. diff --git a/super_editor/lib/src/default_editor/document_keyboard_actions.dart b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_keyboard_actions.dart similarity index 99% rename from super_editor/lib/src/default_editor/document_keyboard_actions.dart rename to super_editor/lib/src/default_editor/document_hardware_keyboard/document_keyboard_actions.dart index 0e3ef730b..33505d24d 100644 --- a/super_editor/lib/src/default_editor/document_keyboard_actions.dart +++ b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_keyboard_actions.dart @@ -5,11 +5,10 @@ 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/default_editor/attributions.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/text.dart'; import 'package:super_editor/src/infrastructure/keyboard.dart'; -import 'paragraph.dart'; -import 'text.dart'; - ExecutionInstruction doNothingWhenThereIsNoSelection({ required EditContext editContext, required RawKeyEvent keyEvent, diff --git a/super_editor/lib/src/default_editor/document_input_keyboard.dart b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_physical_keyboard.dart similarity index 69% rename from super_editor/lib/src/default_editor/document_input_keyboard.dart rename to super_editor/lib/src/default_editor/document_hardware_keyboard/document_physical_keyboard.dart index aa345b648..5fdbfa7cd 100644 --- a/super_editor/lib/src/default_editor/document_input_keyboard.dart +++ b/super_editor/lib/src/default_editor/document_hardware_keyboard/document_physical_keyboard.dart @@ -4,34 +4,29 @@ import 'package:super_editor/src/core/edit_context.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:super_editor/src/infrastructure/keyboard.dart'; -/// Governs document input that comes from a physical keyboard. +/// Applies appropriate edits to a document and selection when the user presses +/// hardware keys. /// -/// Keyboard input won't work on a mobile device with a software -/// keyboard because the software keyboard sends input through -/// the operating system's Input Method Engine. For mobile use-cases, -/// see super_editor's IME input support. - -/// Receives all keyboard input, when focused, and invokes relevant document -/// editing actions on the given [editContext.editor]. +/// Hardware key events are dispatched through [FocusNode]s, therefore, this +/// widget's [FocusNode] needs to be focused for key events to be applied. A +/// [FocusNode] can be provided, or this widget will create its own [FocusNode] +/// internally, which is wrapped around the given [child]. /// /// [keyboardActions] determines the mapping from keyboard key presses /// to document editing behaviors. [keyboardActions] operates as a /// Chain of Responsibility. -class DocumentKeyboardInteractor extends StatelessWidget { - const DocumentKeyboardInteractor({ +class SuperEditorHardwareKeyHandler extends StatefulWidget { + const SuperEditorHardwareKeyHandler({ Key? key, - required this.focusNode, + this.focusNode, required this.editContext, - required this.keyboardActions, - required this.child, + this.keyboardActions = const [], this.autofocus = false, + required this.child, }) : super(key: key); /// The source of all key events. - final FocusNode focusNode; - - /// Whether or not the [DocumentKeyboardInteractor] should autofocus - final bool autofocus; + final FocusNode? focusNode; /// Service locator for document editing dependencies. final EditContext editContext; @@ -45,10 +40,34 @@ class DocumentKeyboardInteractor extends StatelessWidget { /// stops. Otherwise, execution continues to the next [DocumentKeyboardAction]. final List keyboardActions; + /// Whether or not the [SuperEditorHardwareKeyHandler] should autofocus + final bool autofocus; + /// The [child] widget, which is expected to include the document UI /// somewhere in the sub-tree. final Widget child; + @override + State createState() => _SuperEditorHardwareKeyHandlerState(); +} + +class _SuperEditorHardwareKeyHandlerState extends State { + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = (widget.focusNode ?? FocusNode()); + } + + @override + void dispose() { + if (widget.focusNode == null) { + _focusNode.dispose(); + } + super.dispose(); + } + KeyEventResult _onKeyPressed(FocusNode node, RawKeyEvent keyEvent) { if (keyEvent is! RawKeyDownEvent) { editorKeyLog.finer("Received key event, but ignoring because it's not a down event: $keyEvent"); @@ -58,9 +77,9 @@ class DocumentKeyboardInteractor extends StatelessWidget { editorKeyLog.info("Handling key press: $keyEvent"); ExecutionInstruction instruction = ExecutionInstruction.continueExecution; int index = 0; - while (instruction == ExecutionInstruction.continueExecution && index < keyboardActions.length) { - instruction = keyboardActions[index]( - editContext: editContext, + while (instruction == ExecutionInstruction.continueExecution && index < widget.keyboardActions.length) { + instruction = widget.keyboardActions[index]( + editContext: widget.editContext, keyEvent: keyEvent, ); index += 1; @@ -78,10 +97,10 @@ class DocumentKeyboardInteractor extends StatelessWidget { @override Widget build(BuildContext context) { return Focus( - focusNode: focusNode, - onKey: _onKeyPressed, - autofocus: autofocus, - child: child, + focusNode: _focusNode, + onKey: widget.keyboardActions.isEmpty ? null : _onKeyPressed, + autofocus: widget.autofocus, + child: widget.child, ); } } diff --git a/super_editor/lib/src/default_editor/document_ime/document_delta_editing.dart b/super_editor/lib/src/default_editor/document_ime/document_delta_editing.dart new file mode 100644 index 000000000..4dd2123b0 --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/document_delta_editing.dart @@ -0,0 +1,279 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_editor.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/common_editor_operations.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; + +import 'document_serialization.dart'; + +/// Applies software keyboard text deltas to a document. +class TextDeltasDocumentEditor { + const TextDeltasDocumentEditor({ + required this.editor, + required this.selection, + required this.composingRegion, + required this.commonOps, + }); + + final DocumentEditor editor; + final ValueNotifier selection; + final ValueNotifier composingRegion; + final CommonEditorOperations commonOps; + + /// Applies the given [textEditingDeltas] to the [Document]. + void applyDeltas(List textEditingDeltas) { + editorImeLog.info("Applying ${textEditingDeltas.length} IME deltas to document"); + + editorImeLog.fine("Serializing document to perform IME operation"); + final serializedDocBeforeDelta = DocumentImeSerializer( + editor.document, + selection.value!, + composingRegion.value, + ); + + // Apply deltas to the document. + for (final delta in textEditingDeltas) { + editorImeLog.info("Applying delta: $delta"); + if (delta is TextEditingDeltaInsertion) { + _applyInsertion(delta, serializedDocBeforeDelta); + } else if (delta is TextEditingDeltaReplacement) { + _applyReplacement(delta, serializedDocBeforeDelta); + } else if (delta is TextEditingDeltaDeletion) { + _applyDeletion(delta, serializedDocBeforeDelta); + } else if (delta is TextEditingDeltaNonTextUpdate) { + _applyNonTextChange(delta); + } else { + editorImeLog.shout("Unknown IME delta type: ${delta.runtimeType}"); + } + } + + // Update the editor's IME composing region based on the composing region + // for the last delta. If the version of our document serialized hidden + // characters in the IME, adjust for those hidden characters before setting + // the IME composing region. + editorImeLog.fine("After applying all deltas, converting the final composing region to a document range."); + editorImeLog + .fine("Serializing the latest document and selection to use to compute final document composing region."); + final finalSerializedDoc = DocumentImeSerializer( + editor.document, + selection.value!, + null, + serializedDocBeforeDelta.didPrependPlaceholder // + ? PrependedCharacterPolicy.include + : PrependedCharacterPolicy.exclude, + ); + editorImeLog.fine("Raw IME delta composing region: ${textEditingDeltas.last.composing}"); + composingRegion.value = finalSerializedDoc.imeToDocumentRange(textEditingDeltas.last.composing); + editorImeLog.fine("Document composing region: ${composingRegion.value}"); + } + + void _applyInsertion(TextEditingDeltaInsertion delta, DocumentImeSerializer docSerializer) { + editorImeLog.fine('Inserted text: "${delta.textInserted}"'); + editorImeLog.fine("Insertion offset: ${delta.insertionOffset}"); + editorImeLog.fine("Selection: ${delta.selection}"); + editorImeLog.fine("Composing: ${delta.composing}"); + editorImeLog.fine('Old text: "${delta.oldText}"'); + + if (delta.textInserted == "\n") { + // On iOS, newlines are reported here and also to performAction(). + // On Android and web, newlines are only reported here. So, on Android and web, + // we forward the newline action to performAction. + if (defaultTargetPlatform == TargetPlatform.android || kIsWeb) { + editorImeLog.fine("Received a newline insertion on Android. Forwarding to newline input action."); + performAction(TextInputAction.newline); + } else { + editorImeLog.fine("Skipping insertion delta because its a newline"); + } + return; + } + + if (delta.textInserted == "\t" && (defaultTargetPlatform == TargetPlatform.iOS)) { + // On iOS, tabs pressed at the the software keyboard are reported here. + commonOps.indentListItem(); + return; + } + + editorImeLog.fine( + "Inserting text: '${delta.textInserted}', at insertion offset: ${delta.insertionOffset}, with ime selection: ${delta.selection}"); + + editorImeLog.fine("Converting IME insertion offset into a DocumentSelection"); + final insertionSelection = docSerializer.imeToDocumentSelection( + TextSelection.fromPosition(TextPosition( + offset: delta.insertionOffset, + affinity: delta.selection.affinity, + )), + )!; + + insert( + insertionSelection, + delta.textInserted, + ); + } + + void _applyReplacement(TextEditingDeltaReplacement delta, DocumentImeSerializer docSerializer) { + editorImeLog.fine("Text replaced: '${delta.textReplaced}'"); + editorImeLog.fine("Replacement text: '${delta.replacementText}'"); + editorImeLog.fine("Replaced range: ${delta.replacedRange}"); + editorImeLog.fine("Selection: ${delta.selection}"); + editorImeLog.fine("Composing: ${delta.composing}"); + editorImeLog.fine('Old text: "${delta.oldText}"'); + + if (delta.replacementText == "\n") { + // On iOS, newlines are reported here and also to performAction(). + // On Android and web, newlines are only reported here. So, on Android and web, + // we forward the newline action to performAction. + if (defaultTargetPlatform == TargetPlatform.android || kIsWeb) { + editorImeLog.fine("Received a newline replacement on Android. Forwarding to newline input action."); + performAction(TextInputAction.newline); + } else { + editorImeLog.fine("Skipping replacement delta because its a newline"); + } + return; + } + + if (delta.replacementText == "\t" && (defaultTargetPlatform == TargetPlatform.iOS)) { + // On iOS, tabs pressed at the the software keyboard are reported here. + commonOps.indentListItem(); + return; + } + + replace(delta.replacedRange, delta.replacementText, docSerializer); + } + + void _applyDeletion(TextEditingDeltaDeletion delta, DocumentImeSerializer docSerializer) { + editorImeLog.fine("Delete delta:\n" + "Text deleted: '${delta.textDeleted}'\n" + "Deleted Range: ${delta.deletedRange}\n" + "Selection: ${delta.selection}\n" + "Composing: ${delta.composing}\n" + "Old text: '${delta.oldText}'"); + + delete(delta.deletedRange, docSerializer); + + editorImeLog.fine("Deletion operation complete"); + } + + void _applyNonTextChange(TextEditingDeltaNonTextUpdate delta) { + editorImeLog.fine("Non-text change:"); + editorImeLog.fine("OS-side selection - ${delta.selection}"); + editorImeLog.fine("OS-side composing - ${delta.composing}"); + + final docSelection = DocumentImeSerializer( + editor.document, + selection.value!, + null, + ).imeToDocumentSelection(delta.selection); + if (docSelection != null) { + // We got a selection from the platform. + // This could happen in some software keyboards, like GBoard, + // where the user can swipe over the spacebar to change the selection. + selection.value = docSelection; + } + } + + void insert(DocumentSelection insertionSelection, String textInserted) { + editorImeLog.fine('Inserting "$textInserted" at position "$insertionSelection"'); + editorImeLog + .fine("Updating the Document Composer's selection to place caret at insertion offset:\n$insertionSelection"); + final selectionBeforeInsertion = selection.value; + selection.value = insertionSelection; + + editorImeLog.fine("Inserting the text at the Document Composer's selection"); + final didInsert = commonOps.insertPlainText(textInserted); + editorImeLog.fine("Insertion successful? $didInsert"); + + if (!didInsert) { + editorImeLog.fine("Failed to insert characters. Restoring previous selection."); + selection.value = selectionBeforeInsertion; + } + + commonOps.convertParagraphByPatternMatching( + selection.value!.extent.nodeId, + ); + } + + void replace(TextRange replacedRange, String replacementText, DocumentImeSerializer docSerializer) { + final replacementSelection = docSerializer.imeToDocumentSelection(TextSelection( + baseOffset: replacedRange.start, + // TODO: the delta API is wrong for TextRange.end, it should be exclusive, + // but it's implemented as inclusive. Change this code when Flutter + // fixes the problem. + extentOffset: replacedRange.end, + )); + + if (replacementSelection != null) { + selection.value = replacementSelection; + } + editorImeLog.fine("Replacing selection: $replacementSelection"); + editorImeLog.fine('With text: "$replacementText"'); + + if (replacementText == "\n") { + performAction(TextInputAction.newline); + return; + } + + commonOps.insertPlainText(replacementText); + + commonOps.convertParagraphByPatternMatching( + selection.value!.extent.nodeId, + ); + } + + void delete(TextRange deletedRange, DocumentImeSerializer docSerializer) { + final rangeToDelete = deletedRange; + final docSelectionToDelete = docSerializer.imeToDocumentSelection(TextSelection( + baseOffset: rangeToDelete.start, + extentOffset: rangeToDelete.end, + )); + editorImeLog.fine("Doc selection to delete: $docSelectionToDelete"); + + if (docSelectionToDelete == null) { + final selectedNodeIndex = editor.document.getNodeIndexById( + selection.value!.extent.nodeId, + ); + if (selectedNodeIndex > 0) { + // The user is trying to delete upstream at the start of a node. + // This action requires intervention because the IME doesn't know + // that there's more content before this node. Instruct the editor + // to run a delete action upstream, which will take the desired + // "backspace" behavior at the start of this node. + commonOps.deleteUpstream(); + editorImeLog.fine("Deleted upstream. New selection: ${selection.value}"); + return; + } + } + + editorImeLog.fine("Running selection deletion operation"); + selection.value = docSelectionToDelete; + commonOps.deleteSelection(); + } + + void performAction(TextInputAction action) { + switch (action) { + case TextInputAction.newline: + if (!selection.value!.isCollapsed) { + commonOps.deleteSelection(); + } + commonOps.insertBlockLevelNewline(); + break; + case TextInputAction.none: + // no-op + break; + case TextInputAction.done: + case TextInputAction.go: + case TextInputAction.search: + case TextInputAction.send: + case TextInputAction.next: + case TextInputAction.previous: + case TextInputAction.continueAction: + case TextInputAction.join: + case TextInputAction.route: + case TextInputAction.emergencyCall: + case TextInputAction.unspecified: + editorImeLog.warning("User pressed unhandled action button: $action"); + break; + } + } +} diff --git a/super_editor/lib/src/default_editor/document_ime/document_ime_communication.dart b/super_editor/lib/src/default_editor/document_ime/document_ime_communication.dart new file mode 100644 index 000000000..4fd3adde9 --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/document_ime_communication.dart @@ -0,0 +1,386 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/flutter_scheduler.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; + +import 'document_delta_editing.dart'; +import 'document_serialization.dart'; +import 'ime_decoration.dart'; + +/// Sends messages to, and receives messages from, the platform Input Method Engine (IME), +/// for the purpose of document editing. + +/// Widget that keeps a document, selection, and composing region synchronized +/// with the value in the platform Input Method Engine (IME). +/// +/// When the [document], [selection], or [composingRegion] changes, are serialized +/// and sent to the IME. +class DocumentToImeSynchronizer extends StatefulWidget { + const DocumentToImeSynchronizer({ + Key? key, + required this.document, + required this.selection, + required this.composingRegion, + required this.imeConnection, + required this.child, + }) : super(key: key); + + /// [Document] whose content is serialized and sent to the platform IME. + final Document document; + + /// The document's current selection. + final ValueNotifier selection; + + /// The document's current composing region, which represents a section + /// of content that the platform IME is thinking about changing, such as spelling + /// autocorrection. + final ValueListenable composingRegion; + + /// A connection to the platform IME, which might be open or closed. + /// + /// For Flutter test timing purposes, it's critical that [imeConnection] be + /// a [ValueListenable]. By notifying this widget through a [ValueListenable], + /// this widget is able to talk to the IME immediately, without waiting for + /// another tree pump, i.e., `didUpdateWidget()`. In other words, by using + /// a [ValueListenable] instead of a raw [TextInputConnection], this widget + /// can setup the initial IME content on the very first `pumpWidget()` within + /// a test, without requiring additional `pump()`s. Existing tests depend on + /// this fact. + final ValueListenable imeConnection; + + final Widget child; + + @override + State createState() => _DocumentToImeSynchronizerState(); +} + +class _DocumentToImeSynchronizerState extends State { + bool _hasSentInitialImeValue = false; + bool _needsSync = false; + + @override + void initState() { + super.initState(); + + widget.document.addListener(_onDocumentChange); + widget.selection.addListener(_onSelectionChange); + widget.imeConnection.addListener(_onImeConnectionChange); + + if (widget.selection.value != null) { + _sendDocumentToIme(); + } + } + + @override + void didUpdateWidget(DocumentToImeSynchronizer oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.document != oldWidget.document) { + oldWidget.document.removeListener(_onDocumentChange); + widget.document.addListener(_onDocumentChange); + _sendDocumentToImeOnNextFrame(); + } + + if (widget.selection != oldWidget.selection) { + oldWidget.selection.removeListener(_onSelectionChange); + widget.selection.addListener(_onSelectionChange); + _onSelectionChange(); + } + + if (widget.imeConnection != oldWidget.imeConnection) { + oldWidget.imeConnection.removeListener(_onImeConnectionChange); + widget.imeConnection.addListener(_onImeConnectionChange); + _onImeConnectionChange(); + } + } + + @override + void dispose() { + widget.document.removeListener(_onDocumentChange); + widget.selection.removeListener(_onSelectionChange); + widget.imeConnection.removeListener(_onImeConnectionChange); + super.dispose(); + } + + void _onDocumentChange() { + editorImeLog.finer( + "[DocumentToImeSynchronizer] - document change. Sending document and selection to IME on the next frame."); + _sendDocumentToImeOnNextFrame(); + } + + void _onSelectionChange() { + if (widget.selection.value != null) { + editorImeLog.finer( + "[DocumentToImeSynchronizer] - selection change. Sending document and selection to IME on the next frame."); + _sendDocumentToImeOnNextFrame(); + } + } + + void _onImeConnectionChange() { + if (widget.imeConnection.value != null && widget.imeConnection.value!.attached) { + // The IME just connected. Send over our current document and selection. + editorImeLog.fine( + "[DocumentToImeSynchronizer] - An IME connection was just opened. Sending current document and selection to IME."); + _sendDocumentToImeOnNextFrame(); + } + } + + void _sendDocumentToImeOnNextFrame() { + if (!_hasSentInitialImeValue) { + // If we haven't sent any version of the document to the IME, yet, then + // go ahead and greedily send the current document and selection. This is + // important in tests because tests run a single `pump()` and expect the + // initial document to be in the IME already. If we wait another frame, then + // some tests will need to `pump()` a 2nd time, but those tests won't understand + // why they need to do that. We avoid that confusion by immediately sending + // the document to the IME for the first version of the document that we get. + WidgetsBinding.instance.runAsSoonAsPossible(_sendDocumentToIme); + return; + } + + _needsSync = true; + WidgetsBinding.instance.scheduleFrameCallback((timeStamp) { + if (!mounted) { + // This widget is no longer in the tree. Assume that means we shouldn't + // talk to the IME, anymore. + return; + } + + // Send the document to the IME so that we give the editing system an opportunity to + // make all desired changes. Otherwise, we might send the document and selection to + // the IME in the middle of an operation that's in an inconsistent state. + // TODO: When atomic commands are implemented, remove this frame callback. + if (!_needsSync) { + // We might get a bunch of change events, leading to a bunch of these post frame + // callbacks. We only want to sync one time. We track that with _needsSync. + return; + } + + _needsSync = false; + _sendDocumentToIme(); + }); + } + + void _sendDocumentToIme() { + editorImeLog.fine("[DocumentToImeSynchronizer] - Trying to send document to IME"); + if (widget.imeConnection.value == null || !widget.imeConnection.value!.attached) { + editorImeLog.fine("[DocumentToImeSynchronizer] - Not connected to IME. Not sending document to IME."); + return; + } + + if (widget.selection.value == null) { + // There's no selection, which means there's nothing to edit. Return. + editorImeLog.fine("[DocumentToImeSynchronizer] - There's no document selection. Not sending anything to IME."); + return; + } + + editorImeLog.fine("[DocumentToImeSynchronizer] - Serializing and sending document and selection to IME"); + editorImeLog.fine("[DocumentToImeSynchronizer] - Selection: ${widget.selection.value}"); + editorImeLog.fine("[DocumentToImeSynchronizer] - Composing region: ${widget.composingRegion.value}"); + final imeSerialization = DocumentImeSerializer( + widget.document, + widget.selection.value!, + widget.composingRegion.value, + ); + editorImeLog + .fine("[DocumentToImeSynchronizer] - Adding invisible characters?: ${imeSerialization.didPrependPlaceholder}"); + TextEditingValue textEditingValue = imeSerialization.toTextEditingValue(); + + editorImeLog.fine("[DocumentToImeSynchronizer] - Sending IME serialization:"); + editorImeLog.fine("[DocumentToImeSynchronizer] - $textEditingValue"); + widget.imeConnection.value!.setEditingState(textEditingValue); + editorImeLog.fine("[DocumentToImeSynchronizer] - Done sending document to IME"); + _hasSentInitialImeValue = true; + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +/// A [TextInputClient] that applies IME operations to a [Document]. +/// +/// Ideally, this class *wouldn't* implement [TextInputConnection], but there are situations +/// where this class needs to care about what's sent to the IME. For more information, see +/// the [setEditingState] override in this class. +class DocumentImeInputClient extends TextInputConnectionDecorator with TextInputClient, DeltaTextInputClient { + DocumentImeInputClient({ + required this.textDeltasDocumentEditor, + required this.imeConnection, + FloatingCursorController? floatingCursorController, + }) { + imeConnection.addListener(_onImeConnectionChange); + _floatingCursorController = floatingCursorController; + } + + void dispose() { + imeConnection.removeListener(_onImeConnectionChange); + } + + final TextDeltasDocumentEditor textDeltasDocumentEditor; + + final ValueListenable imeConnection; + + // TODO: get floating cursor out of here. Use a multi-client IME decorator to split responsibilities + late FloatingCursorController? _floatingCursorController; + + bool _hasOutstandingMutatingChanges = false; + + void _onImeConnectionChange() { + client = imeConnection.value; + } + + /// Override on [TextInputConnection] base class. + /// + /// This method is the reason that this class extends [TextInputConnectionDecorator]. + /// Ideally, this object would be exclusively responsible for responding to IME + /// deltas, and some other object would be exclusively responsible for sending the + /// document to the IME. However, in certain situations, the decision to send the + /// document to the IME depends upon knowledge of recent deltas received from the + /// IME. As a result, this class is not only responsible for applying deltas to + /// the editor, but also making some decisions about when to send new values to the + /// IME. This method provides an override to do that, with minimal impact on other + /// areas of responsibility. + @override + void setEditingState(TextEditingValue newValue) { + _currentTextEditingValue = newValue; + + if (_isApplyingDeltas) { + // We're in the middle of applying a series of text deltas. Don't + // send any updates to the IME because it will conflict with the + // changes we're actively processing. + editorImeLog.fine("Ignoring new TextEditingValue because we're applying deltas"); + return; + } + + if (newValue != _lastTextEditingValueSentToOs) { + editorImeLog.info("Sending new text editing value to OS: $_currentTextEditingValue"); + _lastTextEditingValueSentToOs = _currentTextEditingValue; + imeConnection.value?.setEditingState(_currentTextEditingValue); + } else if (_hasOutstandingMutatingChanges) { + // We've been given a new IME value, and it's the same as our existing IME + // value. But, we also have outstanding mutating changes. + // + // We applied at least one delta that should have altered the content in + // the serialized IME value, but our local value before the edit is the same + // as the local value after the edit. Why is that, and what should we do? + // + // Sometimes the IME reports changes to us, but our document doesn't change + // in ways that's reflected in the IME. + // + // Example: The user has a caret in an empty paragraph. That empty paragraph + // includes a couple hidden characters, so the IME value might look like: + // + // ". |" + // + // The ". " substring is invisible to the user and the "|" represents the caret at + // the beginning of the empty paragraph. + // + // Then the user inserts a newline "\n". This causes Super Editor to insert a new, + // empty paragraph node, and place the caret in the new, empty paragraph. At this + // point, we have an issue: + // + // This class still sees the TextEditingValue as: ". |" + // + // However, the OS IME thinks the TextEditingValue is: ". |\n" + // + // In this situation, even though our desired TextEditingValue looks identical to what it + // was before, it's not identical to what the operating system thinks it is. We need to + // send our TextEditingValue back to the OS so that the OS doesn't think there's a "\n" + // sitting in the edit region. + editorImeLog.fine( + "Sending forceful update to IME because our local TextEditingValue didn't change, but the IME may have:"); + editorImeLog.fine("$currentTextEditingValue"); + imeConnection.value?.setEditingState(currentTextEditingValue); + _hasOutstandingMutatingChanges = false; + } else { + editorImeLog.fine("Ignoring new TextEditingValue because it's the same as the existing one: $newValue"); + } + } + + @override + AutofillScope? get currentAutofillScope => throw UnimplementedError(); + + @override + TextEditingValue get currentTextEditingValue => _currentTextEditingValue; + TextEditingValue _currentTextEditingValue = const TextEditingValue(); + TextEditingValue? _lastTextEditingValueSentToOs; + + bool _isApplyingDeltas = false; + + @override + void updateEditingValue(TextEditingValue value) { + editorImeLog.shout("Delta text input client received a non-delta TextEditingValue from OS: $value"); + } + + @override + void updateEditingValueWithDeltas(List textEditingDeltas) { + if (textEditingDeltas.isEmpty) { + return; + } + + editorImeLog.fine("Received edit deltas from platform: ${textEditingDeltas.length} deltas"); + for (final delta in textEditingDeltas) { + editorImeLog.fine("$delta"); + } + + final imeValueBeforeChange = currentTextEditingValue; + editorImeLog.fine("IME value before applying deltas: $imeValueBeforeChange"); + + _isApplyingDeltas = true; + textDeltasDocumentEditor.applyDeltas(textEditingDeltas); + _isApplyingDeltas = false; + + // If we had 1+ delta that changed the content of the document, remember that. + // We need this accounting in "set currentTextEditingValue" because, in some + // cases, our serialized IME value needs to be forcefully set because the IME + // thinks there's content that shouldn't be there, such as a newline "/n". + // See "set currentTextEditingValue" for more info. + _hasOutstandingMutatingChanges = + textEditingDeltas.where((element) => element is! TextEditingDeltaNonTextUpdate).toList().isNotEmpty; + + // Note: after the completion of applying deltas, we expect some other part of the + // system to call us back with "set currentTextEditingValue" with whatever that + // final serialized value should be. That's where execution should pick up from here. + } + + @override + void performAction(TextInputAction action) { + editorImeLog.fine("IME says to perform action: $action"); + textDeltasDocumentEditor.performAction(action); + } + + @override + void performSelector(String selectorName) { + editorImeLog.fine("IME says to perform selector (not implemented): $selectorName"); + } + + @override + void performPrivateCommand(String action, Map data) {} + + @override + void showAutocorrectionPromptRect(int start, int end) {} + + @override + void updateFloatingCursor(RawFloatingCursorPoint point) { + switch (point.state) { + case FloatingCursorDragState.Start: + case FloatingCursorDragState.Update: + _floatingCursorController?.offset = point.offset; + break; + case FloatingCursorDragState.End: + _floatingCursorController?.offset = null; + break; + } + } + + @override + void connectionClosed() { + editorImeLog.info("IME connection was closed"); + } +} diff --git a/super_editor/lib/src/default_editor/document_ime/document_ime_interaction_policies.dart b/super_editor/lib/src/default_editor/document_ime/document_ime_interaction_policies.dart new file mode 100644 index 000000000..1f1a69638 --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/document_ime_interaction_policies.dart @@ -0,0 +1,284 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/flutter_scheduler.dart'; + +/// Widget that watches a [FocusNode] and closes the [imeConnection] when +/// the [FocusNode] loses focus. +class ImeFocusPolicy extends StatefulWidget { + const ImeFocusPolicy({ + Key? key, + this.focusNode, + this.closeImeOnFocusLost = true, + required this.imeConnection, + required this.child, + }) : super(key: key); + + /// The document editor's [FocusNode], which is watched for changes based + /// on this widget's [closeImeOnFocusLost] policy. + final FocusNode? focusNode; + + /// Whether to close the [imeConnection] when the [FocusNode] loses focus. + /// + /// Defaults to `true`. + final bool closeImeOnFocusLost; + + /// The connection between this app and the platform Input Method Engine (IME). + final ValueListenable imeConnection; + + final Widget child; + + @override + State createState() => _ImeFocusPolicyState(); +} + +class _ImeFocusPolicyState extends State { + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); + } + + @override + void didUpdateWidget(ImeFocusPolicy oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.focusNode != oldWidget.focusNode) { + _focusNode.removeListener(_onFocusChange); + _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); + } + } + + @override + void dispose() { + if (widget.focusNode == null) { + _focusNode.dispose(); + } + super.dispose(); + } + + void _onFocusChange() { + editorImeLog.finer( + "[${widget.runtimeType}] - onFocusChange(). Has focus: ${_focusNode.hasFocus}. Close IME policy enabled: ${widget.closeImeOnFocusLost}"); + if (!_focusNode.hasFocus && widget.closeImeOnFocusLost) { + editorImeLog.info("[${widget.runtimeType}] - Document editor lost focus. Closing the IME connection."); + widget.imeConnection.value?.close(); + } + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +/// Widget that enforces policies between IME connections and document selections. +/// +/// This widget can automatically open and close the software keyboard when the document +/// selection changes, such as when the user places the caret in the middle of a +/// paragraph. +/// +/// This widget can automatically remove the document selection when the IME +/// connection closes. +class DocumentSelectionOpenAndCloseImePolicy extends StatefulWidget { + const DocumentSelectionOpenAndCloseImePolicy({ + Key? key, + required this.focusNode, + this.isEnabled = true, + required this.selection, + required this.imeConnection, + required this.imeClientFactory, + required this.imeConfiguration, + this.openKeyboardOnSelectionChange = true, + this.closeKeyboardOnSelectionLost = true, + this.clearSelectionWhenImeDisconnects = true, + required this.child, + }) : super(key: key); + + /// The document editor's [FocusNode]. + /// + /// When this widget closes the IME connection, it unfocuses this [focusNode]. + final FocusNode focusNode; + + /// Whether this widget's policies should be enabled. + /// + /// When `false`, this widget does nothing. + final bool isEnabled; + + /// Notifies this widget of changes to a document's selection. + final ValueNotifier selection; + + /// The current connection from this app to the platform IME. + final ValueNotifier imeConnection; + + /// Factory method that creates a [TextInputClient], which is used to + /// attach to the platform IME based on this widget's selection policy. + final TextInputClient Function() imeClientFactory; + + /// The desired [TextInputConfiguration] for the IME connection, used + /// when this widget attaches to the platform IME based on this widget's + /// selection policy. + final TextInputConfiguration imeConfiguration; + + /// Whether the software keyboard should be raised whenever the editor's selection + /// changes, such as when a user taps to place the caret. + /// + /// In a typical app, this property should be `true`. In some apps, the keyboard + /// needs to be closed and opened to reveal special editing controls. In those cases + /// this property should probably be `false`, and the app should take responsibility + /// for opening and closing the keyboard. + final bool openKeyboardOnSelectionChange; + + /// Whether the software keyboard should be closed whenever the editor goes from + /// having a selection to not having a selection. + /// + /// In a typical app, this property should be `true`, because there's no place to + /// apply IME input when there's no editor selection. + final bool closeKeyboardOnSelectionLost; + + /// Whether the document's selection should be cleared (removed) when the + /// IME disconnects, i.e., the software keyboard closes. + /// + /// Typically, on devices with software keyboards, the keyboard is critical + /// to all document editing. In such cases, it should be reasonable to clear + /// the selection when the keyboard closes. + /// + /// Some apps include editing features that can operate when the keyboard is + /// closed. For example, some apps display special editing options behind the + /// keyboard. The user closes the keyboard, uses the special options, and then + /// re-opens the keyboard. In this case, the document selection **shouldn't** + /// be cleared when the keyboard closes, because the special options behind the + /// keyboard still need to operate on that selection. + final bool clearSelectionWhenImeDisconnects; + + final Widget child; + + @override + State createState() => _DocumentSelectionOpenAndCloseImePolicyState(); +} + +class _DocumentSelectionOpenAndCloseImePolicyState extends State { + bool _wasAttached = false; + + @override + void initState() { + super.initState(); + + _wasAttached = widget.imeConnection.value?.attached ?? false; + widget.imeConnection.addListener(_onConnectionChange); + + widget.selection.addListener(_onSelectionChange); + if (widget.selection.value != null) { + _onSelectionChange(); + _onConnectionChange(); + } + } + + @override + void didUpdateWidget(DocumentSelectionOpenAndCloseImePolicy oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.selection != oldWidget.selection) { + oldWidget.selection.removeListener(_onSelectionChange); + widget.selection.addListener(_onSelectionChange); + _onSelectionChange(); + } + + if (widget.imeConnection != oldWidget.imeConnection) { + oldWidget.imeConnection.removeListener(_onConnectionChange); + widget.imeConnection.addListener(_onConnectionChange); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + // We switched IME connection references, which means we may have switched + // from one with a connection to one without a connection, or vis-a-versa. + // Run our connection change check. + // + // Also, we run this at the end of the frame, because this call might clear + // the document selection, which might cause other widgets in the tree + // to call setState(), which would cause an exception during didUpdateWidget(). + _onConnectionChange(); + }); + } + } + + @override + void dispose() { + widget.selection.removeListener(_onSelectionChange); + widget.imeConnection.removeListener(_onConnectionChange); + super.dispose(); + } + + void _onSelectionChange() { + editorImeLog.finer( + "[${widget.runtimeType}] onSelectionChange() - widget enabled: ${widget.isEnabled}, open keyboard on selection enabled: ${widget.openKeyboardOnSelectionChange}, selection: ${widget.selection.value}"); + if (!widget.isEnabled) { + return; + } + + if (widget.selection.value != null && widget.openKeyboardOnSelectionChange) { + // There's a new document selection, and our policy wants the keyboard to be + // displayed whenever the selection changes. Show the keyboard. + editorImeLog.info("[${widget.runtimeType}] - opening the IME keyboard because the document selection changed"); + + if (widget.imeConnection.value == null || !widget.imeConnection.value!.attached) { + WidgetsBinding.instance.runAsSoonAsPossible(() { + if (!mounted) { + return; + } + + editorImeLog.finer("[${widget.runtimeType}] - creating new TextInputConnection to IME"); + widget.imeConnection.value = TextInput.attach( + widget.imeClientFactory(), + widget.imeConfiguration, + )..show(); + }, debugLabel: 'Open IME Connection on Selection Change'); + } else { + widget.imeConnection.value!.show(); + } + } else if (widget.imeConnection.value != null && + widget.selection.value == null && + widget.closeKeyboardOnSelectionLost) { + // There's no document selection, and our policy wants the keyboard to be + // closed whenever the editor loses its selection. Close the keyboard. + editorImeLog + .info("[${widget.runtimeType}] - closing the IME keyboard because the document selection was cleared"); + widget.imeConnection.value!.close(); + } + } + + void _onConnectionChange() { + if (!mounted) { + return; + } + + editorImeLog.finer( + "[${widget.runtimeType}] onConnectionChange() - widget enabled: ${widget.isEnabled}, clear selection when IME disconnects enabled: ${widget.clearSelectionWhenImeDisconnects}, new connection: ${widget.imeConnection.value}, was attached before: $_wasAttached"); + if (widget.isEnabled && + widget.clearSelectionWhenImeDisconnects && + _wasAttached && + !(widget.imeConnection.value?.attached ?? false)) { + // The IME connection closed and our policy wants us to clear the document + // selection when that happens. + editorImeLog.info("[${widget.runtimeType}] - clearing document selection because the IME closed"); + widget.selection.value = null; + + // If we clear SuperEditor's selection, but leave SuperEditor focused, then + // SuperEditor will automatically place the caret at the end of the document. + // This is because SuperEditor always expects a place for text input when it + // has focus. To prevent this from happening, we explicitly remove focus + // from SuperEditor. + widget.focusNode.unfocus(); + } + + _wasAttached = widget.imeConnection.value?.attached ?? false; + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/super_editor/lib/src/default_editor/document_ime/document_input_ime.dart b/super_editor/lib/src/default_editor/document_ime/document_input_ime.dart new file mode 100644 index 000000000..7a791b0a0 --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/document_input_ime.dart @@ -0,0 +1,26 @@ +export 'document_delta_editing.dart'; +export 'document_ime_communication.dart'; +export 'document_ime_interaction_policies.dart'; +export 'document_serialization.dart'; +export 'ime_decoration.dart'; +export 'ime_keyboard_control.dart'; +export 'mobile_toolbar.dart'; +export 'supereditor_ime_interactor.dart'; + +/// This file exports various document IME tools. +/// +/// The term Input Method Engine (IME) refers to an operating system's +/// intermediary between the user's input, such as through a software +/// keyboard, and the app that receives the input. The IME might make +/// changes to the user's input, such as correcting spelling, or +/// inserting emojis. +/// +/// IME input is the only form of input available on mobile devices, +/// unless the user connects a physical keyboard. For example, the +/// software keyboard that appears on the screen of a mobile device +/// talks to the OS, not the app. Once the OS receives input from +/// the user through the software keyboard, the OS forwards a version +/// of that input to the appropriate app. +/// +/// The tools in this package are all about enabling various behaviors +/// and policies for receiving and applying IME input. diff --git a/super_editor/lib/src/default_editor/document_ime/document_serialization.dart b/super_editor/lib/src/default_editor/document_ime/document_serialization.dart new file mode 100644 index 000000000..0ba0b8dd6 --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/document_serialization.dart @@ -0,0 +1,465 @@ +import 'dart:math'; + +import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; + +/// Serializes a [Document] and [DocumentSelection] into a form that's understood by +/// the Input Method Engine (IME), and vis-a-versa. +/// +/// The IME only understands strings of plain text. Therefore, to make [Document] content +/// available for IME editing, the [Document] structure needs to be serialized into a run of text. +/// +/// When the IME alters the given content, that plain text needs to be deserialized back into +/// a [Document] structure. +/// +/// This class implements both [Document] serialization and deserialization for the IME. +class DocumentImeSerializer { + static const _leadingCharacter = '. '; + + DocumentImeSerializer( + this._doc, + this._selection, + this._composingRegion, [ + this._prependedCharacterPolicy = PrependedCharacterPolicy.automatic, + ]) { + _serialize(); + } + + final Document _doc; + final DocumentSelection _selection; + final DocumentRange? _composingRegion; + final _imeRangesToDocTextNodes = {}; + final _docTextNodesToImeRanges = {}; + final _selectedNodes = []; + late String _imeText; + final PrependedCharacterPolicy _prependedCharacterPolicy; + String _prependedPlaceholder = ''; + + void _serialize() { + editorImeLog.fine("Creating an IME model from document, selection, and composing region"); + final buffer = StringBuffer(); + int characterCount = 0; + + if (_shouldPrependPlaceholder()) { + // Put an arbitrary character at the front of the text so that + // the IME will report backspace buttons when the caret sits at + // the beginning of the node. For example, the caret is at the + // beginning of some text and we want to combine this text with + // the text above it when the user presses backspace. + // + // Text above... + // |The selected text node. + _prependedPlaceholder = _leadingCharacter; + buffer.write(_prependedPlaceholder); + characterCount = _prependedPlaceholder.length; + } else { + _prependedPlaceholder = ''; + } + + _selectedNodes.clear(); + _selectedNodes.addAll(_doc.getNodesInContentOrder(_selection)); + for (int i = 0; i < _selectedNodes.length; i += 1) { + // Append a newline character before appending another node's text. + // + // The choice to separate each node with a newline was a judgement call. + // There is no OS-level expectation for how structured content should + // collapse down to IME content. + if (i != 0) { + buffer.write('\n'); + characterCount += 1; + } + + final node = _selectedNodes[i]; + if (node is! TextNode) { + buffer.write('~'); + characterCount += 1; + + final imeRange = TextRange(start: characterCount - 1, end: characterCount); + _imeRangesToDocTextNodes[imeRange] = node.id; + _docTextNodesToImeRanges[node.id] = imeRange; + + continue; + } + + // Cache mappings between the IME text range and the document position + // so that we can easily convert between the two, when requested. + final imeRange = TextRange(start: characterCount, end: characterCount + node.text.text.length); + _imeRangesToDocTextNodes[imeRange] = node.id; + _docTextNodesToImeRanges[node.id] = imeRange; + + // Concatenate this node's text with the previous nodes. + buffer.write(node.text.text); + characterCount += node.text.text.length; + } + + _imeText = buffer.toString(); + editorImeLog.fine("IME serialization:\n'$_imeText'"); + } + + bool _shouldPrependPlaceholder() { + if (_prependedCharacterPolicy == PrependedCharacterPolicy.include) { + // The client explicitly requested prepended characters. This is + // useful, for example, when a client has an existing serialization that + // includes prepended characters and wants to compare that serialization + // to a new serialization. The client wants to ensure that the new + // serialization has prepended characters, too. + return true; + } else if (_prependedCharacterPolicy == PrependedCharacterPolicy.exclude) { + return false; + } + + // We want to prepend an arbitrary placeholder character whenever the + // user's selection is collapsed at the beginning of a node, and there's + // another node above the selected node. Without the arbitrary character, + // the IME would assume that there's no content before the current node and + // therefore it wouldn't report the backspace button. + final selectedNode = _doc.getNode(_selection.extent)!; + final selectedNodeIndex = _doc.getNodeIndexById(selectedNode.id); + return selectedNodeIndex > 0 && + _selection.isCollapsed && + _selection.extent.nodePosition == selectedNode.beginningPosition; + } + + bool get didPrependPlaceholder => _prependedPlaceholder.isNotEmpty; + + DocumentSelection? imeToDocumentSelection(TextSelection imeSelection) { + editorImeLog.fine("Creating doc selection from IME selection: $imeSelection"); + if (!imeSelection.isValid) { + editorImeLog.fine("The IME selection is empty. Returning a null document selection."); + return null; + } + + if (didPrependPlaceholder) { + // The IME might be trying to select our invisible prepended characters. + // If so, we need to adjust the IME selection bounds. + if ((imeSelection.isCollapsed && imeSelection.extentOffset < _prependedPlaceholder.length) || + (imeSelection.start < _prependedPlaceholder.length && imeSelection.end == _prependedPlaceholder.length)) { + // The IME is only trying to select our invisible characters. Return null + // for an empty document selection. + editorImeLog.fine("The IME only selected invisible characters. Returning a null document selection."); + return null; + } else { + // The IME is trying to select some invisible characters and some real + // characters. Remove the invisible characters from the IME selection before + // converting it to a document selection. + editorImeLog.fine("Removing invisible characters from IME selection."); + imeSelection = imeSelection.copyWith( + baseOffset: max(imeSelection.baseOffset, _prependedPlaceholder.length), + extentOffset: max(imeSelection.extentOffset, _prependedPlaceholder.length), + ); + editorImeLog.fine("Adjusted IME selection is: $imeSelection"); + } + } else { + editorImeLog.fine("The IME only selected visible characters. No adjustment necessary."); + } + + return DocumentSelection( + base: _imeToDocumentPosition( + imeSelection.base, + isUpstream: imeSelection.base.affinity == TextAffinity.upstream, + ), + extent: _imeToDocumentPosition( + imeSelection.extent, + isUpstream: imeSelection.extent.affinity == TextAffinity.upstream, + ), + ); + } + + DocumentRange? imeToDocumentRange(TextRange imeRange) { + editorImeLog.fine("Creating doc range from IME range: $imeRange"); + if (!imeRange.isValid) { + editorImeLog.fine("The IME range is empty. Returning null document range."); + // The range is empty. Return null. + return null; + } + + if (didPrependPlaceholder) { + // The IME might be trying to select our invisible prepended characters. + // If so, we need to adjust the IME selection bounds. + if ((imeRange.isCollapsed && imeRange.end < _prependedPlaceholder.length) || + (imeRange.start < _prependedPlaceholder.length && imeRange.end == _prependedPlaceholder.length)) { + // The IME is only trying to select our invisible characters. Return null + // for an empty document range. + editorImeLog + .fine("The IME tried to create a range around invisible characters. Returning null document range."); + return null; + } else { + // The IME is trying to select some invisible characters and some real + // characters. Remove the invisible characters from the IME range before + // converting it to a document range. + editorImeLog.fine("Removing arbitrary character from IME range."); + editorImeLog.fine("Before adjustment, range: $imeRange"); + editorImeLog.fine("Prepended characters length: ${_prependedPlaceholder.length}"); + imeRange = TextRange( + start: max(imeRange.start, _prependedPlaceholder.length), + end: max(imeRange.end, _prependedPlaceholder.length), + ); + editorImeLog.fine("Adjusted IME range to: $imeRange"); + } + } else { + editorImeLog.fine("The IME is only composing visible characters. No adjustment necessary."); + } + + return DocumentRange( + start: _imeToDocumentPosition( + TextPosition(offset: imeRange.start), + isUpstream: false, + ), + end: _imeToDocumentPosition( + TextPosition(offset: imeRange.end), + isUpstream: false, + ), + ); + } + + DocumentPosition _imeToDocumentPosition(TextPosition imePosition, {required bool isUpstream}) { + for (final range in _imeRangesToDocTextNodes.keys) { + if (range.start <= imePosition.offset && imePosition.offset <= range.end) { + final node = _doc.getNodeById(_imeRangesToDocTextNodes[range]!)!; + + if (node is TextNode) { + return DocumentPosition( + nodeId: _imeRangesToDocTextNodes[range]!, + nodePosition: TextNodePosition(offset: imePosition.offset - range.start), + ); + } else { + if (imePosition.offset <= range.start) { + // Return a position at the start of the node. + return DocumentPosition( + nodeId: node.id, + nodePosition: node.beginningPosition, + ); + } else { + // Return a position at the end of the node. + return DocumentPosition( + nodeId: node.id, + nodePosition: node.endPosition, + ); + } + } + } + } + + editorImeLog + .shout("Couldn't map an IME position to a document position. IME position: $imePosition. Available ranges:"); + for (final range in _imeRangesToDocTextNodes.keys) { + editorImeLog.shout("Range: ${range.start} -> ${range.end}"); + } + throw Exception("Couldn't map an IME position to a document position. IME position: $imePosition"); + } + + TextSelection documentToImeSelection(DocumentSelection docSelection) { + editorImeLog.fine("Converting doc selection to ime selection: $docSelection"); + final selectionAffinity = _doc.getAffinityForSelection(docSelection); + + final startDocPosition = selectionAffinity == TextAffinity.downstream ? docSelection.base : docSelection.extent; + final startImePosition = _documentToImePosition(startDocPosition); + + final endDocPosition = selectionAffinity == TextAffinity.downstream ? docSelection.extent : docSelection.base; + final endImePosition = _documentToImePosition(endDocPosition); + + editorImeLog.fine("Start IME position: $startImePosition"); + editorImeLog.fine("End IME position: $endImePosition"); + return TextSelection( + baseOffset: startImePosition.offset, + extentOffset: endImePosition.offset, + affinity: startImePosition == endImePosition ? endImePosition.affinity : TextAffinity.downstream, + ); + } + + TextRange documentToImeRange(DocumentRange? documentRange) { + editorImeLog.fine("Converting doc range to ime range: $documentRange"); + if (documentRange == null) { + editorImeLog.fine("The document range is null. Returning an empty IME range."); + return const TextRange(start: -1, end: -1); + } + + final startImePosition = _documentToImePosition(documentRange.start); + final endImePosition = _documentToImePosition(documentRange.end); + + editorImeLog.fine("After converting DocumentRange to TextRange:"); + editorImeLog.fine("Start IME position: $startImePosition"); + editorImeLog.fine("End IME position: $endImePosition"); + return TextRange( + start: startImePosition.offset, + end: endImePosition.offset, + ); + } + + TextPosition _documentToImePosition(DocumentPosition docPosition) { + editorImeLog.fine("Converting DocumentPosition to IME TextPosition: $docPosition"); + final imeRange = _docTextNodesToImeRanges[docPosition.nodeId]; + if (imeRange == null) { + throw Exception("No such document position in the IME content: $docPosition"); + } + + final nodePosition = docPosition.nodePosition; + + if (nodePosition is UpstreamDownstreamNodePosition) { + if (nodePosition.affinity == TextAffinity.upstream) { + editorImeLog.fine("The doc position is an upstream position on a block."); + // Return the text position before the special character, + // e.g., "|~". + return TextPosition(offset: imeRange.start); + } else { + editorImeLog.fine("The doc position is a downstream position on a block."); + // Return the text position after the special character, + // e.g., "~|". + return TextPosition(offset: imeRange.start + 1); + } + } + + if (nodePosition is TextNodePosition) { + return TextPosition(offset: imeRange.start + (docPosition.nodePosition as TextNodePosition).offset); + } + + throw Exception("Super Editor doesn't know how to convert a $nodePosition into an IME-compatible selection"); + } + + TextEditingValue toTextEditingValue() { + editorImeLog.fine("Creating TextEditingValue from document. Selection: $_selection"); + editorImeLog.fine("Text:\n'$_imeText'"); + final imeSelection = documentToImeSelection(_selection); + editorImeLog.fine("Selection: $imeSelection"); + final imeComposingRegion = documentToImeRange(_composingRegion); + editorImeLog.fine("Composing region: $imeComposingRegion"); + + return TextEditingValue( + text: _imeText, + selection: imeSelection, + composing: imeComposingRegion, + ); + } + + /// Narrows the given [selection] until the base and extent both point to + /// `TextNode`s. + /// + /// If the given [selection] base and/or extent already point to a `TextNode` + /// then those same end-caps are retained in the returned `DocumentSelection`. + /// + /// If there is no text content within the [selection], `null` is returned. + DocumentSelection? _constrictToTextSelectionEndCaps(DocumentSelection selection) { + final baseNode = _doc.getNodeById(selection.base.nodeId)!; + final baseNodeIndex = _doc.getNodeIndexById(baseNode.id); + final extentNode = _doc.getNodeById(selection.extent.nodeId)!; + final extentNodeIndex = _doc.getNodeIndexById(extentNode.id); + + final startNode = baseNodeIndex <= extentNodeIndex ? baseNode : extentNode; + final startNodeIndex = _doc.getNodeIndexById(startNode.id); + final startPosition = + baseNodeIndex <= extentNodeIndex ? selection.base.nodePosition : selection.extent.nodePosition; + final endNode = baseNodeIndex <= extentNodeIndex ? extentNode : baseNode; + final endNodeIndex = _doc.getNodeIndexById(endNode.id); + final endPosition = baseNodeIndex <= extentNodeIndex ? selection.extent.nodePosition : selection.base.nodePosition; + + if (startNodeIndex == endNodeIndex) { + // The document selection is all in one node. + if (startNode is! TextNode) { + // The only content selected is non-text. Return null. + return null; + } + + // Part of a single TextNode is selected, therefore, the given selection + // is already restricted to text end caps. + return selection; + } + + DocumentNode? restrictedStartNode; + TextNodePosition? restrictedStartPosition; + if (startNode is TextNode) { + restrictedStartNode = startNode; + restrictedStartPosition = startPosition as TextNodePosition; + } else { + int restrictedStartNodeIndex = startNodeIndex + 1; + while (_doc.getNodeAt(restrictedStartNodeIndex) is! TextNode && restrictedStartNodeIndex <= endNodeIndex) { + restrictedStartNodeIndex += 1; + } + + if (_doc.getNodeAt(restrictedStartNodeIndex) is TextNode) { + restrictedStartNode = _doc.getNodeAt(restrictedStartNodeIndex); + restrictedStartPosition = const TextNodePosition(offset: 0); + } + } + + DocumentNode? restrictedEndNode; + TextNodePosition? restrictedEndPosition; + if (endNode is TextNode) { + restrictedEndNode = endNode; + restrictedEndPosition = endPosition as TextNodePosition; + } else { + int restrictedEndNodeIndex = endNodeIndex - 1; + while (_doc.getNodeAt(restrictedEndNodeIndex) is! TextNode && restrictedEndNodeIndex >= startNodeIndex) { + restrictedEndNodeIndex -= 1; + } + + if (_doc.getNodeAt(restrictedEndNodeIndex) is TextNode) { + restrictedEndNode = _doc.getNodeAt(restrictedEndNodeIndex); + restrictedEndPosition = TextNodePosition(offset: (restrictedEndNode as TextNode).text.text.length); + } + } + + // If there was no text between the selection end-caps, return null. + if (restrictedStartPosition == null || restrictedEndPosition == null) { + return null; + } + + return DocumentSelection( + base: DocumentPosition( + nodeId: restrictedStartNode!.id, + nodePosition: restrictedStartPosition, + ), + extent: DocumentPosition( + nodeId: restrictedEndNode!.id, + nodePosition: restrictedEndPosition, + ), + ); + } + + /// Serializes just enough document text to serve the needs of the IME. + /// + /// The serialized text includes all the content of all partially selected + /// nodes, plus one node on either side to allow for upstream and downstream + /// deletions. For example, the user press backspace at the beginning of a + /// paragraph. We need to tell the IME that there's content before the paragraph + /// so that the IME sends us the delete delta. + String _getMinimumTextForIME(DocumentSelection selection) { + final baseNode = _doc.getNodeById(selection.base.nodeId)!; + final baseNodeIndex = _doc.getNodeIndexById(baseNode.id); + final extentNode = _doc.getNodeById(selection.extent.nodeId)!; + final extentNodeIndex = _doc.getNodeIndexById(extentNode.id); + + final selectionStartNode = baseNodeIndex <= extentNodeIndex ? baseNode : extentNode; + final selectionStartNodeIndex = _doc.getNodeIndexById(selectionStartNode.id); + final startNodeIndex = max(selectionStartNodeIndex - 1, 0); + + final selectionEndNode = baseNodeIndex <= extentNodeIndex ? extentNode : baseNode; + final selectionEndNodeIndex = _doc.getNodeIndexById(selectionEndNode.id); + final endNodeIndex = min(selectionEndNodeIndex + 1, _doc.nodes.length - 1); + + final buffer = StringBuffer(); + for (int i = startNodeIndex; i <= endNodeIndex; i += 1) { + final node = _doc.getNodeAt(i); + if (node is! TextNode) { + continue; + } + + if (buffer.length > 0) { + buffer.write('\n'); + } + + buffer.write(node.text.text); + } + + return buffer.toString(); + } +} + +enum PrependedCharacterPolicy { + automatic, + include, + exclude, +} diff --git a/super_editor/lib/src/default_editor/document_ime/ime_decoration.dart b/super_editor/lib/src/default_editor/document_ime/ime_decoration.dart new file mode 100644 index 000000000..10a26f42e --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/ime_decoration.dart @@ -0,0 +1,145 @@ +import 'package:flutter/services.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; + +/// Base class for [TextInputConnection] decorators. +/// +/// A decorator is an object that forwards calls to another, existing implementation +/// of a given interface, but adds or alters some of those behaviors. +abstract class TextInputConnectionDecorator implements TextInputConnection { + TextInputConnectionDecorator([this.client]); + + TextInputConnection? client; + + @override + bool get attached => client?.attached ?? false; + + @override + bool get scribbleInProgress => client?.scribbleInProgress ?? false; + + @override + void show() => client?.show(); + + @override + void setEditingState(TextEditingValue value) => client?.setEditingState(value); + + @override + void updateConfig(TextInputConfiguration configuration) => client?.updateConfig(configuration); + + @override + void setCaretRect(Rect rect) => client?.setCaretRect(rect); + + @override + void setSelectionRects(List selectionRects) => client?.setSelectionRects(selectionRects); + + @override + void setComposingRect(Rect rect) => client?.setComposingRect(rect); + + @override + void setStyle( + {required String? fontFamily, + required double? fontSize, + required FontWeight? fontWeight, + required TextDirection textDirection, + required TextAlign textAlign}) => + client?.setStyle( + fontFamily: fontFamily, + fontSize: fontSize, + fontWeight: fontWeight, + textDirection: textDirection, + textAlign: textAlign); + + @override + void requestAutofill() => client?.requestAutofill(); + + @override + void setEditableSizeAndTransform(Size editableBoxSize, Matrix4 transform) => + client?.setEditableSizeAndTransform(editableBoxSize, transform); + + @override + void connectionClosedReceived() => client?.connectionClosedReceived(); + + @override + void close() => client?.close(); +} + +/// A [DeltaTextInputClient] that forwards all calls to the given [_client], and +/// also notifies [_onConnectionClosed] when the IME connection closes. +/// +/// This decorator is needed because [TextInputConnection] has no way to listen +/// for when its connection is closed. By wrapping its [TextInputClient] with +/// this decorator, the code that owns the [TextInputConnection] can receive +/// a notification when the connection closes. +class _ClosureAwareImeClientDecorator implements DeltaTextInputClient { + _ClosureAwareImeClientDecorator(this._client, this._onConnectionClosed); + + final DeltaTextInputClient _client; + final VoidCallback _onConnectionClosed; + + @override + void connectionClosed() { + editorImeLog.fine("[_ClosureAwareImeClientDecorator] - IME connection was closed"); + _onConnectionClosed(); + _client.connectionClosed(); + } + + @override + AutofillScope? get currentAutofillScope => _client.currentAutofillScope; + + @override + TextEditingValue? get currentTextEditingValue => _client.currentTextEditingValue; + + @override + void didChangeInputControl(TextInputControl? oldControl, TextInputControl? newControl) { + _client.didChangeInputControl(oldControl, newControl); + } + + @override + void insertTextPlaceholder(Size size) { + _client.insertTextPlaceholder(size); + } + + @override + void performAction(TextInputAction action) { + _client.performAction(action); + } + + @override + void performPrivateCommand(String action, Map data) { + _client.performPrivateCommand(action, data); + } + + @override + void performSelector(String selectorName) { + _client.performSelector(selectorName); + } + + @override + void removeTextPlaceholder() { + _client.removeTextPlaceholder(); + } + + @override + void showAutocorrectionPromptRect(int start, int end) { + _client.showAutocorrectionPromptRect(start, end); + } + + @override + void showToolbar() { + _client.showToolbar(); + } + + @override + void updateEditingValue(TextEditingValue value) { + _client.updateEditingValue(value); + } + + @override + void updateEditingValueWithDeltas(List textEditingDeltas) { + _client.updateEditingValueWithDeltas(textEditingDeltas); + } + + @override + void updateFloatingCursor(RawFloatingCursorPoint point) { + _client.updateFloatingCursor(point); + } +} diff --git a/super_editor/lib/src/default_editor/document_ime/ime_keyboard_control.dart b/super_editor/lib/src/default_editor/document_ime/ime_keyboard_control.dart new file mode 100644 index 000000000..061b06485 --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/ime_keyboard_control.dart @@ -0,0 +1,146 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; + +/// Widget that opens and closes the software keyboard, when requested. +/// +/// This widget's [State] object implements [SoftwareKeyboardControllerDelegate], +/// which can be controlled with a [SoftwareKeyboardController]. +/// +/// Opening the software keyboard requires that a connection be established to the +/// platform IME. Therefore, this widget requires [createImeClient] and [createImeConfiguration] +/// to establish that connection, if it doesn't exist already. +class SoftwareKeyboardOpener extends StatefulWidget { + const SoftwareKeyboardOpener({ + Key? key, + required this.controller, + required this.imeConnection, + required this.createImeClient, + required this.createImeConfiguration, + required this.child, + }) : super(key: key); + + final SoftwareKeyboardController? controller; + + final ValueNotifier imeConnection; + + final TextInputClient Function() createImeClient; + + final TextInputConfiguration Function() createImeConfiguration; + + final Widget child; + + @override + State createState() => _SoftwareKeyboardOpenerState(); +} + +class _SoftwareKeyboardOpenerState extends State implements SoftwareKeyboardControllerDelegate { + @override + void initState() { + super.initState(); + widget.controller?.attach(this); + } + + @override + void didUpdateWidget(SoftwareKeyboardOpener oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.controller != oldWidget.controller) { + oldWidget.controller?.detach(); + widget.controller?.attach(this); + } + } + + @override + void dispose() { + // Detach from the controller at the end of the frame, so that + // ancestor widgets can still call `close()` on the keyboard in + // their `dispose()` methods. If we `detach()` right now, the + // ancestor widgets would cause errors in their `dispose()` methods. + WidgetsBinding.instance.scheduleFrameCallback((timeStamp) { + widget.controller?.detach(); + }); + super.dispose(); + } + + @override + bool get isConnectedToIme => widget.imeConnection.value?.attached ?? false; + + @override + void open() { + editorImeLog.info("[SoftwareKeyboard] - showing keyboard"); + widget.imeConnection.value ??= TextInput.attach(widget.createImeClient(), widget.createImeConfiguration()); + widget.imeConnection.value!.show(); + } + + @override + void close() { + editorImeLog.info("[SoftwareKeyboard] - closing IME connection."); + widget.imeConnection.value?.close(); + widget.imeConnection.value = null; + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +/// `SuperEditor` controller that opens and closes the software keyboard. +/// +/// A [SoftwareKeyboardController] must be attached to a +/// [SoftwareKeyboardControllerDelegate] to open and close the software keyboard. +class SoftwareKeyboardController { + SoftwareKeyboardControllerDelegate? _delegate; + + /// Whether this controller is currently attached to a delegate that + /// knows how to open and close the software keyboard. + bool get hasDelegate => _delegate != null; + + /// Attaches this controller to a delegate that knows how to open and + /// close the software keyboard. + void attach(SoftwareKeyboardControllerDelegate delegate) { + editorImeLog.finer("[SoftwareKeyboardController] - Attaching to delegate: $delegate"); + _delegate = delegate; + } + + /// Detaches this controller from its delegate. + /// + /// This controller can't open or close the software keyboard while + /// detached from a delegate that knows how to make that happen. + void detach() { + editorImeLog.finer("[SoftwareKeyboardController] - Detaching from delegate: $_delegate"); + _delegate = null; + } + + /// Whether the delegate is currently connected to the platform IME. + bool get isConnectedToIme { + assert(hasDelegate); + return _delegate?.isConnectedToIme ?? false; + } + + /// Opens the software keyboard. + void open() { + assert(hasDelegate); + _delegate?.open(); + } + + /// Closes the software keyboard. + void close() { + assert(hasDelegate); + _delegate?.close(); + } +} + +/// Delegate that's attached to a [SoftwareKeyboardController], which implements +/// the opening and closing of the software keyboard. +abstract class SoftwareKeyboardControllerDelegate { + /// Whether this delegate is currently connected to the platform IME. + bool get isConnectedToIme; + + /// Opens the software keyboard. + void open(); + + /// Closes the software keyboard. + void close(); +} diff --git a/super_editor/lib/src/default_editor/document_ime/mobile_toolbar.dart b/super_editor/lib/src/default_editor/document_ime/mobile_toolbar.dart new file mode 100644 index 000000000..39fc3d779 --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/mobile_toolbar.dart @@ -0,0 +1,282 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/material.dart' hide ListenableBuilder; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/default_editor/common_editor_operations.dart'; +import 'package:super_editor/src/default_editor/list_items.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/_listenable_builder.dart'; + +import '../attributions.dart'; + +/// Toolbar that provides document editing capabilities, like converting +/// paragraphs to blockquotes and list items, and inserting horizontal +/// rules. +/// +/// This toolbar is intended to be placed just above the keyboard on a +/// mobile device. +class KeyboardEditingToolbar extends StatelessWidget { + const KeyboardEditingToolbar({ + Key? key, + required this.document, + required this.composer, + required this.commonOps, + this.brightness, + }) : super(key: key); + + final Document document; + final DocumentComposer composer; + final CommonEditorOperations commonOps; + final Brightness? brightness; + + bool get _isBoldActive => _doesSelectionHaveAttributions({boldAttribution}); + void _toggleBold() => _toggleAttributions({boldAttribution}); + + bool get _isItalicsActive => _doesSelectionHaveAttributions({italicsAttribution}); + void _toggleItalics() => _toggleAttributions({italicsAttribution}); + + bool get _isUnderlineActive => _doesSelectionHaveAttributions({underlineAttribution}); + void _toggleUnderline() => _toggleAttributions({underlineAttribution}); + + bool get _isStrikethroughActive => _doesSelectionHaveAttributions({strikethroughAttribution}); + void _toggleStrikethrough() => _toggleAttributions({strikethroughAttribution}); + + bool _doesSelectionHaveAttributions(Set attributions) { + final selection = composer.selection; + if (selection == null) { + return false; + } + + if (selection.isCollapsed) { + return composer.preferences.currentAttributions.containsAll(attributions); + } + + return document.doesSelectedTextContainAttributions(selection, attributions); + } + + void _toggleAttributions(Set attributions) { + final selection = composer.selection; + if (selection == null) { + return; + } + + selection.isCollapsed + ? commonOps.toggleComposerAttributions(attributions) + : commonOps.toggleAttributionsOnSelection(attributions); + } + + void _convertToHeader1() { + final selectedNode = document.getNodeById(composer.selection!.extent.nodeId); + if (selectedNode is! TextNode) { + return; + } + + if (selectedNode is ListItemNode) { + commonOps.convertToParagraph( + newMetadata: { + 'blockType': header1Attribution, + }, + ); + } else { + selectedNode.putMetadataValue('blockType', header1Attribution); + } + } + + void _convertToHeader2() { + final selectedNode = document.getNodeById(composer.selection!.extent.nodeId); + if (selectedNode is! TextNode) { + return; + } + + if (selectedNode is ListItemNode) { + commonOps.convertToParagraph( + newMetadata: { + 'blockType': header2Attribution, + }, + ); + } else { + selectedNode.putMetadataValue('blockType', header2Attribution); + } + } + + void _convertToParagraph() { + commonOps.convertToParagraph(); + } + + void _convertToOrderedListItem() { + final selectedNode = document.getNodeById(composer.selection!.extent.nodeId)! as TextNode; + + commonOps.convertToListItem(ListItemType.ordered, selectedNode.text); + } + + void _convertToUnorderedListItem() { + final selectedNode = document.getNodeById(composer.selection!.extent.nodeId)! as TextNode; + + commonOps.convertToListItem(ListItemType.unordered, selectedNode.text); + } + + void _convertToBlockquote() { + final selectedNode = document.getNodeById(composer.selection!.extent.nodeId)! as TextNode; + + commonOps.convertToBlockquote(selectedNode.text); + } + + void _convertToHr() { + final selectedNode = document.getNodeById(composer.selection!.extent.nodeId)! as TextNode; + + selectedNode.text = AttributedText(text: '--- '); + composer.selection = DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: selectedNode.id, + nodePosition: const TextNodePosition(offset: 4), + ), + ); + commonOps.convertParagraphByPatternMatching(selectedNode.id); + } + + void _closeKeyboard() { + composer.selection = null; + } + + @override + Widget build(BuildContext context) { + final selection = composer.selection; + + if (selection == null) { + return const SizedBox(); + } + + final brightness = this.brightness ?? MediaQuery.of(context).platformBrightness; + + return Theme( + data: Theme.of(context).copyWith( + brightness: brightness, + disabledColor: brightness == Brightness.light ? Colors.black.withOpacity(0.5) : Colors.white.withOpacity(0.5), + ), + child: IconTheme( + data: IconThemeData( + color: brightness == Brightness.light ? Colors.black : Colors.white, + ), + child: Material( + child: Container( + width: double.infinity, + height: 48, + color: brightness == Brightness.light ? const Color(0xFFDDDDDD) : const Color(0xFF222222), + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ListenableBuilder( + listenable: composer, + builder: (context, _) { + final selectedNode = document.getNodeById(selection.extent.nodeId); + final isSingleNodeSelected = selection.extent.nodeId == selection.base.nodeId; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: selectedNode is TextNode ? _toggleBold : null, + icon: const Icon(Icons.format_bold), + color: _isBoldActive ? Theme.of(context).primaryColor : null, + ), + IconButton( + onPressed: selectedNode is TextNode ? _toggleItalics : null, + icon: const Icon(Icons.format_italic), + color: _isItalicsActive ? Theme.of(context).primaryColor : null, + ), + IconButton( + onPressed: selectedNode is TextNode ? _toggleUnderline : null, + icon: const Icon(Icons.format_underline), + color: _isUnderlineActive ? Theme.of(context).primaryColor : null, + ), + IconButton( + onPressed: selectedNode is TextNode ? _toggleStrikethrough : null, + icon: const Icon(Icons.strikethrough_s), + color: _isStrikethroughActive ? Theme.of(context).primaryColor : null, + ), + IconButton( + onPressed: isSingleNodeSelected && + (selectedNode is TextNode && + selectedNode.getMetadataValue('blockType') != header1Attribution) + ? _convertToHeader1 + : null, + icon: const Icon(Icons.title), + ), + IconButton( + onPressed: isSingleNodeSelected && + (selectedNode is TextNode && + selectedNode.getMetadataValue('blockType') != header2Attribution) + ? _convertToHeader2 + : null, + icon: const Icon(Icons.title), + iconSize: 18, + ), + IconButton( + onPressed: isSingleNodeSelected && + ((selectedNode is ParagraphNode && + selectedNode.hasMetadataValue('blockType')) || + (selectedNode is TextNode && selectedNode is! ParagraphNode)) + ? _convertToParagraph + : null, + icon: const Icon(Icons.wrap_text), + ), + IconButton( + onPressed: isSingleNodeSelected && + (selectedNode is TextNode && selectedNode is! ListItemNode || + (selectedNode is ListItemNode && selectedNode.type != ListItemType.ordered)) + ? _convertToOrderedListItem + : null, + icon: const Icon(Icons.looks_one_rounded), + ), + IconButton( + onPressed: isSingleNodeSelected && + (selectedNode is TextNode && selectedNode is! ListItemNode || + (selectedNode is ListItemNode && + selectedNode.type != ListItemType.unordered)) + ? _convertToUnorderedListItem + : null, + icon: const Icon(Icons.list), + ), + IconButton( + onPressed: isSingleNodeSelected && + selectedNode is TextNode && + (selectedNode is! ParagraphNode || + selectedNode.getMetadataValue('blockType') != blockquoteAttribution) + ? _convertToBlockquote + : null, + icon: const Icon(Icons.format_quote), + ), + IconButton( + onPressed: isSingleNodeSelected && + selectedNode is ParagraphNode && + selectedNode.text.text.isEmpty + ? _convertToHr + : null, + icon: const Icon(Icons.horizontal_rule), + ), + ], + ); + }), + ), + ), + Container( + width: 1, + height: 32, + color: const Color(0xFFCCCCCC), + ), + IconButton( + onPressed: _closeKeyboard, + icon: const Icon(Icons.keyboard_hide), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/super_editor/lib/src/default_editor/document_ime/supereditor_ime_interactor.dart b/super_editor/lib/src/default_editor/document_ime/supereditor_ime_interactor.dart new file mode 100644 index 000000000..2fffc4605 --- /dev/null +++ b/super_editor/lib/src/default_editor/document_ime/supereditor_ime_interactor.dart @@ -0,0 +1,306 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/infrastructure/ime_input_owner.dart'; +import 'package:super_editor/src/infrastructure/platforms/ios/ios_document_controls.dart'; + +import '../document_hardware_keyboard/document_input_keyboard.dart'; +import 'document_delta_editing.dart'; +import 'document_ime_communication.dart'; +import 'document_ime_interaction_policies.dart'; +import 'ime_keyboard_control.dart'; + +/// [SuperEditor] interactor that edits a document based on IME input +/// from the operating system. +// TODO: instead of an IME interactor, try defining more granular interactors, e.g., +// TextDeltaInteractor, FloatingCursorInteractor, ScribbleInteractor. +// The concept of the IME is so broad in functionality that if we mimic that +// concept, we're going to get stuck piling unrelated behaviors into one place. +// To make this division of responsibility possible, each of those interactors +// could receive a proxy TextInputClient, which allows each interactor to say +// proxyInputClient.addClient(myFocusedClient). +class SuperEditorImeInteractor extends StatefulWidget { + const SuperEditorImeInteractor({ + Key? key, + this.focusNode, + this.autofocus = false, + required this.editContext, + this.softwareKeyboardController, + this.imePolicies = const SuperEditorImePolicies(), + this.imeConfiguration = const SuperEditorImeConfiguration(), + this.hardwareKeyboardActions = const [], + this.floatingCursorController, + required this.child, + }) : super(key: key); + + final FocusNode? focusNode; + + final bool autofocus; + + /// All resources that are needed to edit a document. + final EditContext editContext; + + /// Controller that opens and closes the software keyboard. + /// + /// When [SuperEditorImePolicies.openKeyboardOnSelectionChange] and + /// [SuperEditorImePolicies.clearSelectionWhenImeDisconnects] are `false`, + /// an app can use this controller to manually open and close the software + /// keyboard, as needed. + /// + /// When [SuperEditorImePolicies.openKeyboardOnSelectionChange] and + /// [clearSelectionWhenImeDisconnects] are `true`, this controller probably + /// shouldn't be used, because the commands to open and close the keyboard + /// might conflict with teh automated behavior. + final SoftwareKeyboardController? softwareKeyboardController; + + /// Policies that dictate when and how `SuperEditor` should interact with the + /// platform IME. + final SuperEditorImePolicies imePolicies; + + /// Preferences for how the platform IME should look and behave during editing. + final SuperEditorImeConfiguration imeConfiguration; + + /// All the actions that the user can execute with physical hardware + /// keyboard keys. + /// + /// [keyboardActions] operates as a Chain of Responsibility. Starting + /// from the beginning of the list, a [DocumentKeyboardAction] is + /// given the opportunity to handle the currently pressed keys. If that + /// [DocumentKeyboardAction] reports the keys as handled, then execution + /// stops. Otherwise, execution continues to the next [DocumentKeyboardAction]. + final List hardwareKeyboardActions; + + /// Controls "floating cursor" behavior for iOS devices. + /// + /// The floating cursor is an iOS-only feature. Flutter reports floating cursor + /// messages through the IME API, which is why this controller is offered as + /// a property on this IME interactor. + final FloatingCursorController? floatingCursorController; + + final Widget child; + + @override + State createState() => _SuperEditorImeInteractorState(); +} + +class _SuperEditorImeInteractorState extends State implements ImeInputOwner { + late FocusNode _focusNode; + + final _imeConnection = ValueNotifier(null); + late TextInputConfiguration _textInputConfiguration; + late final DocumentImeInputClient _documentImeClient; + // _documentImeConnection functions as both a TextInputConnection and a + // DeltaTextInputClient. This is required for a very specific reason that + // occurs in specific situations. To understand why we need it, check the + // implementation of DocumentImeInputClient. If we find a less confusing + // way to handle that scenario, then get rid of this property. + final _documentImeConnection = ValueNotifier(null); + late final TextDeltasDocumentEditor _textDeltasDocumentEditor; + + @override + void initState() { + super.initState(); + _focusNode = (widget.focusNode ?? FocusNode()); + + _textDeltasDocumentEditor = TextDeltasDocumentEditor( + editor: widget.editContext.editor, + selection: widget.editContext.composer.selectionNotifier, + composingRegion: widget.editContext.composer.composingRegion, + commonOps: widget.editContext.commonOps, + ); + _documentImeClient = DocumentImeInputClient( + textDeltasDocumentEditor: _textDeltasDocumentEditor, + imeConnection: _imeConnection, + floatingCursorController: widget.floatingCursorController, + ); + + _imeConnection.addListener(_onImeConnectionChange); + + _textInputConfiguration = widget.imeConfiguration.toTextInputConfiguration(); + } + + @override + void didUpdateWidget(SuperEditorImeInteractor oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.imeConfiguration != oldWidget.imeConfiguration) { + _textInputConfiguration = widget.imeConfiguration.toTextInputConfiguration(); + if (_isAttachedToIme) { + _imeConnection.value!.updateConfig(_textInputConfiguration); + } + } + } + + @override + void dispose() { + _imeConnection.removeListener(_onImeConnectionChange); + _imeConnection.value?.close(); + + if (widget.focusNode == null) { + _focusNode.dispose(); + } + + super.dispose(); + } + + @visibleForTesting + @override + DeltaTextInputClient get imeClient => _documentImeClient; + + bool get _isAttachedToIme => _imeConnection.value?.attached ?? false; + + void _onImeConnectionChange() { + if (_imeConnection.value == null) { + _documentImeConnection.value = null; + } else { + _documentImeConnection.value = _documentImeClient; + } + } + + @override + Widget build(BuildContext context) { + return SuperEditorHardwareKeyHandler( + focusNode: _focusNode, + editContext: widget.editContext, + keyboardActions: widget.hardwareKeyboardActions, + autofocus: widget.autofocus, + child: DocumentSelectionOpenAndCloseImePolicy( + focusNode: _focusNode, + selection: widget.editContext.composer.selectionNotifier, + imeConnection: _imeConnection, + imeClientFactory: () => _documentImeClient, + imeConfiguration: _textInputConfiguration, + openKeyboardOnSelectionChange: widget.imePolicies.openKeyboardOnSelectionChange, + closeKeyboardOnSelectionLost: widget.imePolicies.closeKeyboardOnSelectionLost, + clearSelectionWhenImeDisconnects: widget.imePolicies.clearSelectionWhenImeDisconnects, + child: ImeFocusPolicy( + focusNode: _focusNode, + imeConnection: _imeConnection, + child: SoftwareKeyboardOpener( + controller: widget.softwareKeyboardController, + imeConnection: _imeConnection, + createImeClient: () => _documentImeClient, + createImeConfiguration: () => _textInputConfiguration, + child: DocumentToImeSynchronizer( + document: widget.editContext.editor.document, + selection: widget.editContext.composer.selectionNotifier, + composingRegion: widget.editContext.composer.composingRegion, + imeConnection: _documentImeConnection, + child: widget.child, + ), + ), + ), + ), + ); + } +} + +/// A collection of policies that dictate how and when `SuperEditor` should +/// interact with the IME, such as opening the software keyboard whenever +/// `SuperEditor`'s selection changes ([openKeyboardOnSelectionChange]). +class SuperEditorImePolicies { + const SuperEditorImePolicies({ + this.openKeyboardOnSelectionChange = true, + this.closeKeyboardOnSelectionLost = true, + this.clearSelectionWhenImeDisconnects = true, + }); + + /// Whether the software keyboard should be raised whenever the editor's selection + /// changes, such as when a user taps to place the caret. + /// + /// In a typical app, this property should be `true`. In some apps, the keyboard + /// needs to be closed and opened to reveal special editing controls. In those cases + /// this property should probably be `false`, and the app should take responsibility + /// for opening and closing the keyboard. + final bool openKeyboardOnSelectionChange; + + /// Whether the software keyboard should be closed whenever the editor goes from + /// having a selection to not having a selection. + /// + /// In a typical app, this property should be `true`, because there's no place to + /// apply IME input when there's no editor selection. + final bool closeKeyboardOnSelectionLost; + + /// Whether the document's selection should be cleared (removed) when the + /// IME disconnects, i.e., the software keyboard closes. + /// + /// Typically, on devices with software keyboards, the keyboard is critical + /// to all document editing. In such cases, it would be reasonable to clear + /// the selection when the keyboard closes. + /// + /// Some apps include editing features that can operate when the keyboard is + /// closed. For example, some apps display special editing options behind the + /// keyboard. The user closes the keyboard, uses the special options, and then + /// re-opens the keyboard. In this case, the document selection **shouldn't** + /// be cleared when the keyboard closes, because the special options behind the + /// keyboard still need to operate on that selection. + final bool clearSelectionWhenImeDisconnects; +} + +/// Input Method Engine (IME) configuration for document text input. +class SuperEditorImeConfiguration { + const SuperEditorImeConfiguration({ + this.enableAutocorrect = true, + this.enableSuggestions = true, + this.keyboardBrightness = Brightness.light, + this.keyboardActionButton = TextInputAction.newline, + }); + + /// Whether the OS should offer auto-correction options to the user. + final bool enableAutocorrect; + + /// Whether the OS should offer text completion suggestions to the user. + final bool enableSuggestions; + + /// The brightness of the software keyboard (only applies to platforms + /// with a software keyboard). + final Brightness keyboardBrightness; + + /// The action button that's displayed on a software keyboard, e.g., + /// new-line, done, go, etc. + final TextInputAction keyboardActionButton; + + TextInputConfiguration toTextInputConfiguration() { + return TextInputConfiguration( + enableDeltaModel: true, + inputType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + autocorrect: enableAutocorrect, + enableSuggestions: enableSuggestions, + inputAction: keyboardActionButton, + keyboardAppearance: keyboardBrightness, + ); + } + + SuperEditorImeConfiguration copyWith({ + bool? enableAutocorrect, + bool? enableSuggestions, + Brightness? keyboardBrightness, + TextInputAction? keyboardActionButton, + bool? clearSelectionWhenImeDisconnects, + }) { + return SuperEditorImeConfiguration( + enableAutocorrect: enableAutocorrect ?? this.enableAutocorrect, + enableSuggestions: enableSuggestions ?? this.enableSuggestions, + keyboardBrightness: keyboardBrightness ?? this.keyboardBrightness, + keyboardActionButton: keyboardActionButton ?? this.keyboardActionButton, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SuperEditorImeConfiguration && + runtimeType == other.runtimeType && + enableAutocorrect == other.enableAutocorrect && + enableSuggestions == other.enableSuggestions && + keyboardBrightness == other.keyboardBrightness && + keyboardActionButton == other.keyboardActionButton; + + @override + int get hashCode => + enableAutocorrect.hashCode ^ + enableSuggestions.hashCode ^ + keyboardBrightness.hashCode ^ + keyboardActionButton.hashCode; +} diff --git a/super_editor/lib/src/default_editor/document_input_ime.dart b/super_editor/lib/src/default_editor/document_input_ime.dart deleted file mode 100644 index a4bf50763..000000000 --- a/super_editor/lib/src/default_editor/document_input_ime.dart +++ /dev/null @@ -1,1353 +0,0 @@ -import 'dart:math'; - -import 'package:attributed_text/attributed_text.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart' hide ListenableBuilder; -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_editor.dart'; -import 'package:super_editor/src/core/document_selection.dart'; -import 'package:super_editor/src/core/edit_context.dart'; -import 'package:super_editor/src/default_editor/common_editor_operations.dart'; -import 'package:super_editor/src/default_editor/paragraph.dart'; -import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart'; -import 'package:super_editor/src/default_editor/text.dart'; -import 'package:super_editor/src/infrastructure/_listenable_builder.dart'; -import 'package:super_editor/src/infrastructure/_logging.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/platforms/ios/ios_document_controls.dart'; - -import 'attributions.dart'; -import 'document_input_keyboard.dart'; -import 'list_items.dart'; - -/// Governs document input that comes from the operating system's -/// Input Method Engine (IME). -/// -/// IME input is the only form of input that can come from a mobile -/// device's software keyboard. In a desktop environment with a -/// physical keyboard, developers can choose to respond to IME input -/// or individual key presses on the keyboard. For key press input, -/// see super_editor's keyboard input support. - -/// Document interactor that changes a document based on IME input -/// from the operating system. -class DocumentImeInteractor extends StatefulWidget { - const DocumentImeInteractor({ - Key? key, - this.focusNode, - this.autofocus = false, - required this.editContext, - required this.softwareKeyboardHandler, - this.hardwareKeyboardActions = const [], - this.floatingCursorController, - required this.child, - }) : super(key: key); - - final FocusNode? focusNode; - - final bool autofocus; - - final EditContext editContext; - - final SoftwareKeyboardHandler softwareKeyboardHandler; - - /// All the actions that the user can execute with physical hardware - /// keyboard keys. - /// - /// [keyboardActions] operates as a Chain of Responsibility. Starting - /// from the beginning of the list, a [DocumentKeyboardAction] is - /// given the opportunity to handle the currently pressed keys. If that - /// [DocumentKeyboardAction] reports the keys as handled, then execution - /// stops. Otherwise, execution continues to the next [DocumentKeyboardAction]. - final List hardwareKeyboardActions; - - final FloatingCursorController? floatingCursorController; - - final Widget child; - - @override - State createState() => _DocumentImeInteractorState(); -} - -class _DocumentImeInteractorState extends State - with TextInputClient, DeltaTextInputClient - implements ImeInputOwner { - late FocusNode _focusNode; - - TextInputConnection? _inputConnection; - - @override - void initState() { - super.initState(); - - _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); - - widget.editContext.composer.selectionNotifier.addListener(_onComposerChange); - widget.editContext.composer.imeConfiguration.addListener(_onClientWantsDifferentImeConfiguration); - } - - @override - void didUpdateWidget(DocumentImeInteractor oldWidget) { - super.didUpdateWidget(oldWidget); - - if (widget.focusNode != oldWidget.focusNode) { - _focusNode.removeListener(_onFocusChange); - _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); - } - - if (widget.editContext.composer.selectionNotifier != oldWidget.editContext.composer.selectionNotifier) { - oldWidget.editContext.composer.selectionNotifier.removeListener(_onComposerChange); - widget.editContext.composer.selectionNotifier.addListener(_onComposerChange); - } - if (widget.editContext.composer.imeConfiguration != oldWidget.editContext.composer.imeConfiguration) { - oldWidget.editContext.composer.imeConfiguration.removeListener(_onClientWantsDifferentImeConfiguration); - oldWidget.editContext.composer.imeConfiguration.addListener(_onClientWantsDifferentImeConfiguration); - } - } - - @override - void dispose() { - _detachFromIme(); - - widget.editContext.composer.imeConfiguration.removeListener(_onClientWantsDifferentImeConfiguration); - widget.editContext.composer.selectionNotifier.removeListener(_onComposerChange); - - if (widget.focusNode == null) { - _focusNode.dispose(); - } - - super.dispose(); - } - - @override - DeltaTextInputClient get imeClient => this; - - void _onFocusChange() { - if (_focusNode.hasFocus) { - editorImeLog.info('Gained focus'); - _attachToIme(); - } else { - editorImeLog.info('Lost focus'); - _detachFromIme(); - } - } - - void _onComposerChange() { - final selection = widget.editContext.composer.selection; - editorImeLog.info("Document composer (${widget.editContext.composer.hashCode}) changed. New selection: $selection"); - - if (selection == null) { - _detachFromIme(); - } else { - if (isAttachedToIme && !_isApplyingDeltas) { - // Note: ^ We don't re-serialize and send to IME while we're in the middle - // of applying deltas because we might be in an inconsistent state. A sync - // will be done when all the deltas have been applied. - _inputConnection!.show(); - editorImeLog.fine( - "Document composer changed while attached to IME. Re-serializing the document and sending to the IME."); - _syncImeWithDocumentAndComposer(); - } else if (!isAttachedToIme) { - _attachToIme(); - } - } - } - - void _onClientWantsDifferentImeConfiguration() { - if (!isAttachedToIme) { - return; - } - - editorImeLog.fine( - "Updating IME to use new config with action button: ${widget.editContext.composer.imeConfiguration.value.keyboardActionButton}"); - _inputConnection!.updateConfig(_createInputConfiguration()); - } - - bool get isAttachedToIme => _inputConnection?.attached == true; - - void _attachToIme() { - if (isAttachedToIme) { - // We're already connected to the IME. - return; - } - - editorImeLog.info('Attaching TextInputClient to TextInput'); - - _inputConnection = TextInput.attach( - this, - _createInputConfiguration(), - ); - - _syncImeWithDocumentAndComposer(); - - _inputConnection! - ..show() - ..setEditingState(currentTextEditingValue); - - editorImeLog.fine('Is attached to input client? ${_inputConnection!.attached}'); - } - - TextInputConfiguration _createInputConfiguration() { - final imeConfig = widget.editContext.composer.imeConfiguration.value; - - return TextInputConfiguration( - enableDeltaModel: true, - inputType: TextInputType.multiline, - textCapitalization: TextCapitalization.sentences, - autocorrect: imeConfig.enableAutocorrect, - enableSuggestions: imeConfig.enableSuggestions, - inputAction: imeConfig.keyboardActionButton, - keyboardAppearance: imeConfig.keyboardBrightness ?? MediaQuery.of(context).platformBrightness, - ); - } - - void _detachFromIme() { - if (!isAttachedToIme) { - return; - } - - editorImeLog.info('Detaching TextInputClient from TextInput.'); - - widget.editContext.composer.selection = null; - - _inputConnection!.close(); - } - - @override - // TODO: implement currentAutofillScope - AutofillScope? get currentAutofillScope => throw UnimplementedError(); - - @override - TextEditingValue get currentTextEditingValue => _currentTextEditingValue; - TextEditingValue _currentTextEditingValue = const TextEditingValue(); - DocumentImeSerializer? _currentImeSerialization; - TextEditingValue? _lastTextEditingValueSentToOs; - set currentTextEditingValue(TextEditingValue newValue) { - _currentTextEditingValue = newValue; - if (newValue != _lastTextEditingValueSentToOs && !_isApplyingDeltas) { - editorImeLog.info("Sending new text editing value to OS: $_currentTextEditingValue"); - _inputConnection?.setEditingState(_currentTextEditingValue); - _lastTextEditingValueSentToOs = _currentTextEditingValue; - } else if (_isApplyingDeltas) { - editorImeLog.fine("Ignoring new TextEditingValue because we're applying deltas"); - } else { - editorImeLog.fine("Ignoring new TextEditingValue because it's the same as the existing one: $newValue"); - } - } - - bool _isApplyingDeltas = false; - - void _syncImeWithDocumentAndComposer([TextRange? newComposingRegion]) { - final selection = widget.editContext.composer.selection; - if (selection != null) { - editorImeLog.fine("Syncing IME with Doc and Composer, given composing region: $newComposingRegion"); - - final newDocSerialization = DocumentImeSerializer( - widget.editContext.editor.document, - selection, - ); - - editorImeLog.fine("Previous doc serialization did prepend? ${_currentImeSerialization?.didPrependPlaceholder}"); - editorImeLog.fine("Desired composing region: $newComposingRegion"); - editorImeLog.fine("Did new doc prepend placeholder? ${newDocSerialization.didPrependPlaceholder}"); - TextRange composingRegion = newComposingRegion ?? currentTextEditingValue.composing; - if (_currentImeSerialization != null && - _currentImeSerialization!.didPrependPlaceholder && - composingRegion.isValid && - !newDocSerialization.didPrependPlaceholder) { - // The IME's desired composing region includes the prepended placeholder. - // The updated IME value doesn't have a prepended placeholder, adjust - // the composing region bounds. - composingRegion = TextRange( - start: composingRegion.start - 2, - end: composingRegion.end - 2, - ); - } - - _currentImeSerialization = newDocSerialization; - currentTextEditingValue = newDocSerialization.toTextEditingValue().copyWith(composing: composingRegion); - } - } - - @override - void updateEditingValue(TextEditingValue value) { - editorImeLog.info("Received new TextEditingValue from OS: $value"); - setState(() { - _currentTextEditingValue = value; - }); - } - - @override - void updateEditingValueWithDeltas(List textEditingDeltas) { - editorImeLog.info("Received edit deltas from platform: ${textEditingDeltas.length} deltas"); - for (final delta in textEditingDeltas) { - editorImeLog.info("$delta"); - } - - final imeValueBeforeChange = currentTextEditingValue; - editorImeLog.fine("IME value before applying deltas: $imeValueBeforeChange"); - - _isApplyingDeltas = true; - widget.softwareKeyboardHandler.applyDeltas(textEditingDeltas); - _isApplyingDeltas = false; - - editorImeLog.fine("Done applying deltas. Serializing the document and sending to IME."); - _syncImeWithDocumentAndComposer(textEditingDeltas.last.composing); - - editorImeLog.fine("IME value after applying deltas: $currentTextEditingValue"); - - final hasDestructiveUpdate = - textEditingDeltas.where((element) => element is! TextEditingDeltaNonTextUpdate).toList().isNotEmpty; - if (hasDestructiveUpdate && imeValueBeforeChange == currentTextEditingValue) { - // Sometimes the IME reports changes to us, but our document doesn't change - // in ways that's reflected in the IME. In this case, we need to "reset" - // the IME value to what it was before the deltas. - // - // Example: The user has a caret in an empty paragraph. That empty paragraph - // includes a couple hidden characters, so the IME value might look like: - // - // ". |" - // - // The ". " substring is invisible to the user and the "|" represents the caret at - // the beginning of the empty paragraph. - // - // Then the user inserts a newline "\n". This causes Super Editor to insert a new, - // empty paragraph node, and place the caret in the new, empty paragraph. At this - // point, we have an issue: - // - // This class still sees the TextEditingValue as: ". |" - // - // However, the OS IME thinks the TextEditingValue is: ". |\n" - // - // In this situation, even though our TextEditingValue looks identical to what it - // was before, we need to send our TextEditingValue to the OS so that the OS doesn't - // think there's a "\n" sitting in the edit region. - editorImeLog.fine( - "Sending forceful update to IME because our local TextEditingValue didn't change, but the IME may have"); - _inputConnection!.setEditingState(currentTextEditingValue); - } - } - - @override - void performAction(TextInputAction action) { - editorImeLog.fine("IME says to perform action: $action"); - widget.softwareKeyboardHandler.performAction(action); - } - - @override - void performSelector(String selectorName) { - // TODO: implement this method starting with Flutter 3.3.4 - } - - @override - void performPrivateCommand(String action, Map data) { - // TODO: implement performPrivateCommand - } - - @override - void showAutocorrectionPromptRect(int start, int end) { - // TODO: implement showAutocorrectionPromptRect - } - - @override - void updateFloatingCursor(RawFloatingCursorPoint point) { - switch (point.state) { - case FloatingCursorDragState.Start: - case FloatingCursorDragState.Update: - widget.floatingCursorController?.offset = point.offset; - break; - case FloatingCursorDragState.End: - widget.floatingCursorController?.offset = null; - break; - } - } - - @override - void connectionClosed() { - editorImeLog.info("IME connection closed"); - _inputConnection = null; - } - - KeyEventResult _onKeyPressed(FocusNode node, RawKeyEvent keyEvent) { - if (keyEvent is! RawKeyDownEvent) { - editorKeyLog.finer("Received key event, but ignoring because it's not a down event: $keyEvent"); - return KeyEventResult.handled; - } - - editorKeyLog.info("Handling key press: $keyEvent"); - ExecutionInstruction instruction = ExecutionInstruction.continueExecution; - int index = 0; - while (instruction == ExecutionInstruction.continueExecution && index < widget.hardwareKeyboardActions.length) { - instruction = widget.hardwareKeyboardActions[index]( - editContext: widget.editContext, - keyEvent: keyEvent, - ); - index += 1; - } - - switch (instruction) { - case ExecutionInstruction.haltExecution: - return KeyEventResult.handled; - case ExecutionInstruction.continueExecution: - case ExecutionInstruction.blocked: - return KeyEventResult.ignored; - } - } - - @override - Widget build(BuildContext context) { - return Focus( - focusNode: _focusNode, - autofocus: widget.autofocus, - onKey: widget.hardwareKeyboardActions.isEmpty ? null : _onKeyPressed, - child: widget.child, - ); - } -} - -class DocumentImeSerializer { - static const _leadingCharacter = '. '; - - DocumentImeSerializer(this._doc, this._selection) { - _serialize(); - } - - final Document _doc; - final DocumentSelection _selection; - final _imeRangesToDocTextNodes = {}; - final _docTextNodesToImeRanges = {}; - final _selectedNodes = []; - late String _imeText; - String _prependedPlaceholder = ''; - - void _serialize() { - editorImeLog.fine("Creating an IME model from document and selection"); - final buffer = StringBuffer(); - int characterCount = 0; - - if (_shouldPrependPlaceholder()) { - // Put an arbitrary character at the front of the text so that - // the IME will report backspace buttons when the caret sits at - // the beginning of the node. For example, the caret is at the - // beginning of some text and we want to combine this text with - // the text above it when the user presses backspace. - // - // Text above... - // |The selected text node. - _prependedPlaceholder = _leadingCharacter; - buffer.write(_prependedPlaceholder); - characterCount = _prependedPlaceholder.length; - } else { - _prependedPlaceholder = ''; - } - - _selectedNodes.clear(); - _selectedNodes.addAll(_doc.getNodesInContentOrder(_selection)); - for (int i = 0; i < _selectedNodes.length; i += 1) { - // Append a newline character before appending another node's text. - // - // The choice to separate each node with a newline was a judgement call. - // There is no OS-level expectation for how structured content should - // collapse down to IME content. - if (i != 0) { - buffer.write('\n'); - characterCount += 1; - } - - final node = _selectedNodes[i]; - if (node is! TextNode) { - buffer.write('~'); - characterCount += 1; - - final imeRange = TextRange(start: characterCount - 1, end: characterCount); - _imeRangesToDocTextNodes[imeRange] = node.id; - _docTextNodesToImeRanges[node.id] = imeRange; - - continue; - } - - // Cache mappings between the IME text range and the document position - // so that we can easily convert between the two, when requested. - final imeRange = TextRange(start: characterCount, end: characterCount + node.text.text.length); - _imeRangesToDocTextNodes[imeRange] = node.id; - _docTextNodesToImeRanges[node.id] = imeRange; - - // Concatenate this node's text with the previous nodes. - buffer.write(node.text.text); - characterCount += node.text.text.length; - } - - _imeText = buffer.toString(); - editorImeLog.fine("IME serialization:\n'$_imeText'"); - } - - bool _shouldPrependPlaceholder() { - // We want to prepend an arbitrary placeholder character whenever the - // user's selection is collapsed at the beginning of a node, and there's - // another node above the selected node. Without the arbitrary character, - // the IME would assume that there's no content before the current node and - // therefore it wouldn't report the backspace button. - final selectedNode = _doc.getNode(_selection.extent)!; - final selectedNodeIndex = _doc.getNodeIndexById(selectedNode.id); - return selectedNodeIndex > 0 && - _selection.isCollapsed && - _selection.extent.nodePosition == selectedNode.beginningPosition; - } - - bool get didPrependPlaceholder => _prependedPlaceholder.isNotEmpty; - - DocumentSelection? imeToDocumentSelection(TextSelection imeSelection) { - editorImeLog.fine("Creating doc selection from IME selection: $imeSelection"); - if (didPrependPlaceholder && - ((!imeSelection.isCollapsed && imeSelection.start < _prependedPlaceholder.length) || - (imeSelection.isCollapsed && imeSelection.extentOffset <= _prependedPlaceholder.length))) { - // The IME is trying to select our artificial prepended character. - // If that's the only character that the IME is trying to select, then - // return a null selection to indicate that there's nothing to select. - // If the selection is expanded, then remove the arbitrary character from - // the selection. - if ((imeSelection.isCollapsed && imeSelection.extentOffset < _prependedPlaceholder.length) || - (imeSelection.start < _prependedPlaceholder.length && imeSelection.end == _prependedPlaceholder.length)) { - editorImeLog.fine("Returning null doc selection"); - return null; - } else { - editorImeLog.fine("Removing arbitrary character from IME selection"); - imeSelection = imeSelection.copyWith( - baseOffset: min(imeSelection.baseOffset, _prependedPlaceholder.length), - extentOffset: min(imeSelection.extentOffset, _prependedPlaceholder.length), - ); - editorImeLog.fine("Adjusted IME selection is: $imeSelection"); - } - } else { - editorImeLog.fine("Mapping the IME base/extent to their corresponding doc positions without modification."); - } - - final base = _imeToDocumentPosition( - imeSelection.base, - isUpstream: imeSelection.base.affinity == TextAffinity.upstream, - ); - final extent = _imeToDocumentPosition( - imeSelection.extent, - isUpstream: imeSelection.extent.affinity == TextAffinity.upstream, - ); - - if (base == null || extent == null) { - return null; - } - - return DocumentSelection( - base: base, - extent: extent, - ); - } - - DocumentPosition? _imeToDocumentPosition(TextPosition imePosition, {required bool isUpstream}) { - for (final range in _imeRangesToDocTextNodes.keys) { - if (imePosition.offset >= range.start && imePosition.offset <= range.end) { - final node = _doc.getNodeById(_imeRangesToDocTextNodes[range]!)!; - - if (node is TextNode) { - return DocumentPosition( - nodeId: _imeRangesToDocTextNodes[range]!, - nodePosition: TextNodePosition(offset: imePosition.offset - range.start), - ); - } else { - if (imePosition.offset <= range.start) { - // Return a position at the start of the node. - return DocumentPosition( - nodeId: node.id, - nodePosition: node.beginningPosition, - ); - } else { - // Return a position at the end of the node. - return DocumentPosition( - nodeId: node.id, - nodePosition: node.endPosition, - ); - } - } - } - } - - editorImeLog.shout( - "Couldn't map an IME position to a document position. IME position: $imePosition. The selected offset range is: ${_imeRangesToDocTextNodes.keys.last.start} -> ${_imeRangesToDocTextNodes.keys.last.end}"); - return null; - } - - TextSelection documentToImeSelection(DocumentSelection docSelection) { - editorImeLog.fine("Converting doc selection to ime selection: $docSelection"); - final selectionAffinity = _doc.getAffinityForSelection(docSelection); - - final startDocPosition = selectionAffinity == TextAffinity.downstream ? docSelection.base : docSelection.extent; - final startImePosition = _documentToImePosition(startDocPosition); - - final endDocPosition = selectionAffinity == TextAffinity.downstream ? docSelection.extent : docSelection.base; - final endImePosition = _documentToImePosition(endDocPosition); - - editorImeLog.fine("Start IME position: $startImePosition"); - editorImeLog.fine("End IME position: $endImePosition"); - return TextSelection( - baseOffset: startImePosition.offset, - extentOffset: endImePosition.offset, - affinity: startImePosition == endImePosition ? endImePosition.affinity : TextAffinity.downstream, - ); - } - - TextPosition _documentToImePosition(DocumentPosition docPosition) { - editorImeLog.fine("Converting DocumentPosition to IME TextPosition: $docPosition"); - final imeRange = _docTextNodesToImeRanges[docPosition.nodeId]; - if (imeRange == null) { - throw Exception("No such document position in the IME content: $docPosition"); - } - - final nodePosition = docPosition.nodePosition; - - if (nodePosition is UpstreamDownstreamNodePosition) { - if (nodePosition.affinity == TextAffinity.upstream) { - editorImeLog.fine("The doc position is an upstream position on a block."); - // Return the text position before the special character, - // e.g., "|~". - return TextPosition(offset: imeRange.start); - } else { - editorImeLog.fine("The doc position is a downstream position on a block."); - // Return the text position after the special character, - // e.g., "~|". - return TextPosition(offset: imeRange.start + 1); - } - } - - if (nodePosition is TextNodePosition) { - return TextPosition(offset: imeRange.start + (docPosition.nodePosition as TextNodePosition).offset); - } - - throw Exception("Super Editor doesn't know how to convert a $nodePosition into an IME-compatible selection"); - } - - TextEditingValue toTextEditingValue() { - editorImeLog.fine("Creating TextEditingValue from document. Selection: $_selection"); - editorImeLog.fine("Text:\n'$_imeText'"); - final imeSelection = documentToImeSelection(_selection); - editorImeLog.fine("Selection: $imeSelection"); - - return TextEditingValue( - text: _imeText, - selection: imeSelection, - ); - } - - /// Narrows the given [selection] until the base and extent both point to - /// `TextNode`s. - /// - /// If the given [selection] base and/or extent already point to a `TextNode` - /// then those same end-caps are retained in the returned `DocumentSelection`. - /// - /// If there is no text content within the [selection], `null` is returned. - DocumentSelection? _constrictToTextSelectionEndCaps(DocumentSelection selection) { - final baseNode = _doc.getNodeById(selection.base.nodeId)!; - final baseNodeIndex = _doc.getNodeIndexById(baseNode.id); - final extentNode = _doc.getNodeById(selection.extent.nodeId)!; - final extentNodeIndex = _doc.getNodeIndexById(extentNode.id); - - final startNode = baseNodeIndex <= extentNodeIndex ? baseNode : extentNode; - final startNodeIndex = _doc.getNodeIndexById(startNode.id); - final startPosition = - baseNodeIndex <= extentNodeIndex ? selection.base.nodePosition : selection.extent.nodePosition; - final endNode = baseNodeIndex <= extentNodeIndex ? extentNode : baseNode; - final endNodeIndex = _doc.getNodeIndexById(endNode.id); - final endPosition = baseNodeIndex <= extentNodeIndex ? selection.extent.nodePosition : selection.base.nodePosition; - - if (startNodeIndex == endNodeIndex) { - // The document selection is all in one node. - if (startNode is! TextNode) { - // The only content selected is non-text. Return null. - return null; - } - - // Part of a single TextNode is selected, therefore, the given selection - // is already restricted to text end caps. - return selection; - } - - DocumentNode? restrictedStartNode; - TextNodePosition? restrictedStartPosition; - if (startNode is TextNode) { - restrictedStartNode = startNode; - restrictedStartPosition = startPosition as TextNodePosition; - } else { - int restrictedStartNodeIndex = startNodeIndex + 1; - while (_doc.getNodeAt(restrictedStartNodeIndex) is! TextNode && restrictedStartNodeIndex <= endNodeIndex) { - restrictedStartNodeIndex += 1; - } - - if (_doc.getNodeAt(restrictedStartNodeIndex) is TextNode) { - restrictedStartNode = _doc.getNodeAt(restrictedStartNodeIndex); - restrictedStartPosition = const TextNodePosition(offset: 0); - } - } - - DocumentNode? restrictedEndNode; - TextNodePosition? restrictedEndPosition; - if (endNode is TextNode) { - restrictedEndNode = endNode; - restrictedEndPosition = endPosition as TextNodePosition; - } else { - int restrictedEndNodeIndex = endNodeIndex - 1; - while (_doc.getNodeAt(restrictedEndNodeIndex) is! TextNode && restrictedEndNodeIndex >= startNodeIndex) { - restrictedEndNodeIndex -= 1; - } - - if (_doc.getNodeAt(restrictedEndNodeIndex) is TextNode) { - restrictedEndNode = _doc.getNodeAt(restrictedEndNodeIndex); - restrictedEndPosition = TextNodePosition(offset: (restrictedEndNode as TextNode).text.text.length); - } - } - - // If there was no text between the selection end-caps, return null. - if (restrictedStartPosition == null || restrictedEndPosition == null) { - return null; - } - - return DocumentSelection( - base: DocumentPosition( - nodeId: restrictedStartNode!.id, - nodePosition: restrictedStartPosition, - ), - extent: DocumentPosition( - nodeId: restrictedEndNode!.id, - nodePosition: restrictedEndPosition, - ), - ); - } - - /// Serializes just enough document text to serve the needs of the IME. - /// - /// The serialized text includes all the content of all partially selected - /// nodes, plus one node on either side to allow for upstream and downstream - /// deletions. For example, the user press backspace at the beginning of a - /// paragraph. We need to tell the IME that there's content before the paragraph - /// so that the IME sends us the delete delta. - String _getMinimumTextForIME(DocumentSelection selection) { - final baseNode = _doc.getNodeById(selection.base.nodeId)!; - final baseNodeIndex = _doc.getNodeIndexById(baseNode.id); - final extentNode = _doc.getNodeById(selection.extent.nodeId)!; - final extentNodeIndex = _doc.getNodeIndexById(extentNode.id); - - final selectionStartNode = baseNodeIndex <= extentNodeIndex ? baseNode : extentNode; - final selectionStartNodeIndex = _doc.getNodeIndexById(selectionStartNode.id); - final startNodeIndex = max(selectionStartNodeIndex - 1, 0); - - final selectionEndNode = baseNodeIndex <= extentNodeIndex ? extentNode : baseNode; - final selectionEndNodeIndex = _doc.getNodeIndexById(selectionEndNode.id); - final endNodeIndex = min(selectionEndNodeIndex + 1, _doc.nodes.length - 1); - - final buffer = StringBuffer(); - for (int i = startNodeIndex; i <= endNodeIndex; i += 1) { - final node = _doc.getNodeAt(i); - if (node is! TextNode) { - continue; - } - - if (buffer.length > 0) { - buffer.write('\n'); - } - - buffer.write(node.text.text); - } - - return buffer.toString(); - } -} - -/// Input Method Engine (IME) configuration for document text input. -/// -/// The IME is an operating system component that observes text that's -/// being edited, and intercepts keyboard input to apply transforms to -/// the user's input. The alternative to IME input is for an app to -/// listen and respond to each individual keyboard key. On mobile, IME -/// input is the only available input system because there is no physical -/// keyboard. -class ImeConfiguration { - const ImeConfiguration({ - this.enableAutocorrect = true, - this.enableSuggestions = true, - this.keyboardBrightness, - this.keyboardActionButton = TextInputAction.newline, - }); - - /// Whether the OS should offer auto-correction options to the user. - final bool enableAutocorrect; - - /// Whether the OS should offer text completion suggestions to the user. - final bool enableSuggestions; - - /// The brightness of the software keyboard (only applies to platforms - /// with a software keyboard). - final Brightness? keyboardBrightness; - - /// The action button that's displayed on a software keyboard, e.g., - /// new-line, done, go, etc. - final TextInputAction keyboardActionButton; - - ImeConfiguration copyWith({ - bool? enableAutocorrect, - bool? enableSuggestions, - Brightness? keyboardBrightness, - TextInputAction? keyboardActionButton, - }) { - return ImeConfiguration( - enableAutocorrect: enableAutocorrect ?? this.enableAutocorrect, - enableSuggestions: enableSuggestions ?? this.enableSuggestions, - keyboardBrightness: keyboardBrightness ?? this.keyboardBrightness, - keyboardActionButton: keyboardActionButton ?? this.keyboardActionButton, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ImeConfiguration && - runtimeType == other.runtimeType && - enableAutocorrect == other.enableAutocorrect && - enableSuggestions == other.enableSuggestions && - keyboardBrightness == other.keyboardBrightness && - keyboardActionButton == other.keyboardActionButton; - - @override - int get hashCode => - enableAutocorrect.hashCode ^ - enableSuggestions.hashCode ^ - keyboardBrightness.hashCode ^ - keyboardActionButton.hashCode; -} - -/// Applies software keyboard edits to a document. -class SoftwareKeyboardHandler { - const SoftwareKeyboardHandler({ - required this.editor, - required this.composer, - required this.commonOps, - }); - - final DocumentEditor editor; - final DocumentComposer composer; - final CommonEditorOperations commonOps; - - /// Applies the given [textEditingDeltas] to the [Document]. - void applyDeltas(List textEditingDeltas) { - editorImeLog.info("Applying ${textEditingDeltas.length} IME deltas to document"); - - for (final delta in textEditingDeltas) { - editorImeLog.info("Applying delta: $delta"); - if (delta is TextEditingDeltaInsertion) { - _applyInsertion(delta); - } else if (delta is TextEditingDeltaReplacement) { - _applyReplacement(delta); - } else if (delta is TextEditingDeltaDeletion) { - _applyDeletion(delta); - } else if (delta is TextEditingDeltaNonTextUpdate) { - _applyNonTextChange(delta); - } else { - editorImeLog.shout("Unknown IME delta type: ${delta.runtimeType}"); - } - } - } - - void _applyInsertion(TextEditingDeltaInsertion delta) { - editorImeLog.fine('Inserted text: "${delta.textInserted}"'); - editorImeLog.fine("Insertion offset: ${delta.insertionOffset}"); - editorImeLog.fine("Selection: ${delta.selection}"); - editorImeLog.fine("Composing: ${delta.composing}"); - editorImeLog.fine('Old text: "${delta.oldText}"'); - - if (delta.textInserted == "\n") { - // On iOS, newlines are reported here and also to performAction(). - // On Android and web, newlines are only reported here. So, on Android and web, - // we forward the newline action to performAction. - if (defaultTargetPlatform == TargetPlatform.android || kIsWeb) { - editorImeLog.fine("Received a newline insertion on Android. Forwarding to newline input action."); - performAction(TextInputAction.newline); - } else { - editorImeLog.fine("Skipping insertion delta because its a newline"); - } - return; - } - - if (delta.textInserted == "\t" && (defaultTargetPlatform == TargetPlatform.iOS)) { - // On iOS, tabs pressed at the the software keyboard are reported here. - commonOps.indentListItem(); - return; - } - - editorImeLog.fine( - "Inserting text: ${delta.textInserted}, insertion offset: ${delta.insertionOffset}, ime selection: ${delta.selection}"); - - insert( - TextPosition(offset: delta.insertionOffset, affinity: delta.selection.affinity), - delta.textInserted, - ); - } - - void _applyReplacement(TextEditingDeltaReplacement delta) { - editorImeLog.fine("Text replaced: '${delta.textReplaced}'"); - editorImeLog.fine("Replacement text: '${delta.replacementText}'"); - editorImeLog.fine("Replaced range: ${delta.replacedRange}"); - editorImeLog.fine("Selection: ${delta.selection}"); - editorImeLog.fine("Composing: ${delta.composing}"); - editorImeLog.fine('Old text: "${delta.oldText}"'); - - if (delta.replacementText == "\n") { - // On iOS, newlines are reported here and also to performAction(). - // On Android and web, newlines are only reported here. So, on Android and web, - // we forward the newline action to performAction. - if (defaultTargetPlatform == TargetPlatform.android || kIsWeb) { - editorImeLog.fine("Received a newline replacement on Android. Forwarding to newline input action."); - performAction(TextInputAction.newline); - } else { - editorImeLog.fine("Skipping replacement delta because its a newline"); - } - return; - } - - if (delta.replacementText == "\t" && (defaultTargetPlatform == TargetPlatform.iOS)) { - // On iOS, tabs pressed at the the software keyboard are reported here. - commonOps.indentListItem(); - return; - } - - replace(delta.replacedRange, delta.replacementText); - } - - void _applyDeletion(TextEditingDeltaDeletion delta) { - editorImeLog.fine("Delete delta:\n" - "Text deleted: '${delta.textDeleted}'\n" - "Deleted Range: ${delta.deletedRange}\n" - "Selection: ${delta.selection}\n" - "Composing: ${delta.composing}\n" - "Old text: '${delta.oldText}'"); - - delete(delta.deletedRange); - - editorImeLog.fine("Deletion operation complete"); - } - - void _applyNonTextChange(TextEditingDeltaNonTextUpdate delta) { - editorImeLog.fine("Non-text change:"); - // editorImeLog.fine("App-side selection - ${currentTextEditingValue.selection}"); - // editorImeLog.fine("App-side composing - ${currentTextEditingValue.composing}"); - editorImeLog.fine("OS-side selection - ${delta.selection}"); - editorImeLog.fine("OS-side composing - ${delta.composing}"); - - final docSerializer = DocumentImeSerializer( - editor.document, - composer.selection!, - ); - - final docSelection = docSerializer.imeToDocumentSelection(delta.selection); - if (docSelection != null) { - // We got a selection from the platform. - // This could happen in some software keyboards, like GBoard, - // where the user can swipe over the spacebar to change the selection. - composer.selection = docSelection; - } - } - - void insert(TextPosition insertionPosition, String textInserted) { - if (textInserted == "\n") { - // Newlines are handled in performAction() - return; - } - - editorImeLog.fine('Inserting "$textInserted" at position "$insertionPosition"'); - editorImeLog.fine("Serializing document to perform IME operation"); - final docSerializer = DocumentImeSerializer( - editor.document, - composer.selection!, - ); - editorImeLog.fine("Converting IME insertion offset into a DocumentSelection"); - final insertionSelection = docSerializer.imeToDocumentSelection( - TextSelection.fromPosition(insertionPosition), - ); - editorImeLog - .fine("Updating the Document Composer's selection to place caret at insertion offset:\n$insertionSelection"); - final selectionBeforeInsertion = composer.selection; - composer.selection = insertionSelection; - - editorImeLog.fine("Inserting the text at the Document Composer's selection"); - final didInsert = commonOps.insertPlainText(textInserted); - editorImeLog.fine("Insertion successful? $didInsert"); - - if (!didInsert) { - editorImeLog.fine("Failed to insert characters. Restoring previous selection."); - composer.selection = selectionBeforeInsertion; - } - - commonOps.convertParagraphByPatternMatching( - composer.selection!.extent.nodeId, - ); - } - - void replace(TextRange replacedRange, String replacementText) { - final docSerializer = DocumentImeSerializer( - editor.document, - composer.selection!, - ); - - final replacementSelection = docSerializer.imeToDocumentSelection(TextSelection( - baseOffset: replacedRange.start, - // TODO: the delta API is wrong for TextRange.end, it should be exclusive, - // but it's implemented as inclusive. Change this code when Flutter - // fixes the problem. - extentOffset: replacedRange.end, - )); - - if (replacementSelection != null) { - composer.selection = replacementSelection; - } - editorImeLog.fine("Replacing selection: $replacementSelection"); - editorImeLog.fine('With text: "$replacementText"'); - - if (replacementText == "\n") { - performAction(TextInputAction.newline); - return; - } - - commonOps.insertPlainText(replacementText); - - commonOps.convertParagraphByPatternMatching( - composer.selection!.extent.nodeId, - ); - } - - void delete(TextRange deletedRange) { - final rangeToDelete = deletedRange; - final docSerializer = DocumentImeSerializer( - editor.document, - composer.selection!, - ); - final docSelectionToDelete = docSerializer.imeToDocumentSelection(TextSelection( - baseOffset: rangeToDelete.start, - extentOffset: rangeToDelete.end, - )); - editorImeLog.fine("Doc selection to delete: $docSelectionToDelete"); - - if (docSelectionToDelete == null) { - final selectedNodeIndex = editor.document.getNodeIndexById( - composer.selection!.extent.nodeId, - ); - if (selectedNodeIndex > 0) { - // The user is trying to delete upstream at the start of a node. - // This action requires intervention because the IME doesn't know - // that there's more content before this node. Instruct the editor - // to run a delete action upstream, which will take the desired - // "backspace" behavior at the start of this node. - commonOps.deleteUpstream(); - editorImeLog.fine("Deleted upstream. New selection: ${composer.selection}"); - return; - } - } - - editorImeLog.fine("Running selection deletion operation"); - composer.selection = docSelectionToDelete; - commonOps.deleteSelection(); - } - - void performAction(TextInputAction action) { - switch (action) { - case TextInputAction.newline: - if (!composer.selection!.isCollapsed) { - commonOps.deleteSelection(); - } - commonOps.insertBlockLevelNewline(); - break; - case TextInputAction.none: - // no-op - break; - case TextInputAction.done: - case TextInputAction.go: - case TextInputAction.search: - case TextInputAction.send: - case TextInputAction.next: - case TextInputAction.previous: - case TextInputAction.continueAction: - case TextInputAction.join: - case TextInputAction.route: - case TextInputAction.emergencyCall: - case TextInputAction.unspecified: - editorImeLog.warning("User pressed unhandled action button: $action"); - break; - } - } -} - -/// Toolbar that provides document editing capabilities, like converting -/// paragraphs to blockquotes and list items, and inserting horizontal -/// rules. -/// -/// This toolbar is intended to be placed just above the keyboard on a -/// mobile device. -class KeyboardEditingToolbar extends StatelessWidget { - const KeyboardEditingToolbar({ - Key? key, - required this.document, - required this.composer, - required this.commonOps, - this.brightness, - }) : super(key: key); - - final Document document; - final DocumentComposer composer; - final CommonEditorOperations commonOps; - final Brightness? brightness; - - bool get _isBoldActive => _doesSelectionHaveAttributions({boldAttribution}); - void _toggleBold() => _toggleAttributions({boldAttribution}); - - bool get _isItalicsActive => _doesSelectionHaveAttributions({italicsAttribution}); - void _toggleItalics() => _toggleAttributions({italicsAttribution}); - - bool get _isUnderlineActive => _doesSelectionHaveAttributions({underlineAttribution}); - void _toggleUnderline() => _toggleAttributions({underlineAttribution}); - - bool get _isStrikethroughActive => _doesSelectionHaveAttributions({strikethroughAttribution}); - void _toggleStrikethrough() => _toggleAttributions({strikethroughAttribution}); - - bool _doesSelectionHaveAttributions(Set attributions) { - final selection = composer.selection; - if (selection == null) { - return false; - } - - if (selection.isCollapsed) { - return composer.preferences.currentAttributions.containsAll(attributions); - } - - return document.doesSelectedTextContainAttributions(selection, attributions); - } - - void _toggleAttributions(Set attributions) { - final selection = composer.selection; - if (selection == null) { - return; - } - - selection.isCollapsed - ? commonOps.toggleComposerAttributions(attributions) - : commonOps.toggleAttributionsOnSelection(attributions); - } - - void _convertToHeader1() { - final selectedNode = document.getNodeById(composer.selection!.extent.nodeId); - if (selectedNode is! TextNode) { - return; - } - - if (selectedNode is ListItemNode) { - commonOps.convertToParagraph( - newMetadata: { - 'blockType': header1Attribution, - }, - ); - } else { - selectedNode.putMetadataValue('blockType', header1Attribution); - } - } - - void _convertToHeader2() { - final selectedNode = document.getNodeById(composer.selection!.extent.nodeId); - if (selectedNode is! TextNode) { - return; - } - - if (selectedNode is ListItemNode) { - commonOps.convertToParagraph( - newMetadata: { - 'blockType': header2Attribution, - }, - ); - } else { - selectedNode.putMetadataValue('blockType', header2Attribution); - } - } - - void _convertToParagraph() { - commonOps.convertToParagraph(); - } - - void _convertToOrderedListItem() { - final selectedNode = document.getNodeById(composer.selection!.extent.nodeId)! as TextNode; - - commonOps.convertToListItem(ListItemType.ordered, selectedNode.text); - } - - void _convertToUnorderedListItem() { - final selectedNode = document.getNodeById(composer.selection!.extent.nodeId)! as TextNode; - - commonOps.convertToListItem(ListItemType.unordered, selectedNode.text); - } - - void _convertToBlockquote() { - final selectedNode = document.getNodeById(composer.selection!.extent.nodeId)! as TextNode; - - commonOps.convertToBlockquote(selectedNode.text); - } - - void _convertToHr() { - final selectedNode = document.getNodeById(composer.selection!.extent.nodeId)! as TextNode; - - selectedNode.text = AttributedText(text: '--- '); - composer.selection = DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: selectedNode.id, - nodePosition: const TextNodePosition(offset: 4), - ), - ); - commonOps.convertParagraphByPatternMatching(selectedNode.id); - } - - void _closeKeyboard() { - composer.selection = null; - } - - @override - Widget build(BuildContext context) { - final selection = composer.selection; - - if (selection == null) { - return const SizedBox(); - } - - final brightness = this.brightness ?? MediaQuery.of(context).platformBrightness; - - return Theme( - data: Theme.of(context).copyWith( - brightness: brightness, - disabledColor: brightness == Brightness.light ? Colors.black.withOpacity(0.5) : Colors.white.withOpacity(0.5), - ), - child: IconTheme( - data: IconThemeData( - color: brightness == Brightness.light ? Colors.black : Colors.white, - ), - child: Material( - child: Container( - width: double.infinity, - height: 48, - color: brightness == Brightness.light ? const Color(0xFFDDDDDD) : const Color(0xFF222222), - child: Row( - children: [ - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ListenableBuilder( - listenable: composer, - builder: (context, _) { - final selectedNode = document.getNodeById(selection.extent.nodeId); - final isSingleNodeSelected = selection.extent.nodeId == selection.base.nodeId; - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: selectedNode is TextNode ? _toggleBold : null, - icon: const Icon(Icons.format_bold), - color: _isBoldActive ? Theme.of(context).primaryColor : null, - ), - IconButton( - onPressed: selectedNode is TextNode ? _toggleItalics : null, - icon: const Icon(Icons.format_italic), - color: _isItalicsActive ? Theme.of(context).primaryColor : null, - ), - IconButton( - onPressed: selectedNode is TextNode ? _toggleUnderline : null, - icon: const Icon(Icons.format_underline), - color: _isUnderlineActive ? Theme.of(context).primaryColor : null, - ), - IconButton( - onPressed: selectedNode is TextNode ? _toggleStrikethrough : null, - icon: const Icon(Icons.strikethrough_s), - color: _isStrikethroughActive ? Theme.of(context).primaryColor : null, - ), - IconButton( - onPressed: isSingleNodeSelected && - (selectedNode is TextNode && - selectedNode.getMetadataValue('blockType') != header1Attribution) - ? _convertToHeader1 - : null, - icon: const Icon(Icons.title), - ), - IconButton( - onPressed: isSingleNodeSelected && - (selectedNode is TextNode && - selectedNode.getMetadataValue('blockType') != header2Attribution) - ? _convertToHeader2 - : null, - icon: const Icon(Icons.title), - iconSize: 18, - ), - IconButton( - onPressed: isSingleNodeSelected && - ((selectedNode is ParagraphNode && - selectedNode.hasMetadataValue('blockType')) || - (selectedNode is TextNode && selectedNode is! ParagraphNode)) - ? _convertToParagraph - : null, - icon: const Icon(Icons.wrap_text), - ), - IconButton( - onPressed: isSingleNodeSelected && - (selectedNode is TextNode && selectedNode is! ListItemNode || - (selectedNode is ListItemNode && selectedNode.type != ListItemType.ordered)) - ? _convertToOrderedListItem - : null, - icon: const Icon(Icons.looks_one_rounded), - ), - IconButton( - onPressed: isSingleNodeSelected && - (selectedNode is TextNode && selectedNode is! ListItemNode || - (selectedNode is ListItemNode && - selectedNode.type != ListItemType.unordered)) - ? _convertToUnorderedListItem - : null, - icon: const Icon(Icons.list), - ), - IconButton( - onPressed: isSingleNodeSelected && - selectedNode is TextNode && - (selectedNode is! ParagraphNode || - selectedNode.getMetadataValue('blockType') != blockquoteAttribution) - ? _convertToBlockquote - : null, - icon: const Icon(Icons.format_quote), - ), - IconButton( - onPressed: isSingleNodeSelected && - selectedNode is ParagraphNode && - selectedNode.text.text.isEmpty - ? _convertToHr - : null, - icon: const Icon(Icons.horizontal_rule), - ), - ], - ); - }), - ), - ), - Container( - width: 1, - height: 32, - color: const Color(0xFFCCCCCC), - ), - IconButton( - onPressed: _closeKeyboard, - icon: const Icon(Icons.keyboard_hide), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/super_editor/lib/src/default_editor/document_selection_on_focus_mixin.dart b/super_editor/lib/src/default_editor/document_selection_on_focus_mixin.dart index 7b1d0fcaf..9bc3c1e57 100644 --- a/super_editor/lib/src/default_editor/document_selection_on_focus_mixin.dart +++ b/super_editor/lib/src/default_editor/document_selection_on_focus_mixin.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:super_editor/src/infrastructure/flutter_scheduler.dart'; import 'package:super_editor/super_editor.dart'; /// Synchronizes document focus with document selection. @@ -75,31 +76,45 @@ mixin DocumentSelectionOnFocusMixin on State { } void _onFocusChange() { + editorImeLog.finer("[DocumentSelectionOnFocusMixin] - Focus change. Is focused? ${_focusNode?.hasFocus}."); if (!_focusNode!.hasFocus) { + editorImeLog.finer("[DocumentSelectionOnFocusMixin] - Editor doesn't have focus. Ignoring focus change."); _selection?.value = null; return; } - // We move the selection in the next frame, so we don't try to access the - // DocumentLayout before it is available when the editor has autofocus - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - // We only update the selection when it's null - // because, when the user taps at the document the selection is - // already set to the correct position, so we don't override it. - if (mounted && _focusNode!.hasFocus && _selection!.value == null) { - if (_previousSelection != null) { - _selection?.value = _previousSelection; - return; - } + WidgetsBinding.instance.runAsSoonAsPossible(() { + editorImeLog.finer("[DocumentSelectionOnFocusMixin] - Editor received focus. Setting a selection, if needed."); + if (!mounted) { + editorImeLog.finer("[DocumentSelectionOnFocusMixin] - We're no longer mounted. Fizzling."); + return; + } + + if (!_focusNode!.hasFocus || _selection!.value != null) { + editorImeLog.finer( + "[DocumentSelectionOnFocusMixin] - Either we already lost focus (has focus? ${_focusNode!.hasFocus}), or the editor already has a selection (has selection? ${_selection!.value != null}). Fizzling."); + return; + } + + // The editor has focus, but there's no selection. Whenever the editor + // is focused, there needs to be a place for user input to go. Place + // the caret at the end of the document. + if (_previousSelection != null) { + editorImeLog + .finer("[DocumentSelectionOnFocusMixin] - Restoring the previous editor selection: $_previousSelection"); + _selection?.value = _previousSelection; + return; + } - DocumentPosition? position = _getDocumentLayout?.call().findLastSelectablePosition(); - if (position != null) { - _selection?.value = DocumentSelection.collapsed( - position: position, - ); - } + editorImeLog.finer( + "[DocumentSelectionOnFocusMixin] - Placing caret at end of document because we didn't have a previous selection"); + DocumentPosition? position = _getDocumentLayout?.call().findLastSelectablePosition(); + if (position != null) { + _selection?.value = DocumentSelection.collapsed( + position: position, + ); } - }); + }, debugLabel: "Set Document Selection Because Received Focus"); } void _onSelectionChange() { diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index 42e2d7029..aa8b34262 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -23,9 +23,8 @@ import 'attributions.dart'; import 'blockquote.dart'; import 'document_caret_overlay.dart'; import 'document_gestures_mouse.dart'; -import 'document_input_ime.dart'; -import 'document_input_keyboard.dart'; -import 'document_keyboard_actions.dart'; +import 'document_ime/document_input_ime.dart'; +import 'document_hardware_keyboard/document_input_keyboard.dart'; import 'horizontal_rule.dart'; import 'image.dart'; import 'layout_single_column/layout_single_column.dart'; @@ -75,67 +74,6 @@ import 'unknown_component.dart'; /// Document composer is responsible for owning document selection and /// the current text entry mode. class SuperEditor extends StatefulWidget { - @Deprecated("Use unnamed SuperEditor() constructor instead") - SuperEditor.standard({ - Key? key, - this.focusNode, - required this.editor, - this.composer, - this.scrollController, - this.documentLayoutKey, - Stylesheet? stylesheet, - this.customStylePhases = const [], - this.inputSource = TextInputSource.keyboard, - this.gestureMode = DocumentGestureMode.mouse, - this.androidHandleColor, - this.androidToolbarBuilder, - this.iOSHandleColor, - this.iOSToolbarBuilder, - this.createOverlayControlsClipper, - this.debugPaint = const DebugPaintConfig(), - this.autofocus = false, - this.overlayController, - }) : componentBuilders = defaultComponentBuilders, - keyboardActions = defaultKeyboardActions, - softwareKeyboardHandler = null, - stylesheet = stylesheet ?? defaultStylesheet, - selectionStyles = defaultSelectionStyle, - documentOverlayBuilders = [const DefaultCaretOverlayBuilder()], - super(key: key); - - @Deprecated("Use unnamed SuperEditor() constructor instead") - SuperEditor.custom({ - Key? key, - this.focusNode, - required this.editor, - this.composer, - this.scrollController, - this.documentLayoutKey, - Stylesheet? stylesheet, - this.customStylePhases = const [], - List? componentBuilders, - SelectionStyles? selectionStyle, - this.inputSource = TextInputSource.keyboard, - this.gestureMode = DocumentGestureMode.mouse, - List? keyboardActions, - this.softwareKeyboardHandler, - this.androidHandleColor, - this.androidToolbarBuilder, - this.iOSHandleColor, - this.iOSToolbarBuilder, - this.createOverlayControlsClipper, - this.debugPaint = const DebugPaintConfig(), - this.autofocus = false, - this.overlayController, - }) : stylesheet = stylesheet ?? defaultStylesheet, - selectionStyles = selectionStyle ?? defaultSelectionStyle, - keyboardActions = keyboardActions ?? defaultKeyboardActions, - documentOverlayBuilders = [const DefaultCaretOverlayBuilder()], - componentBuilders = componentBuilders != null - ? [...componentBuilders, const UnknownComponentBuilder()] - : [...defaultComponentBuilders, const UnknownComponentBuilder()], - super(key: key); - /// Creates a `Super Editor` with common (but configurable) defaults for /// visual components, text styles, and user interaction. SuperEditor({ @@ -150,9 +88,11 @@ class SuperEditor extends StatefulWidget { List? componentBuilders, SelectionStyles? selectionStyle, this.inputSource, - this.gestureMode, + this.softwareKeyboardController, + this.imePolicies = const SuperEditorImePolicies(), + this.imeConfiguration = const SuperEditorImeConfiguration(), List? keyboardActions, - this.softwareKeyboardHandler, + this.gestureMode, this.androidHandleColor, this.androidToolbarBuilder, this.iOSHandleColor, @@ -220,6 +160,22 @@ class SuperEditor extends StatefulWidget { /// The `SuperEditor` input source, e.g., keyboard or Input Method Engine. final TextInputSource? inputSource; + /// Opens and closes the software keyboard. + /// + /// Typically, this controller should only be used when the keyboard is configured + /// for manual control, e.g., [SuperEditorImePolicies.openKeyboardOnSelectionChange] and + /// [SuperEditorImePolicies.clearSelectionWhenImeDisconnects] are `false`. Otherwise, + /// the automatic behavior might conflict with commands to this controller. + final SoftwareKeyboardController? softwareKeyboardController; + + /// Policies that dictate when and how [SuperEditor] should interact with the + /// platform IME, such as automatically opening the software keyboard when + /// [SuperEditor]'s selection changes. + final SuperEditorImePolicies imePolicies; + + /// Preferences for how the platform IME should look and behave during editing. + final SuperEditorImeConfiguration imeConfiguration; + /// The `SuperEditor` gesture mode, e.g., mouse or touch. final DocumentGestureMode? gestureMode; @@ -268,11 +224,6 @@ class SuperEditor extends StatefulWidget { /// mode. final List keyboardActions; - /// Applies all software keyboard edits to the document. - /// - /// This handler is only used when in [TextInputSource.ime] mode. - final SoftwareKeyboardHandler? softwareKeyboardHandler; - /// Paints some extra visual ornamentation to help with /// debugging. final DebugPaintConfig debugPaint; @@ -302,7 +253,7 @@ class SuperEditorState extends State { @visibleForTesting late EditContext editContext; - late SoftwareKeyboardHandler _softwareKeyboardHandler; + final _floatingCursorController = FloatingCursorController(); @visibleForTesting @@ -323,13 +274,6 @@ class SuperEditorState extends State { _createEditContext(); _createLayoutPresenter(); - - _softwareKeyboardHandler = widget.softwareKeyboardHandler ?? - SoftwareKeyboardHandler( - editor: editContext.editor, - composer: editContext.composer, - commonOps: editContext.commonOps, - ); } @override @@ -341,26 +285,21 @@ class SuperEditorState extends State { _composer = widget.composer ?? DocumentComposer(); _composer.addListener(_updateComposerPreferencesAtSelection); } + if (widget.editor != oldWidget.editor) { // The content displayed in this Editor was switched // out. Remove any content selection from the previous // document. _composer.selection = null; } + if (widget.focusNode != oldWidget.focusNode) { _focusNode = (widget.focusNode ?? FocusNode())..addListener(_onFocusChange); } + if (widget.documentLayoutKey != oldWidget.documentLayoutKey) { _docLayoutKey = widget.documentLayoutKey ?? GlobalKey(); } - if (widget.softwareKeyboardHandler != oldWidget.softwareKeyboardHandler) { - _softwareKeyboardHandler = widget.softwareKeyboardHandler ?? - SoftwareKeyboardHandler( - editor: editContext.editor, - composer: editContext.composer, - commonOps: editContext.commonOps, - ); - } if (widget.editor != oldWidget.editor) { _createEditContext(); @@ -542,7 +481,7 @@ class SuperEditorState extends State { }) { switch (_inputSource) { case TextInputSource.keyboard: - return DocumentKeyboardInteractor( + return SuperEditorHardwareKeyHandler( focusNode: _focusNode, autofocus: widget.autofocus, editContext: editContext, @@ -550,11 +489,13 @@ class SuperEditorState extends State { child: child, ); case TextInputSource.ime: - return DocumentImeInteractor( + return SuperEditorImeInteractor( focusNode: _focusNode, autofocus: widget.autofocus, editContext: editContext, - softwareKeyboardHandler: _softwareKeyboardHandler, + softwareKeyboardController: widget.softwareKeyboardController, + imePolicies: widget.imePolicies, + imeConfiguration: widget.imeConfiguration, hardwareKeyboardActions: widget.keyboardActions, floatingCursorController: _floatingCursorController, child: child, diff --git a/super_editor/lib/src/infrastructure/_logging.dart b/super_editor/lib/src/infrastructure/_logging.dart index 89d5d3d2d..f401c581c 100644 --- a/super_editor/lib/src/infrastructure/_logging.dart +++ b/super_editor/lib/src/infrastructure/_logging.dart @@ -32,6 +32,7 @@ class LogNames { static const iosTextField = 'textfield.ios'; static const infrastructure = 'infrastructure'; + static const scheduler = 'infrastructure.scheduler'; static const attributions = 'infrastructure.attributions'; } @@ -63,6 +64,7 @@ final iosTextFieldLog = logging.Logger(LogNames.iosTextField); final docGesturesLog = logging.Logger(LogNames.documentGestures); final infrastructureLog = logging.Logger(LogNames.infrastructure); +final schedulerLog = logging.Logger(LogNames.scheduler); final attributionsLog = logging.Logger(LogNames.attributions); final _activeLoggers = {}; diff --git a/super_editor/lib/src/infrastructure/flutter_scheduler.dart b/super_editor/lib/src/infrastructure/flutter_scheduler.dart new file mode 100644 index 000000000..26ce34576 --- /dev/null +++ b/super_editor/lib/src/infrastructure/flutter_scheduler.dart @@ -0,0 +1,37 @@ +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; + +extension Scheduler on WidgetsBinding { + /// Runs the given [action] as soon as possible, given the status of Flutter's pipeline. + /// + /// Flutter throws an error if a widget ever calls `setState()` while widget building + /// is already underway. This can happen when an [action] sends signals that might cause + /// a widget to call `setState()`. For example, setting a value on a `ValueNotifier` + /// might trigger a `ListenableBuilder` to rebuild somewhere else in the tree. As a + /// result, if code sets the value on a `ValueNotifier` during Flutter's build phase, + /// Flutter will crash. This extension helps avoid such a crash. + /// + /// When [runAsSoonAsPossible] is called *outside* of a Flutter build phase, [action] + /// is executed immediately. + /// + /// When [runAsSoonAsPossible] is called *during* a Flutter build phase, [action] is + /// executed at the end of the current frame with [addPostFrameCallback]. + void runAsSoonAsPossible(VoidCallback action, {String debugLabel = "anonymous action"}) { + schedulerLog.info("Running action as soon as possible: '$debugLabel'."); + if (schedulerPhase == SchedulerPhase.persistentCallbacks) { + // The Flutter pipeline is in the middle of a build phase. Schedule the desired + // action for the end of the current frame. + schedulerLog.info("Scheduling another frame to run '$debugLabel' because Flutter is building widgets right now."); + addPostFrameCallback((timeStamp) { + schedulerLog.info("Flutter is done building widgets. Running '$debugLabel' at the end of the frame."); + action(); + }); + } else { + // The Flutter pipeline isn't building widgets right now. Execute the action + // immediately. + schedulerLog.info("Flutter isn't building widgets right now. Running '$debugLabel' immediately."); + action(); + } + } +} diff --git a/super_editor/lib/src/infrastructure/ime_input_owner.dart b/super_editor/lib/src/infrastructure/ime_input_owner.dart index 3aaa8bafc..4028bbe88 100644 --- a/super_editor/lib/src/infrastructure/ime_input_owner.dart +++ b/super_editor/lib/src/infrastructure/ime_input_owner.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; /// A widget that internally accepts IME input. @@ -9,5 +10,6 @@ import 'package:flutter/services.dart'; /// This interface hides those details and ensures that the [DeltaTextInputClient] is available, by contract, /// from whichever class implements this interface. abstract class ImeInputOwner { + @visibleForTesting DeltaTextInputClient get imeClient; } diff --git a/super_editor/lib/src/test/super_editor_test/supereditor_inspector.dart b/super_editor/lib/src/test/super_editor_test/supereditor_inspector.dart index 87a830ae1..7d04287ce 100644 --- a/super_editor/lib/src/test/super_editor_test/supereditor_inspector.dart +++ b/super_editor/lib/src/test/super_editor_test/supereditor_inspector.dart @@ -30,6 +30,16 @@ class SuperEditorInspector { return superEditor.editContext.editor.document; } + /// Returns the [DocumentComposer] within the [SuperEditor] matched by [finder], + /// or the singular [SuperEditor] in the widget tree, if [finder] is `null`. + /// + /// {@macro supereditor_finder} + static DocumentComposer? findComposer([Finder? finder]) { + final element = (finder ?? find.byType(SuperEditor)).evaluate().single as StatefulElement; + final superEditor = element.state as SuperEditorState; + return superEditor.editContext.composer; + } + /// Returns the current [DocumentSelection] for the [SuperEditor] matched by /// [finder], or the singular [SuperEditor] in the widget tree, if [finder] /// is `null`. diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart index 4c4710c43..a9ccfcc41 100644 --- a/super_editor/lib/super_editor.dart +++ b/super_editor/lib/super_editor.dart @@ -23,9 +23,8 @@ export 'src/default_editor/document_caret_overlay.dart'; export 'src/infrastructure/document_gestures.dart'; export 'src/default_editor/document_gestures_mouse.dart'; export 'src/default_editor/document_gestures_touch.dart'; -export 'src/default_editor/document_input_ime.dart'; -export 'src/default_editor/document_input_keyboard.dart'; -export 'src/default_editor/document_keyboard_actions.dart'; +export 'src/default_editor/document_ime/document_input_ime.dart'; +export 'src/default_editor/document_hardware_keyboard/document_input_keyboard.dart'; export 'src/default_editor/horizontal_rule.dart'; export 'src/default_editor/image.dart'; export 'src/default_editor/layout_single_column/layout_single_column.dart'; diff --git a/super_editor/test/src/default_editor/document_input_ime_test.dart b/super_editor/test/src/default_editor/document_input_ime_test.dart index 3650ca130..69db2b083 100644 --- a/super_editor/test/src/default_editor/document_input_ime_test.dart +++ b/super_editor/test/src/default_editor/document_input_ime_test.dart @@ -6,9 +6,9 @@ import 'package:super_editor/super_editor.dart'; import 'package:super_editor/super_editor_test.dart'; import '../../super_editor/document_test_tools.dart'; +import '../../super_editor/test_documents.dart'; import '../../test_tools.dart'; import '../_document_test_tools.dart'; -import '../../super_editor/test_documents.dart'; void main() { group('IME input', () { @@ -41,9 +41,10 @@ void main() { composer: composer, documentLayoutResolver: () => FakeDocumentLayout(), ); - final softwareKeyboardHandler = SoftwareKeyboardHandler( + final softwareKeyboardHandler = TextDeltasDocumentEditor( editor: editor, - composer: composer, + selection: composer.selectionNotifier, + composingRegion: composer.composingRegion, commonOps: commonOps, ); @@ -79,6 +80,8 @@ void main() { }); testWidgets('can type compound character in an empty paragraph', (tester) async { + final document = twoParagraphEmptyDoc(); + // Inserting special characters, or compound characters, like ü, requires // multiple key presses, which are combined by the IME, based on the // composing region. @@ -95,7 +98,7 @@ void main() { final editContext = createEditContext( // Use a two-paragraph document so that the selection in the 2nd // paragraph sends a hidden placeholder to the IME for backspace. - document: twoParagraphEmptyDoc(), + document: document, documentComposer: DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition( @@ -128,8 +131,8 @@ void main() { // // We have to use implementation details to send the simulated IME deltas // because Flutter doesn't have any testing tools for IME deltas. - final imeInteractor = find.byType(DocumentImeInteractor).evaluate().first; - final deltaClient = (imeInteractor as StatefulElement).state as DeltaTextInputClient; + final imeInteractor = find.byType(SuperEditorImeInteractor).evaluate().first; + final deltaClient = ((imeInteractor as StatefulElement).state as ImeInputOwner).imeClient; // Ensure that the delta client starts with the expected invisible placeholder // characters. @@ -147,6 +150,7 @@ void main() { composing: TextRange(start: 2, end: 3), ), ]); + await tester.pumpAndSettle(); // Ensure that the empty paragraph now reads "¨". expect((editContext.editor.document.nodes[1] as ParagraphNode).text.text, "¨"); @@ -167,6 +171,13 @@ void main() { ), ]); + // We need a final pump and settle to propagate selection changes while we still + // have access to the document layout. Otherwise, the selection change callback + // will execute after the end of this test, and the layout isn't available any + // more. + // TODO: trace the selection change call stack and adjust it so that we don't need this pump + await tester.pumpAndSettle(); + // Ensure that the empty paragraph now reads "ü". expect((editContext.editor.document.nodes[1] as ParagraphNode).text.text, "ü"); }); @@ -191,6 +202,7 @@ void main() { nodePosition: TextNodePosition(offset: 19), ), ), + null, ).toTextEditingValue(), expectedTextWithSelection: "This is a |paragraph| of text.", ); @@ -216,6 +228,7 @@ void main() { nodePosition: TextNodePosition(offset: 28), ), ), + null, ).toTextEditingValue(), expectedTextWithSelection: "This is the |first paragraph of text.\nThis is the second paragraph| of text.", ); @@ -241,6 +254,7 @@ void main() { nodePosition: TextNodePosition(offset: 19), ), ), + null, ).toTextEditingValue(), expectedTextWithSelection: "This is a |paragraph of text.\n~\nThis is a paragraph| of text.", ); @@ -266,6 +280,7 @@ void main() { nodePosition: UpstreamDownstreamNodePosition.downstream(), ), ), + null, ).toTextEditingValue(), expectedTextWithSelection: "|~\nThis is the first paragraph of text.\n~|", ); diff --git a/super_editor/test/src/default_editor/document_keyboard_actions_test.dart b/super_editor/test/src/default_editor/document_keyboard_actions_test.dart index 41ee18916..52683d1ac 100644 --- a/super_editor/test/src/default_editor/document_keyboard_actions_test.dart +++ b/super_editor/test/src/default_editor/document_keyboard_actions_test.dart @@ -665,15 +665,17 @@ void main() { group('key pressed with selection', () { testOnMac('deletes selection if backspace is pressed', () { + final document = MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText(text: 'Text with [DELETEME] selection'), + ), + ], + ); + final editContext = createEditContext( - document: MutableDocument( - nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'Text with [DELETEME] selection'), - ), - ], - ), + document: document, documentComposer: DocumentComposer( initialSelection: const DocumentSelection( base: DocumentPosition( @@ -717,15 +719,16 @@ void main() { }); testOnMac('deletes selection if delete is pressed', () { + final document = MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText(text: 'Text with [DELETEME] selection'), + ), + ], + ); final editContext = createEditContext( - document: MutableDocument( - nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'Text with [DELETEME] selection'), - ), - ], - ), + document: document, documentComposer: DocumentComposer( initialSelection: const DocumentSelection( base: DocumentPosition( @@ -769,15 +772,16 @@ void main() { }); testOnMac('deletes selection when character key is pressed', () { + final document = MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText(text: 'Text with [DELETEME] selection'), + ), + ], + ); final editContext = createEditContext( - document: MutableDocument( - nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'Text with [DELETEME] selection'), - ), - ], - ), + document: document, documentComposer: DocumentComposer( initialSelection: const DocumentSelection( base: DocumentPosition( @@ -824,15 +828,16 @@ void main() { }); testOnMac('collapses selection if escape is pressed', () { + final document = MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText(text: 'Text with [SELECTME] selection'), + ), + ], + ); final editContext = createEditContext( - document: MutableDocument( - nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'Text with [SELECTME] selection'), - ), - ], - ), + document: document, documentComposer: DocumentComposer( initialSelection: const DocumentSelection( base: DocumentPosition( @@ -921,15 +926,16 @@ void main() { }); testOnMac('does nothing when escape is pressed if the selection is collapsed', () { + final document = MutableDocument( + nodes: [ + ParagraphNode( + id: '1', + text: AttributedText(text: 'This is some text'), + ), + ], + ); final editContext = createEditContext( - document: MutableDocument( - nodes: [ - ParagraphNode( - id: '1', - text: AttributedText(text: 'This is some text'), - ), - ], - ), + document: document, documentComposer: DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition( @@ -983,6 +989,7 @@ Future _pumpCaretMovementTestSetup( WidgetTester tester, { required int textOffsetInFirstNode, }) async { + final document = singleParagraphDoc(); final composer = DocumentComposer( initialSelection: DocumentSelection.collapsed( position: DocumentPosition( @@ -992,7 +999,7 @@ Future _pumpCaretMovementTestSetup( ), ); final editContext = createEditContext( - document: singleParagraphDoc(), + document: document, documentComposer: composer, ); diff --git a/super_editor/test/src/default_editor/list_items_test.dart b/super_editor/test/src/default_editor/list_items_test.dart index 9aaaa6676..8181fe5e3 100644 --- a/super_editor/test/src/default_editor/list_items_test.dart +++ b/super_editor/test/src/default_editor/list_items_test.dart @@ -429,15 +429,17 @@ void main() { } EditContext _createEditContextWithParagraph() { + final document = MutableDocument( + nodes: [ + ParagraphNode( + id: 'paragraph', + text: AttributedText(text: ''), + ), + ], + ); + return createEditContext( - document: MutableDocument( - nodes: [ - ParagraphNode( - id: 'paragraph', - text: AttributedText(text: ''), - ), - ], - ), + document: document, documentComposer: DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition( diff --git a/super_editor/test/src/default_editor/upstream_downstream_selection_test.dart b/super_editor/test/src/default_editor/upstream_downstream_selection_test.dart index 0bf2ffaed..88f715123 100644 --- a/super_editor/test/src/default_editor/upstream_downstream_selection_test.dart +++ b/super_editor/test/src/default_editor/upstream_downstream_selection_test.dart @@ -12,12 +12,13 @@ void main() { group("Upstream-downstream block", () { group("move caret up", () { testWidgets("up arrow moves text caret to upstream edge of block from node below", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "3", nodePosition: TextNodePosition(offset: 0)), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.pump(); @@ -28,13 +29,14 @@ void main() { }); testWidgets("up arrow moves text caret to downstream edge of block from node below", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( // The caret needs to be on the 1st line, in the right half of the line. position: DocumentPosition(nodeId: "3", nodePosition: TextNodePosition(offset: 33)), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.pump(); @@ -45,12 +47,13 @@ void main() { }); testWidgets("up arrow moves caret from upstream edge to text node above", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.pump(); @@ -61,12 +64,13 @@ void main() { }); testWidgets("up arrow moves caret from downstream edge to text node above", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.pump(); @@ -77,12 +81,13 @@ void main() { }); testWidgets("left arrow moves caret to text node above", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); await tester.pump(); @@ -94,12 +99,13 @@ void main() { }); testWidgets("right arrow moves caret to text node below", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pump(); @@ -111,12 +117,13 @@ void main() { }); testWidgets("delete moves caret down to block from node above", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 37)), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.delete); await tester.pump(); @@ -127,12 +134,13 @@ void main() { }); testWidgets("backspace moves caret up to block from node below", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "3", nodePosition: TextNodePosition(offset: 0)), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.backspace); await tester.pump(); @@ -145,13 +153,14 @@ void main() { group("move caret down", () { testWidgets("text caret moves to upstream edge of block from node above", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( // Caret needs to sit on the left half of the last line in the paragraph. position: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); @@ -162,13 +171,14 @@ void main() { }); testWidgets("text caret moves to downstream edge of block from node above", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( // Caret needs to sit in right half of the last line in the paragraph. position: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 37)), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); @@ -179,12 +189,13 @@ void main() { }); testWidgets("upstream block caret moves to text node below", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); @@ -195,12 +206,13 @@ void main() { }); testWidgets("downstream block caret moves to text node below", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); @@ -211,12 +223,13 @@ void main() { }); testWidgets("right arrow moves caret to text node below", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pump(); @@ -230,12 +243,13 @@ void main() { group("move caret horizontally", () { testWidgets("right arrow moves caret downstream", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pump(); @@ -246,12 +260,13 @@ void main() { }); testWidgets("left arrow moves caret upstream", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); await tester.pump(); @@ -264,12 +279,13 @@ void main() { group("deletion", () { testWidgets("backspace moves caret to node above when caret is on upstream edge", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.backspace); await tester.pump(); @@ -280,12 +296,13 @@ void main() { }); testWidgets("backspace removes block node when caret is on downstream edge", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.backspace); await tester.pump(); @@ -296,12 +313,13 @@ void main() { }); testWidgets("delete moves caret to node below when caret is at downstream edge", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.delete); await tester.pump(); @@ -312,12 +330,13 @@ void main() { }); testWidgets("delete removes block node when caret is at upstream edge", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection.collapsed( position: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.delete); await tester.pump(); @@ -328,13 +347,14 @@ void main() { }); testWidgets("backspace removes block node when selected", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection( base: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), extent: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.backspace); await tester.pump(); @@ -345,13 +365,14 @@ void main() { }); testWidgets("delete removes block node when selected", (tester) async { + final document = paragraphThenHrThenParagraphDoc(); final composer = DocumentComposer( initialSelection: const DocumentSelection( base: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.upstream()), extent: DocumentPosition(nodeId: "2", nodePosition: UpstreamDownstreamNodePosition.downstream()), ), ); - await tester.pumpWidget(_buildHardwareKeyboardEditor(paragraphThenHrThenParagraphDoc(), composer)); + await tester.pumpWidget(_buildHardwareKeyboardEditor(document, composer)); await tester.sendKeyEvent(LogicalKeyboardKey.delete); await tester.pump(); diff --git a/super_editor/test/super_editor/document_test_tools.dart b/super_editor/test/super_editor/document_test_tools.dart index dadb6eb2a..47e4611a6 100644 --- a/super_editor/test/super_editor/document_test_tools.dart +++ b/super_editor/test/super_editor/document_test_tools.dart @@ -92,6 +92,9 @@ class TestDocumentConfigurator { final TestDocumentContext? _existingContext; DocumentGestureMode? _gestureMode; TextInputSource? _inputSource; + SoftwareKeyboardController? _softwareKeyboardController; + bool _openKeyboardOnSelectionChange = true; + bool _clearSelectionWhenImeDisconnects = true; ThemeData? _appTheme; Stylesheet? _stylesheet; final _addedComponents = []; @@ -137,6 +140,26 @@ class TestDocumentConfigurator { return this; } + /// Configures the [SuperEditor]'s [SoftwareKeyboardController]. + TestDocumentConfigurator withSoftwareKeyboardController(SoftwareKeyboardController controller) { + _softwareKeyboardController = controller; + return this; + } + + /// Configures the [SuperEditor] to automatically open the software keyboard when + /// the selection changes, or not. + TestDocumentConfigurator withOpenKeyboardOnSelectionChange(bool openKeyboardOnSelectionChange) { + _openKeyboardOnSelectionChange = openKeyboardOnSelectionChange; + return this; + } + + /// Configures the [SuperEditor] to automatically clear the document selection when + /// the connection to the platform IME is closed, or not. + TestDocumentConfigurator withClearSelectionWhenImeDisconnects(bool clearSelectionWhenImeDisconnects) { + _clearSelectionWhenImeDisconnects = clearSelectionWhenImeDisconnects; + return this; + } + /// Configures the [SuperEditor] to use the given [gestureMode]. TestDocumentConfigurator withGestureMode(DocumentGestureMode gestureMode) { _gestureMode = gestureMode; @@ -220,66 +243,99 @@ class TestDocumentConfigurator { /// Pumps a [SuperEditor] widget tree with the desired configuration, and returns /// a [TestDocumentContext], which includes the artifacts connected to the widget /// tree, e.g., the [DocumentEditor], [DocumentComposer], etc. + /// + /// If you need access to the pumped [Widget], use [build] instead of this method, + /// and then call [WidgetTester.pump] with the returned [Widget]. Future pump() async { - assert(_document != null || _existingContext != null); + final testDocumentContext = _createTestDocumentContext(); + await _widgetTester.pumpWidget( + _build(testDocumentContext).widget, + ); + return testDocumentContext; + } - late TestDocumentContext testDocumentContext; - if (_document != null) { - final layoutKey = GlobalKey(); - final focusNode = _focusNode ?? FocusNode(); - final editor = DocumentEditor(document: _document!); - final composer = DocumentComposer(initialSelection: _selection); - // ignore: prefer_function_declarations_over_variables - final layoutResolver = () => layoutKey.currentState as DocumentLayout; - final commonOps = CommonEditorOperations( - editor: editor, - documentLayoutResolver: layoutResolver, - composer: composer, - ); - final editContext = EditContext( - editor: editor, - getDocumentLayout: layoutResolver, - composer: composer, - commonOps: commonOps, - ); + /// Builds a Super Editor experience based on chosen configurations and + /// returns a [TestDocumentContext] and the associated [Widget], which + /// presents the Super Editor UI. + /// + /// The returned [Widget] includes more than just a [SuperEditor] widget. + /// It includes everything needed to pump a full UI in a widget test. + /// + /// If you want to immediately pump this UI into a [WidgetTester], use + /// [pump], which does that for you. + ConfiguredSuperEditorWidget build() { + return _build(); + } - testDocumentContext = TestDocumentContext._( - focusNode: focusNode, - layoutKey: layoutKey, - editContext: editContext, - ); - } else { - testDocumentContext = _existingContext!; + /// Builds a [SuperEditor] widget tree based on the configuration in this + /// class and the (optional) [TestDocumentContext]. + /// + /// If no [TestDocumentContext] is provided, one will be created based on the current + /// configuration of this class. + ConfiguredSuperEditorWidget _build([TestDocumentContext? testDocumentContext]) { + final context = testDocumentContext ?? _createTestDocumentContext(); + final superEditor = _buildConstrainedContent( + _buildSuperEditor(context), + ); + + return ConfiguredSuperEditorWidget( + context, + _buildWidgetTree(superEditor), + ); + } + + /// Creates a [TestDocumentContext] based on the configurations in this class. + /// + /// A [TestDocumentContext] is useful as a return value for clients, so that + /// those clients can access important pieces within a [SuperEditor] widget. + TestDocumentContext _createTestDocumentContext() { + assert(_document != null || _existingContext != null); + + if (_document == null) { + return _existingContext!; } - final superEditor = _buildContent( - SuperEditor( - documentLayoutKey: testDocumentContext.layoutKey, - editor: testDocumentContext.editContext.editor, - composer: testDocumentContext.editContext.composer, - focusNode: testDocumentContext.focusNode, - inputSource: _inputSource, - gestureMode: _gestureMode, - androidToolbarBuilder: _androidToolbarBuilder, - iOSToolbarBuilder: _iOSToolbarBuilder, - stylesheet: _stylesheet, - componentBuilders: [ - ..._addedComponents, - ...(_componentBuilders ?? defaultComponentBuilders), - ], - autofocus: _autoFocus, - scrollController: _scrollController, - ), + final layoutKey = GlobalKey(); + final focusNode = _focusNode ?? FocusNode(); + final editor = DocumentEditor(document: _document!); + final composer = DocumentComposer(initialSelection: _selection); + // ignore: prefer_function_declarations_over_variables + final layoutResolver = () => layoutKey.currentState as DocumentLayout; + final commonOps = CommonEditorOperations( + editor: editor, + documentLayoutResolver: layoutResolver, + composer: composer, + ); + final editContext = EditContext( + editor: editor, + getDocumentLayout: layoutResolver, + composer: composer, + commonOps: commonOps, ); - await _widgetTester.pumpWidget( - _buildWidgetTree(superEditor), + return TestDocumentContext._( + focusNode: focusNode, + layoutKey: layoutKey, + editContext: editContext, ); + } - return testDocumentContext; + /// Builds a complete screen experience, which includes the given [superEditor]. + Widget _buildWidgetTree(Widget superEditor) { + if (_widgetTreeBuilder != null) { + return _widgetTreeBuilder!(superEditor); + } + return MaterialApp( + theme: _appTheme, + home: Scaffold( + body: superEditor, + ), + ); } - Widget _buildContent(Widget superEditor) { + /// Constrains the width and height of the given [superEditor], based on configurations + /// in this class. + Widget _buildConstrainedContent(Widget superEditor) { if (_editorSize != null) { return ConstrainedBox( constraints: BoxConstraints( @@ -292,15 +348,30 @@ class TestDocumentConfigurator { return superEditor; } - Widget _buildWidgetTree(Widget superEditor) { - if (_widgetTreeBuilder != null) { - return _widgetTreeBuilder!(superEditor); - } - return MaterialApp( - theme: _appTheme, - home: Scaffold( - body: superEditor, + /// Builds a [SuperEditor] widget based on the configuration of the given + /// [testDocumentContext], as well as other configurations in this class. + Widget _buildSuperEditor(TestDocumentContext testDocumentContext) { + return SuperEditor( + documentLayoutKey: testDocumentContext.layoutKey, + editor: testDocumentContext.editContext.editor, + composer: testDocumentContext.editContext.composer, + focusNode: testDocumentContext.focusNode, + inputSource: _inputSource, + softwareKeyboardController: _softwareKeyboardController, + imePolicies: SuperEditorImePolicies( + openKeyboardOnSelectionChange: _openKeyboardOnSelectionChange, + clearSelectionWhenImeDisconnects: _clearSelectionWhenImeDisconnects, ), + gestureMode: _gestureMode, + androidToolbarBuilder: _androidToolbarBuilder, + iOSToolbarBuilder: _iOSToolbarBuilder, + stylesheet: _stylesheet, + componentBuilders: [ + ..._addedComponents, + ...(_componentBuilders ?? defaultComponentBuilders), + ], + autofocus: _autoFocus, + scrollController: _scrollController, ); } } @@ -320,6 +391,13 @@ class TestDocumentContext { final EditContext editContext; } +class ConfiguredSuperEditorWidget { + const ConfiguredSuperEditorWidget(this.context, this.widget); + + final TestDocumentContext context; + final Widget widget; +} + Matcher equalsMarkdown(String markdown) => DocumentEqualsMarkdownMatcher(markdown); class DocumentEqualsMarkdownMatcher extends Matcher { diff --git a/super_editor/test/super_editor/supereditor_keyboard_test.dart b/super_editor/test/super_editor/supereditor_keyboard_test.dart index 026de7a2d..2f9811501 100644 --- a/super_editor/test/super_editor/supereditor_keyboard_test.dart +++ b/super_editor/test/super_editor/supereditor_keyboard_test.dart @@ -282,7 +282,350 @@ void main() { }); group('SuperEditor software keyboard', () { - testWidgetsOnIos('pressing tab indent list', (tester) async { + group('in automatic control mode', () { + testWidgetsOnAndroid('clears selection when it closes', (tester) async { + final keyboardController = SoftwareKeyboardController(); + final testContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withSoftwareKeyboardController(keyboardController) + .withOpenKeyboardOnSelectionChange(true) + .withClearSelectionWhenImeDisconnects(true) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + resizeToAvoidBottomInset: false, + body: superEditor, + ), + ), + ) + .pump(); + + // Place the caret in Super Editor to open the IME. + final nodeId = testContext.editContext.editor.document.nodes.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Ensure that the document has a selection + final selectionBefore = SuperEditorInspector.findDocumentSelection(); + expect(selectionBefore, isNotNull); + expect(selectionBefore!.isCollapsed, isTrue); + expect(selectionBefore.extent.nodeId, nodeId); + + // Ensure the IME is open + expect(keyboardController.isConnectedToIme, isTrue); + + // Close the IME + keyboardController.close(); + await tester.pumpAndSettle(); + + // Ensure the IME is closed + expect(keyboardController.isConnectedToIme, isFalse); + + // Ensure the document selection is gone + expect(SuperEditorInspector.findDocumentSelection(), null); + }); + + testWidgetsOnAndroid('re-opens when selection changes', (tester) async { + final keyboardController = SoftwareKeyboardController(); + final testContext = await tester // + .createDocument() + .withSingleParagraph() + .withSoftwareKeyboardController(keyboardController) + .withOpenKeyboardOnSelectionChange(true) + .withClearSelectionWhenImeDisconnects(true) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + resizeToAvoidBottomInset: false, + body: superEditor, + ), + ), + ) + .pump(); + + // Place the caret in Super Editor. + final nodeId = testContext.editContext.editor.document.nodes.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Ensure that the document has a selection + final selectionBefore = SuperEditorInspector.findDocumentSelection(); + expect(selectionBefore, isNotNull); + expect(selectionBefore!.isCollapsed, isTrue); + expect(selectionBefore.extent.nodeId, nodeId); + + // Close the IME + keyboardController.close(); + await tester.pumpAndSettle(); + // Ensure the IME is closed + expect(keyboardController.isConnectedToIme, isFalse); + + // Move the caret somewhere else. + await tester.placeCaretInParagraph(nodeId, 5); + // Ensure the selection changed. + expect(SuperEditorInspector.findDocumentSelection(), isNot(selectionBefore)); + // Ensure the keyboard re-opened. + expect(keyboardController.isConnectedToIme, isTrue); + + // Close the IME + keyboardController.close(); + await tester.pumpAndSettle(); + // Ensure the IME is closed + expect(keyboardController.isConnectedToIme, isFalse); + + // Select a word + await tester.doubleTapInParagraph(nodeId, 10); + // Ensure the keyboard re-opened. + expect(keyboardController.isConnectedToIme, isTrue); + + // Close the IME + keyboardController.close(); + await tester.pumpAndSettle(); + // Ensure the IME is closed + expect(keyboardController.isConnectedToIme, isFalse); + + // Select a paragraph + await tester.tripleTapInParagraph(nodeId, 15); + // Ensure the keyboard re-opened. + expect(keyboardController.isConnectedToIme, isTrue); + }); + }); + + group('in manual control mode', () { + testWidgetsOnAndroid('leaves selection active when it closes', (tester) async { + final keyboardController = SoftwareKeyboardController(); + final testContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .withSoftwareKeyboardController(keyboardController) + .withOpenKeyboardOnSelectionChange(false) + .withClearSelectionWhenImeDisconnects(false) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + resizeToAvoidBottomInset: false, + body: superEditor, + ), + ), + ) + .pump(); + + // Place the caret in Super Editor to open the IME. + final nodeId = testContext.editContext.editor.document.nodes.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Ensure that the document has a selection + final selectionBefore = SuperEditorInspector.findDocumentSelection(); + expect(selectionBefore, isNotNull); + expect(selectionBefore!.isCollapsed, isTrue); + expect(selectionBefore.extent.nodeId, nodeId); + + // Open the keyboard + keyboardController.open(); + await tester.pump(); + + // Ensure the IME is open + expect(keyboardController.isConnectedToIme, isTrue); + + // Close the IME + keyboardController.close(); + await tester.pumpAndSettle(); + + // Ensure the IME is closed + expect(keyboardController.isConnectedToIme, isFalse); + + // Ensure the document selection hasn't changed + expect(SuperEditorInspector.findDocumentSelection(), selectionBefore); + }); + + testWidgetsOnAndroid('stays closed when changing selection', (tester) async { + final keyboardController = SoftwareKeyboardController(); + final testContext = await tester // + .createDocument() + .withSingleParagraph() + .withSoftwareKeyboardController(keyboardController) + .withOpenKeyboardOnSelectionChange(false) + .withClearSelectionWhenImeDisconnects(false) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + resizeToAvoidBottomInset: false, + body: superEditor, + ), + ), + ) + .pump(); + + // Place the caret in Super Editor. + final nodeId = testContext.editContext.editor.document.nodes.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Ensure that the document has a selection + final selectionBefore = SuperEditorInspector.findDocumentSelection(); + expect(selectionBefore, isNotNull); + expect(selectionBefore!.isCollapsed, isTrue); + expect(selectionBefore.extent.nodeId, nodeId); + + // Open the keyboard + keyboardController.open(); + await tester.pump(); + + // Ensure the IME is open + expect(keyboardController.isConnectedToIme, isTrue); + + // Close the IME + keyboardController.close(); + await tester.pumpAndSettle(); + + // Ensure the IME is closed + expect(keyboardController.isConnectedToIme, isFalse); + + // Move the caret somewhere else. + await tester.placeCaretInParagraph(nodeId, 5); + // Ensure the selection changed. + expect(SuperEditorInspector.findDocumentSelection()!.extent, isNot(selectionBefore.extent)); + // Ensure the keyboard is still closed. + expect(keyboardController.isConnectedToIme, isFalse); + + // Select a word + await tester.doubleTapInParagraph(nodeId, 10); + // Ensure the keyboard is still closed. + expect(keyboardController.isConnectedToIme, isFalse); + + // Select a paragraph + await tester.tripleTapInParagraph(nodeId, 15); + // Ensure the keyboard is still closed. + expect(keyboardController.isConnectedToIme, isFalse); + }); + + testWidgetsOnAndroid('opens when requested after previously closing', (tester) async { + final keyboardController = SoftwareKeyboardController(); + final testContext = await tester // + .createDocument() + .withSingleParagraph() + .withSoftwareKeyboardController(keyboardController) + .withOpenKeyboardOnSelectionChange(false) + .withClearSelectionWhenImeDisconnects(false) + .withCustomWidgetTreeBuilder( + (superEditor) => MaterialApp( + home: Scaffold( + resizeToAvoidBottomInset: false, + body: superEditor, + ), + ), + ) + .pump(); + + // Place the caret in Super Editor. + final nodeId = testContext.editContext.editor.document.nodes.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Ensure that the document has a selection + final selectionBefore = SuperEditorInspector.findDocumentSelection(); + expect(selectionBefore, isNotNull); + expect(selectionBefore!.isCollapsed, isTrue); + expect(selectionBefore.extent.nodeId, nodeId); + + // Open the keyboard + keyboardController.open(); + await tester.pump(); + + // Ensure the IME is open + expect(keyboardController.isConnectedToIme, isTrue); + + // Close the IME + keyboardController.close(); + await tester.pumpAndSettle(); + + // Ensure the IME is closed + expect(keyboardController.isConnectedToIme, isFalse); + + // Re-open the IME + keyboardController.open(); + await tester.pumpAndSettle(); + + // Ensure the IME is re-opened + expect(keyboardController.isConnectedToIme, isTrue); + + // Ensure the selection is unchanged. + expect(SuperEditorInspector.findDocumentSelection(), selectionBefore); + }); + + testWidgetsOnAndroid('closes when requested before navigation', (tester) async { + final keyboardController = SoftwareKeyboardController(); + final navigationKey = GlobalKey(); + final firstPageKey = GlobalKey(); + + // Display a page without SuperEditor. We'll pop() back to this page, later. + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigationKey, + home: Scaffold( + key: firstPageKey, + body: const Center( + child: Text("Starting Page"), + ), + ), + ), + ); + expect(find.byKey(firstPageKey), findsOneWidget); + + // Push a page with SuperEditor. + final superEditorAndContext = tester // + .createDocument() + .withSingleParagraph() + .withSoftwareKeyboardController(keyboardController) + .withOpenKeyboardOnSelectionChange(false) + .withClearSelectionWhenImeDisconnects(false) + .withCustomWidgetTreeBuilder( + (superEditor) => _CloseKeyboardOnDispose( + keyboardController: keyboardController, + child: Scaffold( + resizeToAvoidBottomInset: false, + body: superEditor, + ), + ), + ) + .build(); + navigationKey.currentState!.push(MaterialPageRoute(builder: (context) { + return superEditorAndContext.widget; + })); + await tester.pumpAndSettle(); // navigation transition + + // Ensure the first page is no longer visible. + expect(find.byKey(firstPageKey), findsNothing); + + // Place the caret in Super Editor. + final nodeId = superEditorAndContext.context.editContext.editor.document.nodes.first.id; + await tester.placeCaretInParagraph(nodeId, 0); + + // Ensure that the document has a selection + final selectionBefore = SuperEditorInspector.findDocumentSelection(); + expect(selectionBefore, isNotNull); + expect(selectionBefore!.isCollapsed, isTrue); + expect(selectionBefore.extent.nodeId, nodeId); + + // Open the keyboard + keyboardController.open(); + await tester.pump(); + + // Ensure the IME is open + expect(keyboardController.isConnectedToIme, isTrue); + + // Pop navigation back to the first screen. + navigationKey.currentState!.pop(); + await tester.pumpAndSettle(); + + // Ensure first page is visible again. + expect(find.byKey(firstPageKey), findsOneWidget); + + // By getting to this point in the test without crashing, we know that the + // _CloseKeyboardOnDispose widget was able to instruct the keyboard to + // close in its `dispose()` method. This should mean that Super Editor users + // can close the keyboard when their Super Editor screen navigates elsewhere. + }); + }); + + testWidgetsOnIos('tab indents list item', (tester) async { await _pumpUnorderedList(tester); final node = SuperEditorInspector.getNodeAt(0); @@ -353,6 +696,9 @@ void main() { // Tap to give focus to the editor. await tester.placeCaretInParagraph(document.nodes.first.id, 0); + // Ensure the document has a selection. + expect(SuperEditorInspector.findDocumentSelection(), isNotNull); + // Ensure that IME input is disabled. To check IME input, we arbitrarily simulate a newline action from // the IME. If the editor doesn't respond to the newline, it means IME input is disabled. // We expect that the document content remains unchanged. @@ -444,3 +790,34 @@ DocumentSelection _selectionInParagraph( extent: DocumentPosition(nodeId: nodeId, nodePosition: TextNodePosition(offset: to, affinity: toAffinity)), ); } + +/// A widget that calls [SoftwareKeyboardController.close] during `dispose()`. +/// +/// This behavior ensures that Super Editor users can close the keyboard as their +/// Super Editor experience goes out of existence, such as navigation. +class _CloseKeyboardOnDispose extends StatefulWidget { + const _CloseKeyboardOnDispose({ + Key? key, + required this.keyboardController, + required this.child, + }) : super(key: key); + + final SoftwareKeyboardController keyboardController; + final Widget child; + + @override + State<_CloseKeyboardOnDispose> createState() => _CloseKeyboardOnDisposeState(); +} + +class _CloseKeyboardOnDisposeState extends State<_CloseKeyboardOnDispose> { + @override + void dispose() { + widget.keyboardController.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/super_editor/test/super_editor/supereditor_selection_test.dart b/super_editor/test/super_editor/supereditor_selection_test.dart index 4f9f133cb..1fe058209 100644 --- a/super_editor/test/super_editor/supereditor_selection_test.dart +++ b/super_editor/test/super_editor/supereditor_selection_test.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; diff --git a/super_editor/test_goldens/editor/mobile/mobile_selection_test.dart b/super_editor/test_goldens/editor/mobile/mobile_selection_test.dart index dfc7fae6e..d8d2a9a90 100644 --- a/super_editor/test_goldens/editor/mobile/mobile_selection_test.dart +++ b/super_editor/test_goldens/editor/mobile/mobile_selection_test.dart @@ -730,13 +730,14 @@ void _testParagraphSelection( final dragLine = ValueNotifier<_Line?>(null); + final editor = _createSingleParagraphEditor(); final composer = DocumentComposer(); final content = _buildScaffold( dragLine: dragLine, child: SuperEditor( documentLayoutKey: docKey, - editor: _createSingleParagraphEditor(), + editor: editor, composer: composer, gestureMode: platform, stylesheet: Stylesheet(