diff --git a/CHANGELOG.md b/CHANGELOG.md index 314cdcafb..d25011654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Replace `TextInputClient` with `DeltaTextInputClient` [#2509](https://github.com/singerdmx/flutter-quill/pull/2509). + ## [11.2.0] - 2025-03-26 ### Added diff --git a/lib/src/common/utils/platform.dart b/lib/src/common/utils/platform.dart index 7d673469b..516bf3d1d 100644 --- a/lib/src/common/utils/platform.dart +++ b/lib/src/common/utils/platform.dart @@ -51,6 +51,14 @@ bool get isDesktop => @pragma('vm:platform-const-if', !kDebugMode) bool get isDesktopApp => !kIsWeb && isDesktop; +// windows + +@pragma('vm:platform-const-if', !kDebugMode) +bool get isWindows => defaultTargetPlatform == TargetPlatform.windows; + +@pragma('vm:platform-const-if', !kDebugMode) +bool get isWindowsApp => !kIsWeb && isWindows; + // macOS @pragma('vm:platform-const-if', !kDebugMode) diff --git a/lib/src/delta/delta_diff.dart b/lib/src/delta/delta_diff.dart index acbe17d9a..e54dd8261 100644 --- a/lib/src/delta/delta_diff.dart +++ b/lib/src/delta/delta_diff.dart @@ -39,7 +39,6 @@ Diff getDiff(String oldText, String newText, int cursorPosition) { end > limit && oldText[end - 1] == newText[end + delta - 1]; end--) {} var start = 0; - //TODO: we need to improve this part because this loop has a lot of unsafe index operations for (final startLimit = cursorPosition - math.max(0, delta); start < startLimit && (start > oldText.length - 1 ? '' : oldText[start]) == diff --git a/lib/src/editor/raw_editor/input/ime/ime_internals.dart b/lib/src/editor/raw_editor/input/ime/ime_internals.dart new file mode 100644 index 000000000..46b842602 --- /dev/null +++ b/lib/src/editor/raw_editor/input/ime/ime_internals.dart @@ -0,0 +1,13 @@ +@internal +library; + +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart'; + +import '../../../../../flutter_quill.dart'; +import '../../../../../internal.dart'; + +part 'on_insert.dart'; +part 'on_delete.dart'; +part 'on_replace_method.dart'; +part 'on_non_update_text.dart'; diff --git a/lib/src/editor/raw_editor/input/ime/on_delete.dart b/lib/src/editor/raw_editor/input/ime/on_delete.dart new file mode 100644 index 000000000..75bf196fb --- /dev/null +++ b/lib/src/editor/raw_editor/input/ime/on_delete.dart @@ -0,0 +1,22 @@ +part of 'ime_internals.dart'; + +void onDelete( + TextEditingDeltaDeletion deletion, + QuillController controller, +) { + final start = deletion.deletedRange.start; + final length = deletion.deletedRange.end - start; + controller.replaceText( + start, + length, + '', + TextSelection.collapsed( + offset: deletion.selection.baseOffset.nonNegative, + affinity: controller.selection.affinity, + ), + ); +} + +extension on int { + int get nonNegative => this < 0 ? 0 : this; +} diff --git a/lib/src/editor/raw_editor/input/ime/on_insert.dart b/lib/src/editor/raw_editor/input/ime/on_insert.dart new file mode 100644 index 000000000..49504a8bc --- /dev/null +++ b/lib/src/editor/raw_editor/input/ime/on_insert.dart @@ -0,0 +1,30 @@ +part of 'ime_internals.dart'; + +void onInsert( + TextEditingDeltaInsertion insertion, + QuillController controller, + List characterShortcutEvents, +) { + final selection = controller.selection; + + final insertionText = insertion.textInserted; + + if (insertionText.length == 1 && !insertionText.contains('\n')) { + for (final shortcutEvent in characterShortcutEvents) { + if (shortcutEvent.character == insertionText && + shortcutEvent.handler(controller)) { + return; + } + } + } + + controller.replaceText( + insertion.insertionOffset, + selection.extentOffset - selection.baseOffset, + insertionText, + TextSelection.collapsed( + offset: insertion.insertionOffset + insertionText.length, + affinity: selection.affinity, + ), + ); +} diff --git a/lib/src/editor/raw_editor/input/ime/on_non_update_text.dart b/lib/src/editor/raw_editor/input/ime/on_non_update_text.dart new file mode 100644 index 000000000..0956047ee --- /dev/null +++ b/lib/src/editor/raw_editor/input/ime/on_non_update_text.dart @@ -0,0 +1,24 @@ +part of 'ime_internals.dart'; + +void onNonTextUpdate( + TextEditingDeltaNonTextUpdate nonTextUpdate, + QuillController controller, +) { + final effectiveSelection = nonTextUpdate.selection; + // when typing characters with CJK IME on Windows, a non-text update is sent + // with the selection range. + if (isWindowsApp) { + if (nonTextUpdate.composing == TextRange.empty && + nonTextUpdate.selection.isCollapsed) { + controller.updateSelection( + effectiveSelection, + ChangeSource.local, + ); + } + return; + } + controller.updateSelection( + effectiveSelection, + ChangeSource.local, + ); +} diff --git a/lib/src/editor/raw_editor/input/ime/on_replace_method.dart b/lib/src/editor/raw_editor/input/ime/on_replace_method.dart new file mode 100644 index 000000000..b46c8ae18 --- /dev/null +++ b/lib/src/editor/raw_editor/input/ime/on_replace_method.dart @@ -0,0 +1,37 @@ +part of 'ime_internals.dart'; + +void onReplace( + TextEditingDeltaReplacement replacement, + QuillController controller, + List characterShortcutEvents, +) { + // delete the selection + final selection = controller.selection; + + final textReplacement = replacement.replacementText; + + if (selection.isCollapsed && isIosApp && textReplacement.endsWith('\n')) { + // remove the trailing '\n' when pressing the return key + replacement = TextEditingDeltaReplacement( + oldText: replacement.oldText, + replacementText: replacement.replacementText.substring( + 0, + replacement.replacementText.length - 1, + ), + replacedRange: replacement.replacedRange, + selection: replacement.selection, + composing: replacement.composing, + ); + } + final start = replacement.replacedRange.start; + final length = replacement.replacedRange.end - start; + controller.replaceText( + start, + length, + replacement.replacementText, + TextSelection.collapsed( + offset: selection.baseOffset, + affinity: selection.affinity, + ), + ); +} diff --git a/lib/src/editor/raw_editor/raw_editor_state_text_input_client_mixin.dart b/lib/src/editor/raw_editor/input/raw_editor_state_input_client_mixin.dart similarity index 88% rename from lib/src/editor/raw_editor/raw_editor_state_text_input_client_mixin.dart rename to lib/src/editor/raw_editor/input/raw_editor_state_input_client_mixin.dart index e35489b75..56aff41ee 100644 --- a/lib/src/editor/raw_editor/raw_editor_state_text_input_client_mixin.dart +++ b/lib/src/editor/raw_editor/input/raw_editor_state_input_client_mixin.dart @@ -6,14 +6,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:meta/meta.dart'; - -import '../../delta/delta_diff.dart'; -import '../../document/document.dart'; -import '../editor.dart'; -import 'raw_editor.dart'; +import '../raw_editor.dart'; +import 'ime/ime_internals.dart'; mixin RawEditorStateTextInputClientMixin on EditorState - implements TextInputClient { + implements DeltaTextInputClient { TextInputConnection? _textInputConnection; TextEditingValue? __lastKnownRemoteTextEditingValue; @@ -82,6 +79,8 @@ mixin RawEditorStateTextInputClientMixin on EditorState TextInputConfiguration( inputType: TextInputType.multiline, readOnly: widget.config.readOnly, + enableDeltaModel: true, + enableIMEPersonalizedLearning: true, inputAction: widget.config.textInputAction, enableSuggestions: !widget.config.readOnly, keyboardAppearance: createKeyboardAppearance(), @@ -115,6 +114,7 @@ mixin RawEditorStateTextInputClientMixin on EditorState _textInputConnection!.show(); } + // windows void _updateComposingRectIfNeeded() { final composingRange = _lastKnownRemoteTextEditingValue?.composing ?? textEditingValue.composing; @@ -131,6 +131,7 @@ mixin RawEditorStateTextInputClientMixin on EditorState } } + // macos void _updateCaretRectIfNeeded() { if (hasConnection) { if (!dirty && @@ -202,43 +203,43 @@ mixin RawEditorStateTextInputClientMixin on EditorState AutofillScope? get currentAutofillScope => null; @override - void updateEditingValue(TextEditingValue value) { - if (!shouldCreateInputConnection) { - return; - } - - if (_lastKnownRemoteTextEditingValue == value) { - // There is no difference between this value and the last known value. - return; - } + void updateEditingValue(TextEditingValue value) {} - // Check if only composing range changed. - if (_lastKnownRemoteTextEditingValue!.text == value.text && - _lastKnownRemoteTextEditingValue!.selection == value.selection) { - // This update only modifies composing range. Since we don't keep track - // of composing range we just need to update last known value here. - // This check fixes an issue on Android when it sends - // composing updates separately from regular changes for text and - // selection. - _lastKnownRemoteTextEditingValue = value; + @override + void updateEditingValueWithDeltas(List textEditingDeltas) { + if (!shouldCreateInputConnection || textEditingDeltas.isEmpty) { return; } + _apply(textEditingDeltas); + } - final effectiveLastKnownValue = _lastKnownRemoteTextEditingValue!; - _lastKnownRemoteTextEditingValue = value; - final oldText = effectiveLastKnownValue.text; - final text = value.text; - final cursorPosition = value.selection.extentOffset; - final diff = getDiff(oldText, text, cursorPosition); - if (diff.deleted.isEmpty && diff.inserted.isEmpty) { - widget.controller.updateSelection(value.selection, ChangeSource.local); - } else { - widget.controller.replaceText( - diff.start, - diff.deleted.length, - diff.inserted, - value.selection, - ); + void _apply(List deltas) { + for (final delta in deltas) { + // updates _lastKnownRemoteTextEditingValue to avoid issues + updateLastKnownRemoteTextEditingValueWithDeltas(delta); + if (delta is TextEditingDeltaInsertion) { + onInsert( + delta, + widget.controller, + widget.config.characterShortcutEvents, + ); + } else if (delta is TextEditingDeltaDeletion) { + onDelete( + delta, + widget.controller, + ); + } else if (delta is TextEditingDeltaReplacement) { + onReplace( + delta, + widget.controller, + widget.config.characterShortcutEvents, + ); + } else if (delta is TextEditingDeltaNonTextUpdate) { + onNonTextUpdate( + delta, + widget.controller, + ); + } } } @@ -381,6 +382,15 @@ mixin RawEditorStateTextInputClientMixin on EditorState _lastKnownRemoteTextEditingValue = null; } + @visibleForTesting + @internal + void updateLastKnownRemoteTextEditingValueWithDeltas(TextEditingDelta delta) { + // Apply the deltas to the previous platform-side IME value, to find out + // what the platform thinks the IME value is + _lastKnownRemoteTextEditingValue = + delta.apply(_lastKnownRemoteTextEditingValue!); + } + void _updateSizeAndTransform() { if (hasConnection) { // Asking for renderEditor.size here can cause errors if layout hasn't diff --git a/lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcuts.dart b/lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcuts.dart index a21b612c4..f1a128869 100644 --- a/lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcuts.dart +++ b/lib/src/editor/raw_editor/keyboard_shortcuts/editor_keyboard_shortcuts.dart @@ -12,7 +12,6 @@ import '../../../document/nodes/leaf.dart' as leaf; import '../../../document/nodes/line.dart'; import '../../../document/nodes/node.dart'; import '../../widgets/keyboard_listener.dart'; -import '../config/events/character_shortcuts_events.dart'; import '../config/events/space_shortcut_events.dart'; import 'default_single_activator_intents.dart'; @@ -26,7 +25,6 @@ class EditorKeyboardShortcuts extends StatelessWidget { required this.controller, required this.readOnly, required this.enableAlwaysIndentOnTab, - required this.characterEvents, required this.spaceEvents, this.onKeyPressed, this.customShortcuts, @@ -39,7 +37,6 @@ class EditorKeyboardShortcuts extends StatelessWidget { final QuillController controller; @experimental final KeyEventResult? Function(KeyEvent event, Node? node)? onKeyPressed; - final List characterEvents; final List spaceEvents; final Map? customShortcuts; final Map>? customActions; @@ -97,17 +94,6 @@ class EditorKeyboardShortcuts extends StatelessWidget { final isSpace = event.logicalKey == LogicalKeyboardKey.space; final containsSelection = controller.selection.baseOffset != controller.selection.extentOffset; - if (!isTab && !isSpace && event.character != '\n' && !containsSelection) { - for (final charEvents in characterEvents) { - if (event.character != null && - event.character == charEvents.character) { - final executed = charEvents.execute(controller); - if (executed) { - return KeyEventResult.handled; - } - } - } - } if (event is! KeyDownEvent) { return KeyEventResult.ignored; diff --git a/lib/src/editor/raw_editor/raw_editor_state.dart b/lib/src/editor/raw_editor/raw_editor_state.dart index 5c70f5792..20583d903 100644 --- a/lib/src/editor/raw_editor/raw_editor_state.dart +++ b/lib/src/editor/raw_editor/raw_editor_state.dart @@ -31,12 +31,12 @@ import '../widgets/proxy.dart'; import '../widgets/text/text_block.dart'; import '../widgets/text/text_line.dart'; import '../widgets/text/text_selection.dart'; +import 'input/raw_editor_state_input_client_mixin.dart'; import 'keyboard_shortcuts/editor_keyboard_shortcut_actions_manager.dart'; import 'keyboard_shortcuts/editor_keyboard_shortcuts.dart'; import 'raw_editor.dart'; import 'raw_editor_render_object.dart'; import 'raw_editor_state_selection_delegate_mixin.dart'; -import 'raw_editor_state_text_input_client_mixin.dart'; import 'scribble_focusable.dart'; class QuillRawEditorState extends EditorState @@ -486,7 +486,6 @@ class QuillRawEditorState extends EditorState child: EditorKeyboardShortcuts( actions: _shortcutActionsManager.actions, onKeyPressed: widget.config.onKeyPressed, - characterEvents: widget.config.characterShortcutEvents, spaceEvents: widget.config.spaceShortcutEvents, constraints: constraints, focusNode: widget.config.focusNode,