parent
6105134f8a
commit
abd305582c
13 changed files with 578 additions and 1081 deletions
@ -1,354 +0,0 @@ |
|||||||
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)); |
|
||||||
} |
|
||||||
} |
|
@ -1,40 +0,0 @@ |
|||||||
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; |
|
||||||
} |
|
@ -1,200 +0,0 @@ |
|||||||
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(); |
|
||||||
} |
|
||||||
} |
|
@ -1,344 +0,0 @@ |
|||||||
import 'dart:convert'; |
|
||||||
import 'dart:io' as io; |
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart'; |
|
||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:flutter/rendering.dart'; |
|
||||||
import 'package:flutter/services.dart'; |
|
||||||
import 'package:string_validator/string_validator.dart'; |
|
||||||
import 'package:tuple/tuple.dart'; |
|
||||||
|
|
||||||
import '../models/documents/attribute.dart'; |
|
||||||
import '../models/documents/document.dart'; |
|
||||||
import '../models/documents/nodes/block.dart'; |
|
||||||
import '../models/documents/nodes/leaf.dart' as leaf; |
|
||||||
import '../models/documents/nodes/line.dart'; |
|
||||||
import 'controller.dart'; |
|
||||||
import 'cursor.dart'; |
|
||||||
import 'default_styles.dart'; |
|
||||||
import 'delegate.dart'; |
|
||||||
import 'editor.dart'; |
|
||||||
import 'text_block.dart'; |
|
||||||
import 'text_line.dart'; |
|
||||||
|
|
||||||
class QuillSimpleViewer extends StatefulWidget { |
|
||||||
const QuillSimpleViewer({ |
|
||||||
required this.controller, |
|
||||||
this.customStyles, |
|
||||||
this.truncate = false, |
|
||||||
this.truncateScale, |
|
||||||
this.truncateAlignment, |
|
||||||
this.truncateHeight, |
|
||||||
this.truncateWidth, |
|
||||||
this.scrollBottomInset = 0, |
|
||||||
this.padding = EdgeInsets.zero, |
|
||||||
this.embedBuilder, |
|
||||||
Key? key, |
|
||||||
}) : assert(truncate || |
|
||||||
((truncateScale == null) && |
|
||||||
(truncateAlignment == null) && |
|
||||||
(truncateHeight == null) && |
|
||||||
(truncateWidth == null))), |
|
||||||
super(key: key); |
|
||||||
|
|
||||||
final QuillController controller; |
|
||||||
final DefaultStyles? customStyles; |
|
||||||
final bool truncate; |
|
||||||
final double? truncateScale; |
|
||||||
final Alignment? truncateAlignment; |
|
||||||
final double? truncateHeight; |
|
||||||
final double? truncateWidth; |
|
||||||
final double scrollBottomInset; |
|
||||||
final EdgeInsetsGeometry padding; |
|
||||||
final EmbedBuilder? embedBuilder; |
|
||||||
|
|
||||||
@override |
|
||||||
_QuillSimpleViewerState createState() => _QuillSimpleViewerState(); |
|
||||||
} |
|
||||||
|
|
||||||
class _QuillSimpleViewerState extends State<QuillSimpleViewer> |
|
||||||
with SingleTickerProviderStateMixin { |
|
||||||
late DefaultStyles _styles; |
|
||||||
final LayerLink _toolbarLayerLink = LayerLink(); |
|
||||||
final LayerLink _startHandleLayerLink = LayerLink(); |
|
||||||
final LayerLink _endHandleLayerLink = LayerLink(); |
|
||||||
late CursorCont _cursorCont; |
|
||||||
|
|
||||||
@override |
|
||||||
void initState() { |
|
||||||
super.initState(); |
|
||||||
|
|
||||||
_cursorCont = CursorCont( |
|
||||||
show: ValueNotifier<bool>(false), |
|
||||||
style: const CursorStyle( |
|
||||||
color: Colors.black, |
|
||||||
backgroundColor: Colors.grey, |
|
||||||
width: 2, |
|
||||||
radius: Radius.zero, |
|
||||||
offset: Offset.zero, |
|
||||||
), |
|
||||||
tickerProvider: this, |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
@override |
|
||||||
void didChangeDependencies() { |
|
||||||
super.didChangeDependencies(); |
|
||||||
final parentStyles = QuillStyles.getStyles(context, true); |
|
||||||
final defaultStyles = DefaultStyles.getInstance(context); |
|
||||||
_styles = (parentStyles != null) |
|
||||||
? defaultStyles.merge(parentStyles) |
|
||||||
: defaultStyles; |
|
||||||
|
|
||||||
if (widget.customStyles != null) { |
|
||||||
_styles = _styles.merge(widget.customStyles!); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
EmbedBuilder get embedBuilder => widget.embedBuilder ?? _defaultEmbedBuilder; |
|
||||||
|
|
||||||
Widget _defaultEmbedBuilder(BuildContext context, leaf.Embed node) { |
|
||||||
assert(!kIsWeb, 'Please provide EmbedBuilder for Web'); |
|
||||||
switch (node.value.type) { |
|
||||||
case 'image': |
|
||||||
final imageUrl = _standardizeImageUrl(node.value.data); |
|
||||||
return imageUrl.startsWith('http') |
|
||||||
? Image.network(imageUrl) |
|
||||||
: isBase64(imageUrl) |
|
||||||
? Image.memory(base64.decode(imageUrl)) |
|
||||||
: Image.file(io.File(imageUrl)); |
|
||||||
default: |
|
||||||
throw UnimplementedError( |
|
||||||
'Embeddable type "${node.value.type}" is not supported by default embed ' |
|
||||||
'builder of QuillEditor. You must pass your own builder function to ' |
|
||||||
'embedBuilder property of QuillEditor or QuillField widgets.'); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
String _standardizeImageUrl(String url) { |
|
||||||
if (url.contains('base64')) { |
|
||||||
return url.split(',')[1]; |
|
||||||
} |
|
||||||
return url; |
|
||||||
} |
|
||||||
|
|
||||||
@override |
|
||||||
Widget build(BuildContext context) { |
|
||||||
final _doc = widget.controller.document; |
|
||||||
// if (_doc.isEmpty() && |
|
||||||
// !widget.focusNode.hasFocus && |
|
||||||
// widget.placeholder != null) { |
|
||||||
// _doc = Document.fromJson(jsonDecode( |
|
||||||
// '[{"attributes":{"placeholder":true},"insert":"${widget.placeholder}\\n"}]')); |
|
||||||
// } |
|
||||||
|
|
||||||
Widget child = CompositedTransformTarget( |
|
||||||
link: _toolbarLayerLink, |
|
||||||
child: Semantics( |
|
||||||
child: _SimpleViewer( |
|
||||||
document: _doc, |
|
||||||
textDirection: _textDirection, |
|
||||||
startHandleLayerLink: _startHandleLayerLink, |
|
||||||
endHandleLayerLink: _endHandleLayerLink, |
|
||||||
onSelectionChanged: _nullSelectionChanged, |
|
||||||
scrollBottomInset: widget.scrollBottomInset, |
|
||||||
padding: widget.padding, |
|
||||||
children: _buildChildren(_doc, context), |
|
||||||
), |
|
||||||
), |
|
||||||
); |
|
||||||
|
|
||||||
if (widget.truncate) { |
|
||||||
if (widget.truncateScale != null) { |
|
||||||
child = Container( |
|
||||||
height: widget.truncateHeight, |
|
||||||
child: Align( |
|
||||||
heightFactor: widget.truncateScale, |
|
||||||
widthFactor: widget.truncateScale, |
|
||||||
alignment: widget.truncateAlignment ?? Alignment.topLeft, |
|
||||||
child: Container( |
|
||||||
width: widget.truncateWidth! / widget.truncateScale!, |
|
||||||
child: SingleChildScrollView( |
|
||||||
physics: const NeverScrollableScrollPhysics(), |
|
||||||
child: Transform.scale( |
|
||||||
scale: widget.truncateScale!, |
|
||||||
alignment: |
|
||||||
widget.truncateAlignment ?? Alignment.topLeft, |
|
||||||
child: child))))); |
|
||||||
} else { |
|
||||||
child = Container( |
|
||||||
height: widget.truncateHeight, |
|
||||||
width: widget.truncateWidth, |
|
||||||
child: SingleChildScrollView( |
|
||||||
physics: const NeverScrollableScrollPhysics(), child: child)); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return QuillStyles(data: _styles, child: child); |
|
||||||
} |
|
||||||
|
|
||||||
List<Widget> _buildChildren(Document doc, BuildContext context) { |
|
||||||
final result = <Widget>[]; |
|
||||||
final indentLevelCounts = <int, int>{}; |
|
||||||
for (final node in doc.root.children) { |
|
||||||
if (node is Line) { |
|
||||||
final editableTextLine = _getEditableTextLineFromNode(node, context); |
|
||||||
result.add(editableTextLine); |
|
||||||
} else if (node is Block) { |
|
||||||
final attrs = node.style.attributes; |
|
||||||
final editableTextBlock = EditableTextBlock( |
|
||||||
node, |
|
||||||
_textDirection, |
|
||||||
widget.scrollBottomInset, |
|
||||||
_getVerticalSpacingForBlock(node, _styles), |
|
||||||
widget.controller.selection, |
|
||||||
Colors.black, |
|
||||||
// selectionColor, |
|
||||||
_styles, |
|
||||||
false, |
|
||||||
// enableInteractiveSelection, |
|
||||||
false, |
|
||||||
// hasFocus, |
|
||||||
attrs.containsKey(Attribute.codeBlock.key) |
|
||||||
? const EdgeInsets.all(16) |
|
||||||
: null, |
|
||||||
embedBuilder, |
|
||||||
_cursorCont, |
|
||||||
indentLevelCounts, |
|
||||||
_handleCheckboxTap); |
|
||||||
result.add(editableTextBlock); |
|
||||||
} else { |
|
||||||
throw StateError('Unreachable.'); |
|
||||||
} |
|
||||||
} |
|
||||||
return result; |
|
||||||
} |
|
||||||
|
|
||||||
/// Updates the checkbox positioned at [offset] in document |
|
||||||
/// by changing its attribute according to [value]. |
|
||||||
void _handleCheckboxTap(int offset, bool value) { |
|
||||||
// readonly - do nothing |
|
||||||
} |
|
||||||
|
|
||||||
TextDirection get _textDirection { |
|
||||||
final result = Directionality.of(context); |
|
||||||
return result; |
|
||||||
} |
|
||||||
|
|
||||||
EditableTextLine _getEditableTextLineFromNode( |
|
||||||
Line node, BuildContext context) { |
|
||||||
final textLine = TextLine( |
|
||||||
line: node, |
|
||||||
textDirection: _textDirection, |
|
||||||
embedBuilder: embedBuilder, |
|
||||||
styles: _styles, |
|
||||||
); |
|
||||||
final editableTextLine = EditableTextLine( |
|
||||||
node, |
|
||||||
null, |
|
||||||
textLine, |
|
||||||
0, |
|
||||||
_getVerticalSpacingForLine(node, _styles), |
|
||||||
_textDirection, |
|
||||||
widget.controller.selection, |
|
||||||
Colors.black, |
|
||||||
//widget.selectionColor, |
|
||||||
false, |
|
||||||
//enableInteractiveSelection, |
|
||||||
false, |
|
||||||
//_hasFocus, |
|
||||||
MediaQuery.of(context).devicePixelRatio, |
|
||||||
_cursorCont); |
|
||||||
return editableTextLine; |
|
||||||
} |
|
||||||
|
|
||||||
Tuple2<double, double> _getVerticalSpacingForLine( |
|
||||||
Line line, DefaultStyles? defaultStyles) { |
|
||||||
final attrs = line.style.attributes; |
|
||||||
if (attrs.containsKey(Attribute.header.key)) { |
|
||||||
final int? level = attrs[Attribute.header.key]!.value; |
|
||||||
switch (level) { |
|
||||||
case 1: |
|
||||||
return defaultStyles!.h1!.verticalSpacing; |
|
||||||
case 2: |
|
||||||
return defaultStyles!.h2!.verticalSpacing; |
|
||||||
case 3: |
|
||||||
return defaultStyles!.h3!.verticalSpacing; |
|
||||||
default: |
|
||||||
throw 'Invalid level $level'; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return defaultStyles!.paragraph!.verticalSpacing; |
|
||||||
} |
|
||||||
|
|
||||||
Tuple2<double, double> _getVerticalSpacingForBlock( |
|
||||||
Block node, DefaultStyles? defaultStyles) { |
|
||||||
final attrs = node.style.attributes; |
|
||||||
if (attrs.containsKey(Attribute.blockQuote.key)) { |
|
||||||
return defaultStyles!.quote!.verticalSpacing; |
|
||||||
} else if (attrs.containsKey(Attribute.codeBlock.key)) { |
|
||||||
return defaultStyles!.code!.verticalSpacing; |
|
||||||
} else if (attrs.containsKey(Attribute.indent.key)) { |
|
||||||
return defaultStyles!.indent!.verticalSpacing; |
|
||||||
} |
|
||||||
return defaultStyles!.lists!.verticalSpacing; |
|
||||||
} |
|
||||||
|
|
||||||
void _nullSelectionChanged( |
|
||||||
TextSelection selection, SelectionChangedCause cause) {} |
|
||||||
} |
|
||||||
|
|
||||||
class _SimpleViewer extends MultiChildRenderObjectWidget { |
|
||||||
_SimpleViewer({ |
|
||||||
required List<Widget> children, |
|
||||||
required this.document, |
|
||||||
required this.textDirection, |
|
||||||
required this.startHandleLayerLink, |
|
||||||
required this.endHandleLayerLink, |
|
||||||
required this.onSelectionChanged, |
|
||||||
required this.scrollBottomInset, |
|
||||||
this.padding = EdgeInsets.zero, |
|
||||||
Key? key, |
|
||||||
}) : super(key: key, children: children); |
|
||||||
|
|
||||||
final Document document; |
|
||||||
final TextDirection textDirection; |
|
||||||
final LayerLink startHandleLayerLink; |
|
||||||
final LayerLink endHandleLayerLink; |
|
||||||
final TextSelectionChangedHandler onSelectionChanged; |
|
||||||
final double scrollBottomInset; |
|
||||||
final EdgeInsetsGeometry padding; |
|
||||||
|
|
||||||
@override |
|
||||||
RenderEditor createRenderObject(BuildContext context) { |
|
||||||
return RenderEditor( |
|
||||||
null, |
|
||||||
textDirection, |
|
||||||
scrollBottomInset, |
|
||||||
padding, |
|
||||||
document, |
|
||||||
const TextSelection(baseOffset: 0, extentOffset: 0), |
|
||||||
false, |
|
||||||
// hasFocus, |
|
||||||
onSelectionChanged, |
|
||||||
startHandleLayerLink, |
|
||||||
endHandleLayerLink, |
|
||||||
const EdgeInsets.fromLTRB(4, 4, 4, 5), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
@override |
|
||||||
void updateRenderObject( |
|
||||||
BuildContext context, covariant RenderEditor renderObject) { |
|
||||||
renderObject |
|
||||||
..document = document |
|
||||||
..setContainer(document.root) |
|
||||||
..textDirection = textDirection |
|
||||||
..setStartHandleLayerLink(startHandleLayerLink) |
|
||||||
..setEndHandleLayerLink(endHandleLayerLink) |
|
||||||
..onSelectionChanged = onSelectionChanged |
|
||||||
..setScrollBottomInset(scrollBottomInset) |
|
||||||
..setPadding(padding); |
|
||||||
} |
|
||||||
} |
|
Loading…
Reference in new issue