commit
f40de5a8b6
6 changed files with 629 additions and 510 deletions
@ -0,0 +1,354 @@ |
||||
import 'dart:ui'; |
||||
|
||||
import 'package:characters/characters.dart'; |
||||
import 'package:flutter/services.dart'; |
||||
|
||||
import '../../models/documents/document.dart'; |
||||
import '../../utils/diff_delta.dart'; |
||||
import '../editor.dart'; |
||||
import '../keyboard_listener.dart'; |
||||
|
||||
mixin RawEditorStateKeyboardMixin on EditorState { |
||||
// Holds the last cursor location the user selected in the case the user tries |
||||
// to select vertically past the end or beginning of the field. If they do, |
||||
// then we need to keep the old cursor location so that we can go back to it |
||||
// if they change their minds. Only used for moving selection up and down in a |
||||
// multiline text field when selecting using the keyboard. |
||||
int _cursorResetLocation = -1; |
||||
|
||||
// Whether we should reset the location of the cursor in the case the user |
||||
// tries to select vertically past the end or beginning of the field. If they |
||||
// do, then we need to keep the old cursor location so that we can go back to |
||||
// it if they change their minds. Only used for resetting selection up and |
||||
// down in a multiline text field when selecting using the keyboard. |
||||
bool _wasSelectingVerticallyWithKeyboard = false; |
||||
|
||||
void handleCursorMovement( |
||||
LogicalKeyboardKey key, |
||||
bool wordModifier, |
||||
bool lineModifier, |
||||
bool shift, |
||||
) { |
||||
if (wordModifier && lineModifier) { |
||||
// If both modifiers are down, nothing happens on any of the platforms. |
||||
return; |
||||
} |
||||
final selection = widget.controller.selection; |
||||
|
||||
var newSelection = widget.controller.selection; |
||||
|
||||
final plainText = getTextEditingValue().text; |
||||
|
||||
final rightKey = key == LogicalKeyboardKey.arrowRight, |
||||
leftKey = key == LogicalKeyboardKey.arrowLeft, |
||||
upKey = key == LogicalKeyboardKey.arrowUp, |
||||
downKey = key == LogicalKeyboardKey.arrowDown; |
||||
|
||||
if ((rightKey || leftKey) && !(rightKey && leftKey)) { |
||||
newSelection = _jumpToBeginOrEndOfWord(newSelection, wordModifier, |
||||
leftKey, rightKey, plainText, lineModifier, shift); |
||||
} |
||||
|
||||
if (downKey || upKey) { |
||||
newSelection = _handleMovingCursorVertically( |
||||
upKey, downKey, shift, selection, newSelection, plainText); |
||||
} |
||||
|
||||
if (!shift) { |
||||
newSelection = |
||||
_placeCollapsedSelection(selection, newSelection, leftKey, rightKey); |
||||
} |
||||
|
||||
widget.controller.updateSelection(newSelection, ChangeSource.LOCAL); |
||||
} |
||||
|
||||
// Handles shortcut functionality including cut, copy, paste and select all |
||||
// using control/command + (X, C, V, A). |
||||
// TODO: Add support for formatting shortcuts: Cmd+B (bold), Cmd+I (italic) |
||||
Future<void> handleShortcut(InputShortcut? shortcut) async { |
||||
final selection = widget.controller.selection; |
||||
final plainText = getTextEditingValue().text; |
||||
if (shortcut == InputShortcut.COPY) { |
||||
if (!selection.isCollapsed) { |
||||
await Clipboard.setData( |
||||
ClipboardData(text: selection.textInside(plainText))); |
||||
} |
||||
return; |
||||
} |
||||
if (shortcut == InputShortcut.CUT && !widget.readOnly) { |
||||
if (!selection.isCollapsed) { |
||||
final data = selection.textInside(plainText); |
||||
await Clipboard.setData(ClipboardData(text: data)); |
||||
|
||||
widget.controller.replaceText( |
||||
selection.start, |
||||
data.length, |
||||
'', |
||||
TextSelection.collapsed(offset: selection.start), |
||||
); |
||||
|
||||
setTextEditingValue(TextEditingValue( |
||||
text: |
||||
selection.textBefore(plainText) + selection.textAfter(plainText), |
||||
selection: TextSelection.collapsed(offset: selection.start), |
||||
)); |
||||
} |
||||
return; |
||||
} |
||||
if (shortcut == InputShortcut.PASTE && !widget.readOnly) { |
||||
final data = await Clipboard.getData(Clipboard.kTextPlain); |
||||
if (data != null) { |
||||
widget.controller.replaceText( |
||||
selection.start, |
||||
selection.end - selection.start, |
||||
data.text, |
||||
TextSelection.collapsed(offset: selection.start + data.text!.length), |
||||
); |
||||
} |
||||
return; |
||||
} |
||||
if (shortcut == InputShortcut.SELECT_ALL && |
||||
widget.enableInteractiveSelection) { |
||||
widget.controller.updateSelection( |
||||
selection.copyWith( |
||||
baseOffset: 0, |
||||
extentOffset: getTextEditingValue().text.length, |
||||
), |
||||
ChangeSource.REMOTE); |
||||
return; |
||||
} |
||||
} |
||||
|
||||
void handleDelete(bool forward) { |
||||
final selection = widget.controller.selection; |
||||
final plainText = getTextEditingValue().text; |
||||
var cursorPosition = selection.start; |
||||
var textBefore = selection.textBefore(plainText); |
||||
var textAfter = selection.textAfter(plainText); |
||||
if (selection.isCollapsed) { |
||||
if (!forward && textBefore.isNotEmpty) { |
||||
final characterBoundary = |
||||
_previousCharacter(textBefore.length, textBefore, true); |
||||
textBefore = textBefore.substring(0, characterBoundary); |
||||
cursorPosition = characterBoundary; |
||||
} |
||||
if (forward && textAfter.isNotEmpty && textAfter != '\n') { |
||||
final deleteCount = _nextCharacter(0, textAfter, true); |
||||
textAfter = textAfter.substring(deleteCount); |
||||
} |
||||
} |
||||
final newSelection = TextSelection.collapsed(offset: cursorPosition); |
||||
final newText = textBefore + textAfter; |
||||
final size = plainText.length - newText.length; |
||||
widget.controller.replaceText( |
||||
cursorPosition, |
||||
size, |
||||
'', |
||||
newSelection, |
||||
); |
||||
} |
||||
|
||||
TextSelection _jumpToBeginOrEndOfWord( |
||||
TextSelection newSelection, |
||||
bool wordModifier, |
||||
bool leftKey, |
||||
bool rightKey, |
||||
String plainText, |
||||
bool lineModifier, |
||||
bool shift) { |
||||
if (wordModifier) { |
||||
if (leftKey) { |
||||
final textSelection = getRenderEditor()!.selectWordAtPosition( |
||||
TextPosition( |
||||
offset: _previousCharacter( |
||||
newSelection.extentOffset, plainText, false))); |
||||
return newSelection.copyWith(extentOffset: textSelection.baseOffset); |
||||
} |
||||
final textSelection = getRenderEditor()!.selectWordAtPosition( |
||||
TextPosition( |
||||
offset: |
||||
_nextCharacter(newSelection.extentOffset, plainText, false))); |
||||
return newSelection.copyWith(extentOffset: textSelection.extentOffset); |
||||
} else if (lineModifier) { |
||||
if (leftKey) { |
||||
final textSelection = getRenderEditor()!.selectLineAtPosition( |
||||
TextPosition( |
||||
offset: _previousCharacter( |
||||
newSelection.extentOffset, plainText, false))); |
||||
return newSelection.copyWith(extentOffset: textSelection.baseOffset); |
||||
} |
||||
final startPoint = newSelection.extentOffset; |
||||
if (startPoint < plainText.length) { |
||||
final textSelection = getRenderEditor()! |
||||
.selectLineAtPosition(TextPosition(offset: startPoint)); |
||||
return newSelection.copyWith(extentOffset: textSelection.extentOffset); |
||||
} |
||||
return newSelection; |
||||
} |
||||
|
||||
if (rightKey && newSelection.extentOffset < plainText.length) { |
||||
final nextExtent = |
||||
_nextCharacter(newSelection.extentOffset, plainText, true); |
||||
final distance = nextExtent - newSelection.extentOffset; |
||||
newSelection = newSelection.copyWith(extentOffset: nextExtent); |
||||
if (shift) { |
||||
_cursorResetLocation += distance; |
||||
} |
||||
return newSelection; |
||||
} |
||||
|
||||
if (leftKey && newSelection.extentOffset > 0) { |
||||
final previousExtent = |
||||
_previousCharacter(newSelection.extentOffset, plainText, true); |
||||
final distance = newSelection.extentOffset - previousExtent; |
||||
newSelection = newSelection.copyWith(extentOffset: previousExtent); |
||||
if (shift) { |
||||
_cursorResetLocation -= distance; |
||||
} |
||||
return newSelection; |
||||
} |
||||
return newSelection; |
||||
} |
||||
|
||||
/// Returns the index into the string of the next character boundary after the |
||||
/// given index. |
||||
/// |
||||
/// The character boundary is determined by the characters package, so |
||||
/// surrogate pairs and extended grapheme clusters are considered. |
||||
/// |
||||
/// The index must be between 0 and string.length, inclusive. If given |
||||
/// string.length, string.length is returned. |
||||
/// |
||||
/// Setting includeWhitespace to false will only return the index of non-space |
||||
/// characters. |
||||
int _nextCharacter(int index, String string, bool includeWhitespace) { |
||||
assert(index >= 0 && index <= string.length); |
||||
if (index == string.length) { |
||||
return string.length; |
||||
} |
||||
|
||||
var count = 0; |
||||
final remain = string.characters.skipWhile((currentString) { |
||||
if (count <= index) { |
||||
count += currentString.length; |
||||
return true; |
||||
} |
||||
if (includeWhitespace) { |
||||
return false; |
||||
} |
||||
return WHITE_SPACE.contains(currentString.codeUnitAt(0)); |
||||
}); |
||||
return string.length - remain.toString().length; |
||||
} |
||||
|
||||
/// Returns the index into the string of the previous character boundary |
||||
/// before the given index. |
||||
/// |
||||
/// The character boundary is determined by the characters package, so |
||||
/// surrogate pairs and extended grapheme clusters are considered. |
||||
/// |
||||
/// The index must be between 0 and string.length, inclusive. If index is 0, |
||||
/// 0 will be returned. |
||||
/// |
||||
/// Setting includeWhitespace to false will only return the index of non-space |
||||
/// characters. |
||||
int _previousCharacter(int index, String string, includeWhitespace) { |
||||
assert(index >= 0 && index <= string.length); |
||||
if (index == 0) { |
||||
return 0; |
||||
} |
||||
|
||||
var count = 0; |
||||
int? lastNonWhitespace; |
||||
for (final currentString in string.characters) { |
||||
if (!includeWhitespace && |
||||
!WHITE_SPACE.contains( |
||||
currentString.characters.first.toString().codeUnitAt(0))) { |
||||
lastNonWhitespace = count; |
||||
} |
||||
if (count + currentString.length >= index) { |
||||
return includeWhitespace ? count : lastNonWhitespace ?? 0; |
||||
} |
||||
count += currentString.length; |
||||
} |
||||
return 0; |
||||
} |
||||
|
||||
TextSelection _handleMovingCursorVertically( |
||||
bool upKey, |
||||
bool downKey, |
||||
bool shift, |
||||
TextSelection selection, |
||||
TextSelection newSelection, |
||||
String plainText) { |
||||
final originPosition = TextPosition( |
||||
offset: upKey ? selection.baseOffset : selection.extentOffset); |
||||
|
||||
final child = getRenderEditor()!.childAtPosition(originPosition); |
||||
final localPosition = TextPosition( |
||||
offset: originPosition.offset - child.getContainer().documentOffset); |
||||
|
||||
var position = upKey |
||||
? child.getPositionAbove(localPosition) |
||||
: child.getPositionBelow(localPosition); |
||||
|
||||
if (position == null) { |
||||
final sibling = upKey |
||||
? getRenderEditor()!.childBefore(child) |
||||
: getRenderEditor()!.childAfter(child); |
||||
if (sibling == null) { |
||||
position = TextPosition(offset: upKey ? 0 : plainText.length - 1); |
||||
} else { |
||||
final finalOffset = Offset( |
||||
child.getOffsetForCaret(localPosition).dx, |
||||
sibling |
||||
.getOffsetForCaret(TextPosition( |
||||
offset: upKey ? sibling.getContainer().length - 1 : 0)) |
||||
.dy); |
||||
final siblingPosition = sibling.getPositionForOffset(finalOffset); |
||||
position = TextPosition( |
||||
offset: |
||||
sibling.getContainer().documentOffset + siblingPosition.offset); |
||||
} |
||||
} else { |
||||
position = TextPosition( |
||||
offset: child.getContainer().documentOffset + position.offset); |
||||
} |
||||
|
||||
if (position.offset == newSelection.extentOffset) { |
||||
if (downKey) { |
||||
newSelection = newSelection.copyWith(extentOffset: plainText.length); |
||||
} else if (upKey) { |
||||
newSelection = newSelection.copyWith(extentOffset: 0); |
||||
} |
||||
_wasSelectingVerticallyWithKeyboard = shift; |
||||
return newSelection; |
||||
} |
||||
|
||||
if (_wasSelectingVerticallyWithKeyboard && shift) { |
||||
newSelection = newSelection.copyWith(extentOffset: _cursorResetLocation); |
||||
_wasSelectingVerticallyWithKeyboard = false; |
||||
return newSelection; |
||||
} |
||||
newSelection = newSelection.copyWith(extentOffset: position.offset); |
||||
_cursorResetLocation = newSelection.extentOffset; |
||||
return newSelection; |
||||
} |
||||
|
||||
TextSelection _placeCollapsedSelection(TextSelection selection, |
||||
TextSelection newSelection, bool leftKey, bool rightKey) { |
||||
var newOffset = newSelection.extentOffset; |
||||
if (!selection.isCollapsed) { |
||||
if (leftKey) { |
||||
newOffset = newSelection.baseOffset < newSelection.extentOffset |
||||
? newSelection.baseOffset |
||||
: newSelection.extentOffset; |
||||
} else if (rightKey) { |
||||
newOffset = newSelection.baseOffset > newSelection.extentOffset |
||||
? newSelection.baseOffset |
||||
: newSelection.extentOffset; |
||||
} |
||||
} |
||||
return TextSelection.fromPosition(TextPosition(offset: newOffset)); |
||||
} |
||||
} |
@ -0,0 +1,40 @@ |
||||
import 'package:flutter/widgets.dart'; |
||||
|
||||
import '../editor.dart'; |
||||
|
||||
mixin RawEditorStateSelectionDelegateMixin on EditorState |
||||
implements TextSelectionDelegate { |
||||
@override |
||||
TextEditingValue get textEditingValue { |
||||
return getTextEditingValue(); |
||||
} |
||||
|
||||
@override |
||||
set textEditingValue(TextEditingValue value) { |
||||
setTextEditingValue(value); |
||||
} |
||||
|
||||
@override |
||||
void bringIntoView(TextPosition position) { |
||||
// TODO: implement bringIntoView |
||||
} |
||||
|
||||
@override |
||||
void hideToolbar([bool hideHandles = true]) { |
||||
if (getSelectionOverlay()?.toolbar != null) { |
||||
getSelectionOverlay()?.hideToolbar(); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly; |
||||
|
||||
@override |
||||
bool get copyEnabled => widget.toolbarOptions.copy; |
||||
|
||||
@override |
||||
bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly; |
||||
|
||||
@override |
||||
bool get selectAllEnabled => widget.toolbarOptions.selectAll; |
||||
} |
@ -0,0 +1,200 @@ |
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/services.dart'; |
||||
import 'package:flutter/widgets.dart'; |
||||
|
||||
import '../../utils/diff_delta.dart'; |
||||
import '../editor.dart'; |
||||
|
||||
mixin RawEditorStateTextInputClientMixin on EditorState |
||||
implements TextInputClient { |
||||
final List<TextEditingValue> _sentRemoteValues = []; |
||||
TextInputConnection? _textInputConnection; |
||||
TextEditingValue? _lastKnownRemoteTextEditingValue; |
||||
|
||||
/// Whether to create an input connection with the platform for text editing |
||||
/// or not. |
||||
/// |
||||
/// Read-only input fields do not need a connection with the platform since |
||||
/// there's no need for text editing capabilities (e.g. virtual keyboard). |
||||
/// |
||||
/// On the web, we always need a connection because we want some browser |
||||
/// functionalities to continue to work on read-only input fields like: |
||||
/// |
||||
/// - Relevant context menu. |
||||
/// - cmd/ctrl+c shortcut to copy. |
||||
/// - cmd/ctrl+a to select all. |
||||
/// - Changing the selection using a physical keyboard. |
||||
bool get shouldCreateInputConnection => kIsWeb || !widget.readOnly; |
||||
|
||||
/// Returns `true` if there is open input connection. |
||||
bool get hasConnection => |
||||
_textInputConnection != null && _textInputConnection!.attached; |
||||
|
||||
/// Opens or closes input connection based on the current state of |
||||
/// [focusNode] and [value]. |
||||
void openOrCloseConnection() { |
||||
if (widget.focusNode.hasFocus && widget.focusNode.consumeKeyboardToken()) { |
||||
openConnectionIfNeeded(); |
||||
} else if (!widget.focusNode.hasFocus) { |
||||
closeConnectionIfNeeded(); |
||||
} |
||||
} |
||||
|
||||
void openConnectionIfNeeded() { |
||||
if (!shouldCreateInputConnection) { |
||||
return; |
||||
} |
||||
|
||||
if (!hasConnection) { |
||||
_lastKnownRemoteTextEditingValue = getTextEditingValue(); |
||||
_textInputConnection = TextInput.attach( |
||||
this, |
||||
TextInputConfiguration( |
||||
inputType: TextInputType.multiline, |
||||
readOnly: widget.readOnly, |
||||
inputAction: TextInputAction.newline, |
||||
enableSuggestions: !widget.readOnly, |
||||
keyboardAppearance: widget.keyboardAppearance, |
||||
textCapitalization: widget.textCapitalization, |
||||
), |
||||
); |
||||
|
||||
_textInputConnection!.setEditingState(_lastKnownRemoteTextEditingValue!); |
||||
// _sentRemoteValues.add(_lastKnownRemoteTextEditingValue); |
||||
} |
||||
|
||||
_textInputConnection!.show(); |
||||
} |
||||
|
||||
/// Closes input connection if it's currently open. Otherwise does nothing. |
||||
void closeConnectionIfNeeded() { |
||||
if (!hasConnection) { |
||||
return; |
||||
} |
||||
_textInputConnection!.close(); |
||||
_textInputConnection = null; |
||||
_lastKnownRemoteTextEditingValue = null; |
||||
_sentRemoteValues.clear(); |
||||
} |
||||
|
||||
/// Updates remote value based on current state of [document] and |
||||
/// [selection]. |
||||
/// |
||||
/// This method may not actually send an update to native side if it thinks |
||||
/// remote value is up to date or identical. |
||||
void updateRemoteValueIfNeeded() { |
||||
if (!hasConnection) { |
||||
return; |
||||
} |
||||
|
||||
// Since we don't keep track of the composing range in value provided |
||||
// by the Controller we need to add it here manually before comparing |
||||
// with the last known remote value. |
||||
// It is important to prevent excessive remote updates as it can cause |
||||
// race conditions. |
||||
final actualValue = getTextEditingValue().copyWith( |
||||
composing: _lastKnownRemoteTextEditingValue!.composing, |
||||
); |
||||
|
||||
if (actualValue == _lastKnownRemoteTextEditingValue) { |
||||
return; |
||||
} |
||||
|
||||
final shouldRemember = |
||||
getTextEditingValue().text != _lastKnownRemoteTextEditingValue!.text; |
||||
_lastKnownRemoteTextEditingValue = actualValue; |
||||
_textInputConnection!.setEditingState(actualValue); |
||||
if (shouldRemember) { |
||||
// Only keep track if text changed (selection changes are not relevant) |
||||
_sentRemoteValues.add(actualValue); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
TextEditingValue? get currentTextEditingValue => |
||||
_lastKnownRemoteTextEditingValue; |
||||
|
||||
// autofill is not needed |
||||
@override |
||||
AutofillScope? get currentAutofillScope => null; |
||||
|
||||
@override |
||||
void updateEditingValue(TextEditingValue value) { |
||||
if (!shouldCreateInputConnection) { |
||||
return; |
||||
} |
||||
|
||||
if (_sentRemoteValues.contains(value)) { |
||||
/// There is a race condition in Flutter text input plugin where sending |
||||
/// updates to native side too often results in broken behavior. |
||||
/// TextInputConnection.setEditingValue is an async call to native side. |
||||
/// For each such call native side _always_ sends an update which triggers |
||||
/// this method (updateEditingValue) with the same value we've sent it. |
||||
/// If multiple calls to setEditingValue happen too fast and we only |
||||
/// track the last sent value then there is no way for us to filter out |
||||
/// automatic callbacks from native side. |
||||
/// Therefore we have to keep track of all values we send to the native |
||||
/// side and when we see this same value appear here we skip it. |
||||
/// This is fragile but it's probably the only available option. |
||||
_sentRemoteValues.remove(value); |
||||
return; |
||||
} |
||||
|
||||
if (_lastKnownRemoteTextEditingValue == value) { |
||||
// There is no difference between this value and the last known value. |
||||
return; |
||||
} |
||||
|
||||
// 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; |
||||
return; |
||||
} |
||||
|
||||
final effectiveLastKnownValue = _lastKnownRemoteTextEditingValue!; |
||||
_lastKnownRemoteTextEditingValue = value; |
||||
final oldText = effectiveLastKnownValue.text; |
||||
final text = value.text; |
||||
final cursorPosition = value.selection.extentOffset; |
||||
final diff = getDiff(oldText, text, cursorPosition); |
||||
widget.controller.replaceText( |
||||
diff.start, diff.deleted.length, diff.inserted, value.selection); |
||||
} |
||||
|
||||
@override |
||||
void performAction(TextInputAction action) { |
||||
// no-op |
||||
} |
||||
|
||||
@override |
||||
void performPrivateCommand(String action, Map<String, dynamic> data) { |
||||
// no-op |
||||
} |
||||
|
||||
@override |
||||
void updateFloatingCursor(RawFloatingCursorPoint point) { |
||||
throw UnimplementedError(); |
||||
} |
||||
|
||||
@override |
||||
void showAutocorrectionPromptRect(int start, int end) { |
||||
throw UnimplementedError(); |
||||
} |
||||
|
||||
@override |
||||
void connectionClosed() { |
||||
if (!hasConnection) { |
||||
return; |
||||
} |
||||
_textInputConnection!.connectionClosedReceived(); |
||||
_textInputConnection = null; |
||||
_lastKnownRemoteTextEditingValue = null; |
||||
_sentRemoteValues.clear(); |
||||
} |
||||
} |
Loading…
Reference in new issue