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(