Skip to content

Commit b86fb8b

Browse files
authored
Updated RawEditorStateTextInputClientMixin to implement DeltaTextInputClient (#35)
1 parent 271167a commit b86fb8b

File tree

6 files changed

+326
-68
lines changed

6 files changed

+326
-68
lines changed

packages/fleather/lib/src/widgets/editor_input_client_mixin.dart

Lines changed: 31 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import 'dart:ui' as ui;
22

3-
import 'package:fleather/util.dart';
43
import 'package:flutter/foundation.dart';
54
import 'package:flutter/scheduler.dart';
65
import 'package:flutter/services.dart';
@@ -10,7 +9,7 @@ import '../rendering/editor.dart';
109
import 'editor.dart';
1110

1211
mixin RawEditorStateTextInputClientMixin on EditorState
13-
implements TextInputClient {
12+
implements DeltaTextInputClient {
1413
TextInputConnection? _textInputConnection;
1514
TextEditingValue? _lastKnownRemoteTextEditingValue;
1615

@@ -29,12 +28,6 @@ mixin RawEditorStateTextInputClientMixin on EditorState
2928
/// - Changing the selection using a physical keyboard.
3029
bool get shouldCreateInputConnection => kIsWeb || !widget.readOnly;
3130

32-
void _remoteValueChanged(
33-
int start, String deleted, String inserted, TextSelection selection) {
34-
widget.controller
35-
.replaceText(start, deleted.length, inserted, selection: selection);
36-
}
37-
3831
/// Returns `true` if there is open input connection.
3932
bool get hasConnection =>
4033
_textInputConnection != null && _textInputConnection!.attached;
@@ -64,6 +57,7 @@ mixin RawEditorStateTextInputClientMixin on EditorState
6457
readOnly: widget.readOnly,
6558
obscureText: false,
6659
autocorrect: false,
60+
enableDeltaModel: true,
6761
inputAction: TextInputAction.newline,
6862
keyboardAppearance: widget.keyboardAppearance,
6963
textCapitalization: widget.textCapitalization,
@@ -120,51 +114,33 @@ mixin RawEditorStateTextInputClientMixin on EditorState
120114
AutofillScope? get currentAutofillScope => null;
121115

122116
@override
123-
void updateEditingValue(TextEditingValue value) {
124-
if (!shouldCreateInputConnection) {
125-
return;
126-
}
127-
128-
if (_lastKnownRemoteTextEditingValue == value) {
129-
// There is no difference between this value and the last known value.
130-
return;
131-
}
132-
133-
// Check if only composing range changed.
134-
if (_lastKnownRemoteTextEditingValue!.text == value.text &&
135-
_lastKnownRemoteTextEditingValue!.selection == value.selection) {
136-
// This update only modifies composing range. Since we don't keep track
137-
// of composing range in Zefyr we just need to update last known value
138-
// here.
139-
// This check fixes an issue on Android when it sends
140-
// composing updates separately from regular changes for text and
141-
// selection.
142-
_lastKnownRemoteTextEditingValue = value;
143-
return;
117+
void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {
118+
if (!shouldCreateInputConnection || textEditingDeltas.isEmpty) return;
119+
120+
for (final textEditingDelta in textEditingDeltas) {
121+
int start = 0, length = 0;
122+
String data = '';
123+
if (textEditingDelta is TextEditingDeltaInsertion) {
124+
start = textEditingDelta.insertionOffset;
125+
data = textEditingDelta.textInserted;
126+
} else if (textEditingDelta is TextEditingDeltaDeletion) {
127+
start = textEditingDelta.deletedRange.start;
128+
length = textEditingDelta.deletedRange.length;
129+
} else if (textEditingDelta is TextEditingDeltaReplacement) {
130+
start = textEditingDelta.replacedRange.start;
131+
length = textEditingDelta.replacedRange.length;
132+
data = textEditingDelta.replacementText;
133+
}
134+
_lastKnownRemoteTextEditingValue =
135+
textEditingDelta.apply(_lastKnownRemoteTextEditingValue!);
136+
widget.controller.replaceText(start, length, data,
137+
selection: textEditingDelta.selection);
144138
}
139+
}
145140

146-
// Note Flutter (unintentionally?) silences errors occurred during
147-
// text input update, so we have to report it ourselves.
148-
// For more details see https://github.com/flutter/flutter/issues/19191
149-
// TODO: remove try-catch when/if Flutter stops silencing these errors.
150-
try {
151-
final effectiveLastKnownValue = _lastKnownRemoteTextEditingValue!;
152-
_lastKnownRemoteTextEditingValue = value;
153-
final oldText = effectiveLastKnownValue.text;
154-
final text = value.text;
155-
final cursorPosition = value.selection.extentOffset;
156-
final diff = fastDiff(oldText, text, cursorPosition);
157-
_remoteValueChanged(
158-
diff.start, diff.deleted, diff.inserted, value.selection);
159-
} catch (e, trace) {
160-
FlutterError.reportError(FlutterErrorDetails(
161-
exception: e,
162-
stack: trace,
163-
library: 'Fleather',
164-
context: ErrorSummary('while updating editing value'),
165-
));
166-
rethrow;
167-
}
141+
@override
142+
void updateEditingValue(TextEditingValue value) {
143+
// no-op
168144
}
169145

170146
@override
@@ -318,3 +294,7 @@ mixin RawEditorStateTextInputClientMixin on EditorState
318294
}
319295
}
320296
}
297+
298+
extension on TextRange {
299+
int get length => end - start;
300+
}

packages/fleather/pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ dev_dependencies:
1919
flutter_test:
2020
sdk: flutter
2121
flutter_lints: ^2.0.1
22+
mocktail: ^0.3.0

packages/fleather/test/testing.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// BSD-style license that can be found in the LICENSE file.
44

55
import 'package:fleather/fleather.dart';
6+
import 'package:fleather/src/widgets/editor_input_client_mixin.dart';
67
import 'package:flutter/material.dart';
78
import 'package:flutter_test/flutter_test.dart';
89
import 'package:quill_delta/quill_delta.dart';
@@ -201,3 +202,7 @@ class TestUpdateWidgetState extends State<TestUpdateWidget> {
201202
],
202203
);
203204
}
205+
206+
RawEditorStateTextInputClientMixin getInputClient() =>
207+
(find.byType(RawEditor).evaluate().single as StatefulElement).state
208+
as RawEditorStateTextInputClientMixin;
Lines changed: 118 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,37 @@
1-
// Copyright (c) 2018, the Zefyr project authors. Please see the AUTHORS file
2-
// for details. All rights reserved. Use of this source code is governed by a
3-
// BSD-style license that can be found in the LICENSE file.
4-
import 'package:flutter/widgets.dart';
1+
import 'package:flutter/services.dart';
52
import 'package:flutter_test/flutter_test.dart';
63

74
import '../testing.dart';
85

96
void main() {
107
group('FleatherEditableText', () {
11-
testWidgets('user input', (tester) async {
8+
testWidgets('user input inserts text', (tester) async {
129
final editor = EditorSandBox(tester: tester);
1310
await editor.pumpAndTap();
1411
final currentValue = editor.document.toPlainText();
15-
await enterText(tester, 'Added $currentValue');
12+
await insertText(tester, 'Added ', inText: currentValue);
1613
expect(editor.document.toPlainText(), 'Added This House Is A Circus\n');
1714
});
1815

16+
testWidgets('user input deletes text', (tester) async {
17+
final editor = EditorSandBox(tester: tester);
18+
await editor.pumpAndTap();
19+
final currentValue = editor.document.toPlainText();
20+
await deleteText(tester, nbCharacters: 5, inText: currentValue);
21+
expect(editor.document.toPlainText(), 'House Is A Circus\n');
22+
});
23+
24+
testWidgets('user input replaced text', (tester) async {
25+
final editor = EditorSandBox(tester: tester);
26+
await editor.pumpAndTap();
27+
final currentValue = editor.document.toPlainText();
28+
await replaceText(tester,
29+
inText: currentValue,
30+
range: const TextRange(start: 5, end: 5 + 'House'.length),
31+
withText: 'Place');
32+
expect(editor.document.toPlainText(), 'This Place Is A Circus\n');
33+
});
34+
1935
testWidgets('autofocus', (tester) async {
2036
final editor = EditorSandBox(tester: tester, autofocus: true);
2137
await editor.pump();
@@ -30,14 +46,103 @@ void main() {
3046
});
3147
}
3248

33-
Future<void> enterText(WidgetTester tester, String text) async {
49+
Future<void> insertText(WidgetTester tester, String textInserted,
50+
{int atOffset = 0, String inText = ''}) async {
3451
return TestAsyncUtils.guard(() async {
35-
tester.testTextInput.updateEditingValue(
36-
TextEditingValue(
37-
text: text,
38-
selection: const TextSelection.collapsed(offset: 6),
39-
),
40-
);
52+
updateDeltaEditingValue(TextEditingDeltaInsertion(
53+
oldText: inText,
54+
textInserted: textInserted,
55+
insertionOffset: atOffset,
56+
selection: const TextSelection.collapsed(offset: 0),
57+
composing: TextRange.empty));
58+
await tester.idle();
59+
});
60+
}
61+
62+
Future<void> deleteText(WidgetTester tester,
63+
{required int nbCharacters, int at = 0, required String inText}) {
64+
return TestAsyncUtils.guard(() async {
65+
updateDeltaEditingValue(TextEditingDeltaDeletion(
66+
oldText: inText,
67+
deletedRange: TextRange(start: at, end: at + nbCharacters),
68+
selection: const TextSelection.collapsed(offset: 0),
69+
composing: TextRange.empty));
4170
await tester.idle();
4271
});
4372
}
73+
74+
Future<void> replaceText(WidgetTester tester,
75+
{required TextRange range,
76+
required String withText,
77+
required String inText}) {
78+
return TestAsyncUtils.guard(() async {
79+
updateDeltaEditingValue(TextEditingDeltaReplacement(
80+
oldText: inText,
81+
replacedRange: range,
82+
replacementText: withText,
83+
selection: const TextSelection.collapsed(offset: 0),
84+
composing: TextRange.empty));
85+
await tester.idle();
86+
});
87+
}
88+
89+
void updateDeltaEditingValue(TextEditingDelta delta, {int? client}) {
90+
TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger
91+
.handlePlatformMessage(
92+
SystemChannels.textInput.name,
93+
SystemChannels.textInput.codec.encodeMethodCall(
94+
MethodCall(
95+
'TextInputClient.updateEditingStateWithDeltas',
96+
<dynamic>[
97+
client ?? -1,
98+
{
99+
'deltas': [delta.toJSON()]
100+
}
101+
],
102+
),
103+
),
104+
(ByteData? data) {
105+
/* ignored */
106+
},
107+
);
108+
}
109+
110+
extension DeltaJson on TextEditingDelta {
111+
Map<String, dynamic> toJSON() {
112+
final json = <String, dynamic>{};
113+
json['composingBase'] = composing.start;
114+
json['composingExtent'] = composing.end;
115+
116+
json['selectionBase'] = selection.baseOffset;
117+
json['selectionExtent'] = selection.extentOffset;
118+
json['selectionAffinity'] = selection.affinity.name;
119+
json['selectionIsDirectional'] = selection.isDirectional;
120+
121+
json['oldText'] = oldText;
122+
123+
if (this is TextEditingDeltaInsertion) {
124+
final insertion = this as TextEditingDeltaInsertion;
125+
json['deltaStart'] = insertion.insertionOffset;
126+
// Assumes no replacement, simply insertion here
127+
json['deltaEnd'] = insertion.insertionOffset;
128+
json['deltaText'] = insertion.textInserted;
129+
}
130+
131+
if (this is TextEditingDeltaDeletion) {
132+
final deletion = this as TextEditingDeltaDeletion;
133+
json['deltaStart'] = deletion.deletedRange.start;
134+
// Assumes no replacement, simply insertion here
135+
json['deltaEnd'] = deletion.deletedRange.end;
136+
json['deltaText'] = '';
137+
}
138+
139+
if (this is TextEditingDeltaReplacement) {
140+
final replacement = this as TextEditingDeltaReplacement;
141+
json['deltaStart'] = replacement.replacedRange.start;
142+
// Assumes no replacement, simply insertion here
143+
json['deltaEnd'] = replacement.replacedRange.end;
144+
json['deltaText'] = replacement.replacementText;
145+
}
146+
return json;
147+
}
148+
}

0 commit comments

Comments
 (0)