blob: 658c8649f9c799791057093769b4dbf93aaddebb [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:html' as html;
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:meta/meta.dart';
import 'package:ui/ui.dart' as ui;
import '../browser_detection.dart';
import '../dom.dart';
import '../embedder.dart';
import '../host_node.dart';
import '../platform_dispatcher.dart';
import '../safe_browser_api.dart';
import '../semantics.dart';
import '../services.dart';
import '../text/paragraph.dart';
import '../util.dart';
import 'autofill_hint.dart';
import 'composition_aware_mixin.dart';
import 'input_type.dart';
import 'text_capitalization.dart';
/// Make the content editable span visible to facilitate debugging.
bool _debugVisibleTextEditing = false;
/// Set this to `true` to print when text input commands are scheduled and run.
bool _debugPrintTextInputCommands = false;
/// The `keyCode` of the "Enter" key.
const int _kReturnKeyCode = 13;
/// Blink and Webkit engines, bring an overlay on top of the text field when it
/// is autofilled.
bool browserHasAutofillOverlay() =>
browserEngine == BrowserEngine.blink ||
browserEngine == BrowserEngine.samsung ||
browserEngine == BrowserEngine.webkit;
/// `transparentTextEditing` class is configured to make the autofill overlay
/// transparent.
const String transparentTextEditingClass = 'transparentTextEditing';
void _emptyCallback(dynamic _) {}
/// The default [HostNode] that hosts all DOM required for text editing when a11y is not enabled.
@visibleForTesting
HostNode get defaultTextEditingRoot => flutterViewEmbedder.glassPaneShadow!;
/// These style attributes are constant throughout the life time of an input
/// element.
///
/// They are assigned once during the creation of the DOM element.
void _setStaticStyleAttributes(DomHTMLElement domElement) {
domElement.classList.add(HybridTextEditing.textEditingClass);
final DomCSSStyleDeclaration elementStyle = domElement.style;
elementStyle
..whiteSpace = 'pre-wrap'
..alignContent = 'center'
..position = 'absolute'
..top = '0'
..left = '0'
..padding = '0'
..opacity = '1'
..color = 'transparent'
..backgroundColor = 'transparent'
..background = 'transparent'
..outline = 'none'
..border = 'none'
..resize = 'none'
..textShadow = 'transparent'
..overflow = 'hidden'
..transformOrigin = '0 0 0';
if (browserHasAutofillOverlay()) {
domElement.classList.add(transparentTextEditingClass);
}
// This property makes the input's blinking cursor transparent.
elementStyle.setProperty('caret-color', 'transparent');
if (_debugVisibleTextEditing) {
elementStyle
..color = 'purple'
..outline = '1px solid purple';
}
}
/// Sets attributes to hide autofill elements.
///
/// These style attributes are constant throughout the life time of an input
/// element.
///
/// They are assigned once during the creation of the DOM element.
void _hideAutofillElements(DomHTMLElement domElement,
{bool isOffScreen = false}) {
final DomCSSStyleDeclaration elementStyle = domElement.style;
elementStyle
..whiteSpace = 'pre-wrap'
..alignContent = 'center'
..padding = '0'
..opacity = '1'
..color = 'transparent'
..backgroundColor = 'transparent'
..background = 'transparent'
..outline = 'none'
..border = 'none'
..resize = 'none'
..width = '0'
..height = '0'
..textShadow = 'transparent'
..transformOrigin = '0 0 0';
if (isOffScreen) {
elementStyle
..top = '-9999px'
..left = '-9999px';
}
if (browserHasAutofillOverlay()) {
domElement.classList.add(transparentTextEditingClass);
}
/// This property makes the input's blinking cursor transparent.
elementStyle.setProperty('caret-color', 'transparent');
}
/// Form that contains all the fields in the same AutofillGroup.
///
/// An [EngineAutofillForm] will only be constructed when autofill is enabled
/// (the default) on the current input field. See the [fromFrameworkMessage]
/// static method.
class EngineAutofillForm {
EngineAutofillForm({
required this.formElement,
this.elements,
this.items,
this.formIdentifier = '',
});
final DomHTMLFormElement formElement;
final Map<String, DomHTMLElement>? elements;
final Map<String, AutofillInfo>? items;
/// Identifier for the form.
///
/// It is constructed by concatenating unique ids of input elements on the
/// form.
///
/// It is used for storing the form until submission.
/// See [formsOnTheDom].
final String formIdentifier;
/// Creates an [EngineAutofillFrom] from the JSON representation of a Flutter
/// framework `TextInputConfiguration` object.
///
/// The `focusedElementAutofill` argument corresponds to the "autofill" field
/// in a `TextInputConfiguration`. Not having this field indicates autofill
/// is explicitly disabled on the text field by the developer.
///
/// The `fields` argument corresponds to the "fields" field in a
/// `TextInputConfiguration`.
///
/// Returns null if autofill is disabled for the input field.
static EngineAutofillForm? fromFrameworkMessage(
Map<String, dynamic>? focusedElementAutofill,
List<dynamic>? fields,
) {
// Autofill value will be null if the developer explicitly disables it on
// the input field.
if (focusedElementAutofill == null) {
return null;
}
// If there is only one text field in the autofill model, `fields` will be
// null. `focusedElementAutofill` contains the information about the one
// text field.
final Map<String, DomHTMLElement> elements = <String, DomHTMLElement>{};
final Map<String, AutofillInfo> items = <String, AutofillInfo>{};
final DomHTMLFormElement formElement = createDomHTMLFormElement();
// Validation is in the framework side.
formElement.noValidate = true;
formElement.method = 'post';
formElement.action = '#';
formElement.addEventListener('submit', allowInterop((DomEvent e) {
e.preventDefault();
}));
_hideAutofillElements(formElement);
// We keep the ids in a list then sort them later, in case the text fields'
// locations are re-ordered on the framework side.
final List<String> ids = List<String>.empty(growable: true);
// The focused text editing element will not be created here.
final AutofillInfo focusedElement =
AutofillInfo.fromFrameworkMessage(focusedElementAutofill);
if (fields != null) {
for (final Map<String, dynamic> field in
fields.cast<Map<String, dynamic>>()) {
final Map<String, dynamic> autofillInfo = field.readJson('autofill');
final AutofillInfo autofill = AutofillInfo.fromFrameworkMessage(
autofillInfo,
textCapitalization: TextCapitalizationConfig.fromInputConfiguration(
field.readString('textCapitalization'),
),
);
ids.add(autofill.uniqueIdentifier);
if (autofill.uniqueIdentifier != focusedElement.uniqueIdentifier) {
final EngineInputType engineInputType = EngineInputType.fromName(
field.readJson('inputType').readString('name'),
);
final DomHTMLElement htmlElement = engineInputType.createDomElement();
autofill.editingState.applyToDomElement(htmlElement);
autofill.applyToDomElement(htmlElement);
_hideAutofillElements(htmlElement);
items[autofill.uniqueIdentifier] = autofill;
elements[autofill.uniqueIdentifier] = htmlElement;
formElement.append(htmlElement);
}
}
} else {
// There is one input element in the form.
ids.add(focusedElement.uniqueIdentifier);
}
ids.sort();
final StringBuffer idBuffer = StringBuffer();
// Add a separator between element identifiers.
for (final String id in ids) {
if (idBuffer.length > 0) {
idBuffer.write('*');
}
idBuffer.write(id);
}
final String formIdentifier = idBuffer.toString();
// If a form with the same Autofill elements is already on the dom, remove
// it from DOM.
final DomHTMLFormElement? form = formsOnTheDom[formIdentifier];
form?.remove();
// In order to submit the form when Framework sends a `TextInput.commit`
// message, we add a submit button to the form.
final DomHTMLInputElement submitButton = createDomHTMLInputElement();
_hideAutofillElements(submitButton, isOffScreen: true);
submitButton.className = 'submitBtn';
submitButton.type = 'submit';
formElement.append(submitButton);
return EngineAutofillForm(
formElement: formElement,
elements: elements,
items: items,
formIdentifier: formIdentifier,
);
}
void placeForm(DomHTMLElement mainTextEditingElement) {
formElement.append(mainTextEditingElement);
defaultTextEditingRoot.append(formElement);
}
void storeForm() {
formsOnTheDom[formIdentifier] = formElement;
_hideAutofillElements(formElement, isOffScreen: true);
}
/// Listens to `onInput` event on the form fields.
///
/// Registering to the listeners could have been done in the constructor.
/// On the other hand, overall for text editing there is already a lifecycle
/// for subscriptions: All the subscriptions of the DOM elements are to the
/// `subscriptions` property of [DefaultTextEditingStrategy].
/// [TextEditingStrategy] manages all subscription lifecyle. All
/// listeners with no exceptions are added during
/// [TextEditingStrategy.addEventHandlers] method call and all
/// listeners are removed during [TextEditingStrategy.disable] method call.
List<DomSubscription> addInputEventListeners() {
final Iterable<String> keys = elements!.keys;
final List<DomSubscription> subscriptions = <DomSubscription>[];
void addSubscriptionForKey(String key) {
final DomElement element = elements![key]!;
subscriptions.add(
DomSubscription(element, 'input',
allowInterop((DomEvent e) {
if (items![key] == null) {
throw StateError(
'AutofillInfo must have a valid uniqueIdentifier.');
} else {
final AutofillInfo autofillInfo = items![key]!;
handleChange(element, autofillInfo);
}
})));
}
keys.forEach(addSubscriptionForKey);
return subscriptions;
}
void handleChange(DomElement domElement, AutofillInfo autofillInfo) {
final EditingState newEditingState = EditingState.fromDomElement(
domElement as DomHTMLElement);
_sendAutofillEditingState(autofillInfo.uniqueIdentifier, newEditingState);
}
/// Sends the 'TextInputClient.updateEditingStateWithTag' message to the framework.
void _sendAutofillEditingState(String? tag, EditingState editingState) {
EnginePlatformDispatcher.instance.invokeOnPlatformMessage(
'flutter/textinput',
const JSONMethodCodec().encodeMethodCall(
MethodCall(
'TextInputClient.updateEditingStateWithTag',
<dynamic>[
0,
<String?, dynamic>{tag: editingState.toFlutter()}
],
),
),
_emptyCallback,
);
}
}
/// Autofill related values.
///
/// These values are to be used when a text field have autofill enabled.
@visibleForTesting
class AutofillInfo {
AutofillInfo({
required this.editingState,
required this.uniqueIdentifier,
required this.autofillHint,
required this.textCapitalization,
this.placeholder,
});
/// The current text and selection state of a text field.
final EditingState editingState;
/// Unique value set by the developer or generated by the framework.
///
/// Used as id of the text field.
///
/// An example an id generated by the framework: `EditableText-285283643`.
final String uniqueIdentifier;
/// Information on how should autofilled text capitalized.
///
/// For example for [TextCapitalization.characters] each letter is converted
/// to upper case.
///
/// This value is not necessary for autofilling the focused element since
/// [DefaultTextEditingStrategy.inputConfiguration] already has this
/// information.
///
/// On the other hand for the multi element forms, for the input elements
/// other the focused field, we need to use this information.
final TextCapitalizationConfig textCapitalization;
/// The type of information expected in the field, specified by the developer.
///
/// Used as a guidance to the browser as to the type of information expected
/// in the field.
/// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete
final String? autofillHint;
/// The optional hint text placed on the view that typically suggests what
/// sort of input the field accepts, for example "enter your password here".
///
/// If the developer does not specify any [autofillHints], the [placeholder]
/// can be a useful indication to the platform autofill service as to what
/// information is expected in this field.
final String? placeholder;
factory AutofillInfo.fromFrameworkMessage(Map<String, dynamic> autofill,
{TextCapitalizationConfig textCapitalization =
const TextCapitalizationConfig.defaultCapitalization()}) {
assert(autofill != null); // ignore: unnecessary_null_comparison
final String uniqueIdentifier = autofill.readString('uniqueIdentifier');
final List<dynamic>? hintsList = autofill.tryList('hints');
final String? firstHint = (hintsList == null || hintsList.isEmpty) ? null : hintsList.first as String;
final EditingState editingState =
EditingState.fromFrameworkMessage(autofill.readJson('editingValue'));
return AutofillInfo(
uniqueIdentifier: uniqueIdentifier,
autofillHint: (firstHint != null) ? BrowserAutofillHints.instance.flutterToEngine(firstHint) : null,
editingState: editingState,
placeholder: autofill.tryString('hintText'),
textCapitalization: textCapitalization,
);
}
void applyToDomElement(DomHTMLElement domElement,
{bool focusedElement = false}) {
final String? autofillHint = this.autofillHint;
final String? placeholder = this.placeholder;
if (domInstanceOfString(domElement, 'HTMLInputElement')) {
final DomHTMLInputElement element = domElement as DomHTMLInputElement;
if (placeholder != null) {
element.placeholder = placeholder;
}
if (autofillHint != null) {
element.name = autofillHint;
element.id = autofillHint;
if (autofillHint.contains('password')) {
element.type = 'password';
} else {
element.type = 'text';
}
}
element.autocomplete = autofillHint ?? 'on';
} else if (domInstanceOfString(domElement, 'HTMLTextAreaElement')) {
final DomHTMLTextAreaElement element = domElement as DomHTMLTextAreaElement;
if (placeholder != null) {
element.placeholder = placeholder;
}
if (autofillHint != null) {
element.name = autofillHint;
element.id = autofillHint;
}
element.setAttribute('autocomplete', autofillHint ?? 'on');
}
}
}
/// Replaces a range of text in the original string with the text given in the
/// replacement string.
String _replace(String originalText, String replacementText, ui.TextRange replacedRange) {
assert(replacedRange.isValid);
assert(replacedRange.start <= originalText.length && replacedRange.end <= originalText.length);
final ui.TextRange normalizedRange = ui.TextRange(start: math.min(replacedRange.start, replacedRange.end), end: math.max(replacedRange.start, replacedRange.end));
return normalizedRange.textBefore(originalText) + replacementText + normalizedRange.textAfter(originalText);
}
/// The change between the last editing state and the current editing state
/// of a text field.
///
/// This is packaged into a JSON and sent to the framework
/// to be processed into a concrete [TextEditingDelta].
class TextEditingDeltaState {
TextEditingDeltaState({
this.oldText = '',
this.deltaText = '',
this.deltaStart = -1,
this.deltaEnd = -1,
this.baseOffset,
this.extentOffset,
this.composingOffset,
this.composingExtent,
});
/// Infers the correct delta values based on information from the new editing state
/// and the last editing state.
///
/// For a deletion we calculate the length of the deleted text by comparing the new
/// and last editing states. We subtract this from the [deltaEnd] that we set when beforeinput
/// was fired to determine the [deltaStart].
///
/// For a replacement at a selection we set the [deltaStart] to be the beginning of the selection
/// from the last editing state.
///
/// For the composing region we check if a composing range was captured by the compositionupdate event,
/// we have a non empty [deltaText], and that we did not have an active selection. An active selection
/// would mean we are not composing.
///
/// We then verify that the delta we collected results in the text contained within the new editing state
/// when applied to the last editing state. If it is not then we use our new editing state as the source of truth,
/// and use regex to find the correct [deltaStart] and [deltaEnd].
static TextEditingDeltaState inferDeltaState(EditingState newEditingState, EditingState? lastEditingState, TextEditingDeltaState lastTextEditingDeltaState) {
final TextEditingDeltaState newTextEditingDeltaState = lastTextEditingDeltaState.copyWith();
final bool previousSelectionWasCollapsed = lastEditingState?.baseOffset == lastEditingState?.extentOffset;
final bool isTextBeingRemoved = newTextEditingDeltaState.deltaText.isEmpty && newTextEditingDeltaState.deltaEnd != -1;
final bool isTextBeingChangedAtActiveSelection = newTextEditingDeltaState.deltaText.isNotEmpty && !previousSelectionWasCollapsed;
if (isTextBeingRemoved) {
// When text is deleted outside of the composing region or is cut using the native toolbar,
// we calculate the length of the deleted text by comparing the new and old editing state lengths.
// This value is then subtracted from the end position of the delta to capture the deleted range.
final int deletedLength = newTextEditingDeltaState.oldText.length - newEditingState.text!.length;
newTextEditingDeltaState.deltaStart = newTextEditingDeltaState.deltaEnd - deletedLength;
} else if (isTextBeingChangedAtActiveSelection) {
// When a selection of text is replaced by a copy/paste operation we set the starting range
// of the delta to be the beginning of the selection of the previous editing state.
newTextEditingDeltaState.deltaStart = lastEditingState!.baseOffset!;
}
// If we are composing then set the delta range to the composing region we
// captured in compositionupdate.
final bool isCurrentlyComposing = newTextEditingDeltaState.composingOffset != null && newTextEditingDeltaState.composingOffset != newTextEditingDeltaState.composingExtent;
if (newTextEditingDeltaState.deltaText.isNotEmpty && previousSelectionWasCollapsed && isCurrentlyComposing) {
newTextEditingDeltaState.deltaStart = newTextEditingDeltaState.composingOffset!;
}
final bool isDeltaRangeEmpty = newTextEditingDeltaState.deltaStart == -1 && newTextEditingDeltaState.deltaStart == newTextEditingDeltaState.deltaEnd;
if (!isDeltaRangeEmpty) {
// To verify the range of our delta we should compare the newEditingState's
// text with the delta applied to the oldText. If they differ then capture
// the correct delta range from the newEditingState's text value.
//
// We can assume the deltaText for additions and replacements to the text value
// are accurate. What may not be accurate is the range of the delta.
//
// We can think of the newEditingState as our source of truth.
//
// This verification is needed for cases such as the insertion of a period
// after a double space, and the insertion of an accented character through
// a native composing menu.
final ui.TextRange replacementRange = ui.TextRange(start: newTextEditingDeltaState.deltaStart, end: newTextEditingDeltaState.deltaEnd);
final String textAfterDelta = _replace(
newTextEditingDeltaState.oldText, newTextEditingDeltaState.deltaText,
replacementRange);
final bool isDeltaVerified = textAfterDelta == newEditingState.text!;
if (!isDeltaVerified) {
// 1. Find all matches for deltaText.
// 2. Apply matches/replacement to oldText until oldText matches the
// new editing state's text value.
final bool isPeriodInsertion = newTextEditingDeltaState.deltaText.contains('.');
final RegExp deltaTextPattern = RegExp(RegExp.escape(newTextEditingDeltaState.deltaText));
for (final Match match in deltaTextPattern.allMatches(newEditingState.text!)) {
String textAfterMatch;
int actualEnd;
final bool isMatchWithinOldTextBounds = match.start >= 0 && match.end <= newTextEditingDeltaState.oldText.length;
if (!isMatchWithinOldTextBounds) {
actualEnd = match.start + newTextEditingDeltaState.deltaText.length - 1;
textAfterMatch = _replace(
newTextEditingDeltaState.oldText,
newTextEditingDeltaState.deltaText,
ui.TextRange(
start: match.start,
end: actualEnd,
),
);
} else {
actualEnd = actualEnd = isPeriodInsertion? match.end - 1 : match.end;
textAfterMatch = _replace(
newTextEditingDeltaState.oldText,
newTextEditingDeltaState.deltaText,
ui.TextRange(
start: match.start,
end: actualEnd,
),
);
}
if (textAfterMatch == newEditingState.text!) {
newTextEditingDeltaState.deltaStart = match.start;
newTextEditingDeltaState.deltaEnd = actualEnd;
break;
}
}
}
}
// Update selection of the delta using information from the new editing state.
newTextEditingDeltaState.baseOffset = newEditingState.baseOffset!;
newTextEditingDeltaState.extentOffset = newEditingState.extentOffset!;
return newTextEditingDeltaState;
}
/// The text before the text field was updated.
String oldText;
/// The text that is being inserted/replaced into the text field.
/// This will be an empty string for deletions and non text updates
/// such as selection updates.
String deltaText;
/// The position in the text field where the change begins.
///
/// Has a default value of -1 to signify an empty range.
int deltaStart;
/// The position in the text field where the change ends.
///
/// Has a default value of -1 to signify an empty range.
int deltaEnd;
/// The updated starting position of the selection in the text field.
int? baseOffset;
/// The updated terminating position of the selection in the text field.
int? extentOffset;
/// The starting position of the composing region.
int? composingOffset;
/// The terminating position of the composing region.
int? composingExtent;
Map<String, dynamic> toFlutter() => <String, dynamic>{
'deltas': <Map<String, dynamic>>[
<String, dynamic>{
'oldText': oldText,
'deltaText': deltaText,
'deltaStart': deltaStart,
'deltaEnd': deltaEnd,
'selectionBase': baseOffset,
'selectionExtent': extentOffset,
'composingBase': composingOffset,
'composingExtent': composingExtent
},
],
};
TextEditingDeltaState copyWith({
String? oldText,
String? deltaText,
int? deltaStart,
int? deltaEnd,
int? baseOffset,
int? extentOffset,
int? composingOffset,
int? composingExtent,
}) {
return TextEditingDeltaState(
oldText: oldText ?? this.oldText,
deltaText: deltaText ?? this.deltaText,
deltaStart: deltaStart ?? this.deltaStart,
deltaEnd: deltaEnd ?? this.deltaEnd,
baseOffset: baseOffset ?? this.baseOffset,
extentOffset: extentOffset ?? this.extentOffset,
composingOffset: composingOffset ?? this.composingOffset,
composingExtent: composingExtent ?? this.composingExtent,
);
}
}
/// The current text and selection state of a text field.
class EditingState {
EditingState({
this.text,
int? baseOffset,
int? extentOffset,
this.composingBaseOffset,
this.composingExtentOffset
}) :
// Don't allow negative numbers. Pick the smallest selection index for base.
baseOffset = math.max(0, math.min(baseOffset ?? 0, extentOffset ?? 0)),
// Don't allow negative numbers. Pick the greatest selection index for extent.
extentOffset = math.max(0, math.max(baseOffset ?? 0, extentOffset ?? 0));
/// Creates an [EditingState] instance using values from an editing state Map
/// coming from Flutter.
///
/// The `editingState` Map has the following structure:
/// ```json
/// {
/// "text": "The text here",
/// "selectionBase": 0,
/// "selectionExtent": 0,
/// "selectionAffinity": "TextAffinity.upstream",
/// "selectionIsDirectional": false,
/// "composingBase": -1,
/// "composingExtent": -1
/// }
/// ```
///
/// Flutter Framework can send the [selectionBase] and [selectionExtent] as
/// -1, if so 0 assigned to the [baseOffset] and [extentOffset]. -1 is not a
/// valid selection range for input DOM elements.
factory EditingState.fromFrameworkMessage(
Map<String, dynamic> flutterEditingState) {
final String? text = flutterEditingState.tryString('text');
final int selectionBase = flutterEditingState.readInt('selectionBase');
final int selectionExtent = flutterEditingState.readInt('selectionExtent');
final int? composingBase = flutterEditingState.tryInt('composingBase');
final int? composingExtent = flutterEditingState.tryInt('composingExtent');
return EditingState(
text: text,
baseOffset: selectionBase,
extentOffset: selectionExtent,
composingBaseOffset: composingBase,
composingExtentOffset: composingExtent
);
}
/// Creates an [EditingState] instance using values from the editing element
/// in the DOM.
///
/// [domElement] can be a [InputElement] or a [TextAreaElement] depending on
/// the [InputType] of the text field.
factory EditingState.fromDomElement(DomHTMLElement? domElement) {
if (domInstanceOfString(domElement, 'HTMLInputElement')) {
final DomHTMLInputElement element = domElement! as DomHTMLInputElement;
return EditingState(
text: element.value,
baseOffset: element.selectionStart,
extentOffset: element.selectionEnd);
} else if (domInstanceOfString(domElement, 'HTMLTextAreaElement')) {
final DomHTMLTextAreaElement element = domElement! as
DomHTMLTextAreaElement;
return EditingState(
text: element.value,
baseOffset: element.selectionStart,
extentOffset: element.selectionEnd);
} else {
throw UnsupportedError('Initialized with unsupported input type');
}
}
EditingState copyWith({
String? text,
int? baseOffset,
int? extentOffset,
int? composingBaseOffset,
int? composingExtentOffset,
}) {
return EditingState(
text: text ?? this.text,
baseOffset: baseOffset ?? this.baseOffset,
extentOffset: extentOffset ?? this.extentOffset,
composingBaseOffset: composingBaseOffset ?? this.composingBaseOffset,
composingExtentOffset: composingExtentOffset ?? this.composingExtentOffset,
);
}
/// The counterpart of [EditingState.fromFrameworkMessage]. It generates a Map that
/// can be sent to Flutter.
// TODO(mdebbar): Should we get `selectionAffinity` and other properties from flutter's editing state?
Map<String, dynamic> toFlutter() => <String, dynamic>{
'text': text,
'selectionBase': baseOffset,
'selectionExtent': extentOffset,
'composingBase': composingBaseOffset,
'composingExtent': composingExtentOffset,
};
/// The current text being edited.
final String? text;
/// The offset at which the text selection originates.
final int? baseOffset;
/// The offset at which the text selection terminates.
final int? extentOffset;
/// The offset at which [CompositionAwareMixin.composingText] begins, if any.
final int? composingBaseOffset;
/// The offset at which [CompositionAwareMixin.composingText] terminates, if any.
final int? composingExtentOffset;
/// Whether the current editing state is valid or not.
bool get isValid => baseOffset! >= 0 && extentOffset! >= 0;
@override
int get hashCode => Object.hash(
text, baseOffset, extentOffset, composingBaseOffset, composingExtentOffset
);
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (runtimeType != other.runtimeType) {
return false;
}
return other is EditingState &&
other.text == text &&
other.baseOffset == baseOffset &&
other.extentOffset == extentOffset &&
other.composingBaseOffset == composingBaseOffset &&
other.composingExtentOffset == composingExtentOffset;
}
@override
String toString() {
return assertionsEnabled
? 'EditingState("$text", base:$baseOffset, extent:$extentOffset, composingBase:$composingBaseOffset, composingExtent:$composingExtentOffset)'
: super.toString();
}
/// Sets the selection values of a DOM element using this [EditingState].
///
/// [domElement] can be a [InputElement] or a [TextAreaElement] depending on
/// the [InputType] of the text field.
///
/// This should only be used by focused elements only, because only focused
/// elements can have their text selection range set. Attempting to set
/// selection range on a non-focused element will cause it to request focus.
///
/// See also:
///
/// * [applyTextToDomElement], which is used for non-focused elements.
void applyToDomElement(DomHTMLElement? domElement) {
if (domInstanceOfString(domElement, 'HTMLInputElement')) {
final DomHTMLInputElement element = domElement! as DomHTMLInputElement;
element.value = text;
element.setSelectionRange(baseOffset!, extentOffset!);
} else if (domInstanceOfString(domElement, 'HTMLTextAreaElement')) {
final DomHTMLTextAreaElement element = domElement! as
DomHTMLTextAreaElement;
element.value = text;
element.setSelectionRange(baseOffset!, extentOffset!);
} else {
throw UnsupportedError('Unsupported DOM element type: <${domElement?.tagName}> (${domElement.runtimeType})');
}
}
/// Applies the [text] to the [domElement].
///
/// This is used by non-focused elements.
///
/// See also:
///
/// * [applyToDomElement], which is used for focused elements.
void applyTextToDomElement(DomHTMLElement? domElement) {
if (domInstanceOfString(domElement, 'HTMLInputElement')) {
final DomHTMLInputElement element = domElement! as DomHTMLInputElement;
element.value = text;
} else if (domInstanceOfString(domElement, 'HTMLTextAreaElement')) {
final DomHTMLTextAreaElement element = domElement! as
DomHTMLTextAreaElement;
element.value = text;
} else {
throw UnsupportedError('Unsupported DOM element type');
}
}
}
/// Controls the appearance of the input control being edited.
///
/// For example, [inputType] determines whether we should use `<input>` or
/// `<textarea>` as a backing DOM element.
///
/// This corresponds to Flutter's [TextInputConfiguration].
class InputConfiguration {
InputConfiguration({
this.inputType = EngineInputType.text,
this.inputAction = 'TextInputAction.done',
this.obscureText = false,
this.readOnly = false,
this.autocorrect = true,
this.textCapitalization =
const TextCapitalizationConfig.defaultCapitalization(),
this.autofill,
this.autofillGroup,
this.enableDeltaModel = false,
});
InputConfiguration.fromFrameworkMessage(
Map<String, dynamic> flutterInputConfiguration)
: inputType = EngineInputType.fromName(
flutterInputConfiguration.readJson('inputType').readString('name'),
isDecimal: flutterInputConfiguration.readJson('inputType').tryBool('decimal') ?? false,
),
inputAction =
flutterInputConfiguration.tryString('inputAction') ?? 'TextInputAction.done',
obscureText = flutterInputConfiguration.tryBool('obscureText') ?? false,
readOnly = flutterInputConfiguration.tryBool('readOnly') ?? false,
autocorrect = flutterInputConfiguration.tryBool('autocorrect') ?? true,
textCapitalization = TextCapitalizationConfig.fromInputConfiguration(
flutterInputConfiguration.readString('textCapitalization'),
),
autofill = flutterInputConfiguration.containsKey('autofill')
? AutofillInfo.fromFrameworkMessage(
flutterInputConfiguration.readJson('autofill'))
: null,
autofillGroup = EngineAutofillForm.fromFrameworkMessage(
flutterInputConfiguration.tryJson('autofill'),
flutterInputConfiguration.tryList('fields'),
),
enableDeltaModel = flutterInputConfiguration.tryBool('enableDeltaModel') ?? false;
/// The type of information being edited in the input control.
final EngineInputType inputType;
/// The default action for the input field.
final String inputAction;
/// Whether the text field can be edited or not.
///
/// Defaults to false.
final bool readOnly;
/// Whether to hide the text being edited.
final bool obscureText;
/// Whether to enable autocorrection.
///
/// Definition of autocorrect can be found in:
/// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
///
/// For future manual tests, note that autocorrect is an attribute only
/// supported by Safari.
final bool autocorrect;
final bool enableDeltaModel;
final AutofillInfo? autofill;
final EngineAutofillForm? autofillGroup;
final TextCapitalizationConfig textCapitalization;
}
typedef OnChangeCallback = void Function(EditingState? editingState, TextEditingDeltaState? editingDeltaState);
typedef OnActionCallback = void Function(String? inputAction);
/// Provides HTML DOM functionality for editable text.
///
/// A concrete implementation is picked at runtime based on the current
/// operating system, web browser, and accessibility mode.
abstract class TextEditingStrategy {
void initializeTextEditing(
InputConfiguration inputConfig, {
required OnChangeCallback onChange,
required OnActionCallback onAction,
});
/// Sets the initial placement of the DOM element on the UI.
///
/// The element must be located exactly in the same place with the editable
/// widget. However, its contents and cursor will be invisible.
///
/// Users can interact with the element and use the functionality of the
/// right-click menu, such as copy, paste, cut, select, translate, etc.
void initializeElementPlacement();
/// Register event listeners to the DOM element.
///
/// These event listener will be removed in [disable].
void addEventHandlers();
/// Update the element's position.
///
/// The position will be updated everytime Flutter Framework sends
/// 'TextInput.setEditableSizeAndTransform' message.
void updateElementPlacement(EditableTextGeometry geometry);
/// Set editing state of the element.
///
/// This includes text and selection relelated states. The editing state will
/// be updated everytime Flutter Framework sends 'TextInput.setEditingState'
/// message.
void setEditingState(EditingState editingState);
/// Set style to the native DOM element used for text editing.
void updateElementStyle(EditableTextStyle style);
/// Disables the element so it's no longer used for text editing.
///
/// Calling [disable] also removes any registered event listeners.
void disable();
}
/// A [TextEditingStrategy] that places its [domElement] assuming no
/// prior transform or sizing is applied to it.
///
/// This implementation is used by text editables when semantics is not
/// enabled. With semantics enabled the placement is provided by the semantics
/// tree.
class GloballyPositionedTextEditingStrategy extends DefaultTextEditingStrategy {
GloballyPositionedTextEditingStrategy(HybridTextEditing owner) : super(owner);
@override
void placeElement() {
geometry?.applyToDomElement(activeDomElement);
if (hasAutofillGroup) {
placeForm();
// Set the last editing state if it exists, this is critical for a
// users ongoing work to continue uninterrupted when there is an update to
// the transform.
lastEditingState?.applyToDomElement(domElement);
// On Chrome, when a form is focused, it opens an autofill menu
// immediately.
// Flutter framework sends `setEditableSizeAndTransform` for informing
// the engine about the location of the text field. This call will
// arrive after `show` call.
// Therefore on Chrome we place the element when
// `setEditableSizeAndTransform` method is called and focus on the form
// only after placing it to the correct position. Hence autofill menu
// does not appear on top-left of the page.
// Refocus on the elements after applying the geometry.
focusedFormElement!.focus();
activeDomElement.focus();
}
}
}
/// A [TextEditingStrategy] for Safari Desktop Browser.
///
/// It places its [domElement] assuming no prior transform or sizing is applied
/// to it.
///
/// In case of an autofill enabled form, it does not append the form element
/// to the DOM, until the geometry information is updated.
///
/// This implementation is used by text editables when semantics is not
/// enabled. With semantics enabled the placement is provided by the semantics
/// tree.
class SafariDesktopTextEditingStrategy extends DefaultTextEditingStrategy {
SafariDesktopTextEditingStrategy(HybridTextEditing owner) : super(owner);
/// Appending an element on the DOM for Safari Desktop Browser.
///
/// This method is only called when geometry information is updated by
/// 'TextInput.setEditableSizeAndTransform' message.
///
/// This method is similar to the [GloballyPositionedTextEditingStrategy].
/// The only part different: this method does not call `super.placeElement()`,
/// which in current state calls `domElement.focus()`.
///
/// Making an extra `focus` request causes flickering in Safari.
@override
void placeElement() {
geometry?.applyToDomElement(activeDomElement);
if (hasAutofillGroup) {
placeForm();
// On Safari Desktop, when a form is focused, it opens an autofill menu
// immediately.
// Flutter framework sends `setEditableSizeAndTransform` for informing
// the engine about the location of the text field. This call may arrive
// after the first `show` call, depending on the text input widget's
// implementation. Therefore form is placed, when
// `setEditableSizeAndTransform` method is called and focus called on the
// form only after placing it to the correct position and only once after
// that. Calling focus multiple times causes flickering.
focusedFormElement!.focus();
// Set the last editing state if it exists, this is critical for a
// users ongoing work to continue uninterrupted when there is an update to
// the transform.
// If domElement is not focused cursor location will not be correct.
activeDomElement.focus();
lastEditingState?.applyToDomElement(activeDomElement);
}
}
@override
void initializeElementPlacement() {
if (geometry != null) {
placeElement();
}
activeDomElement.focus();
}
}
/// Class implementing the default editing strategies for text editing.
///
/// This class uses a DOM element to provide text editing capabilities.
///
/// The backing DOM element could be one of:
///
/// 1. `<input>`.
/// 2. `<textarea>`.
/// 3. `<span contenteditable="true">`.
///
/// This class includes all the default behaviour for an editing element as
/// well as the common properties such as [domElement].
///
/// Strategies written for different form factors and browsers should extend
/// this class instead of extending the interface [TextEditingStrategy]. In
/// particular, a concrete implementation is expected to override
/// [placeElement] that places the DOM element accordingly. The default
/// implementation of [placeElement] does not position the element.
///
/// Unless a formfactor/browser requires specific implementation for a specific
/// strategy the methods in this class should be used.
abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements TextEditingStrategy {
final HybridTextEditing owner;
DefaultTextEditingStrategy(this.owner);
bool isEnabled = false;
/// The DOM element used for editing, if any.
DomHTMLElement? domElement;
/// Same as [domElement] but null-checked.
///
/// This must only be called in places that know for sure that a DOM element
/// is currently available for editing.
DomHTMLElement get activeDomElement {
assert(
domElement != null,
'The DOM element of this text editing strategy is not currently active.',
);
return domElement!;
}
late InputConfiguration inputConfiguration;
EditingState? lastEditingState;
TextEditingDeltaState? _editingDeltaState;
TextEditingDeltaState get editingDeltaState {
_editingDeltaState ??= TextEditingDeltaState(oldText: lastEditingState!.text!);
return _editingDeltaState!;
}
/// Styles associated with the editable text.
EditableTextStyle? style;
/// Size and transform of the editable text on the page.
EditableTextGeometry? geometry;
OnChangeCallback? onChange;
OnActionCallback? onAction;
final List<DomSubscription> subscriptions = <DomSubscription>[];
bool get hasAutofillGroup => inputConfiguration.autofillGroup != null;
/// Whether the focused input element is part of a form.
bool get appendedToForm => _appendedToForm;
bool _appendedToForm = false;
DomHTMLFormElement? get focusedFormElement =>
inputConfiguration.autofillGroup?.formElement;
@override
void initializeTextEditing(
InputConfiguration inputConfig, {
required OnChangeCallback onChange,
required OnActionCallback onAction,
}) {
assert(!isEnabled);
domElement = inputConfig.inputType.createDomElement();
applyConfiguration(inputConfig);
_setStaticStyleAttributes(activeDomElement);
style?.applyToDomElement(activeDomElement);
if (!hasAutofillGroup) {
// If there is an Autofill Group the `FormElement`, it will be appended to the
// DOM later, when the first location information arrived.
// Otherwise, on Blink based Desktop browsers, the autofill menu appears
// on top left of the screen.
defaultTextEditingRoot.append(activeDomElement);
_appendedToForm = false;
}
initializeElementPlacement();
isEnabled = true;
this.onChange = onChange;
this.onAction = onAction;
}
void applyConfiguration(InputConfiguration config) {
inputConfiguration = config;
if (config.readOnly) {
activeDomElement.setAttribute('readonly', 'readonly');
} else {
activeDomElement.removeAttribute('readonly');
}
if (config.obscureText) {
activeDomElement.setAttribute('type', 'password');
}
if (config.inputType == EngineInputType.none) {
activeDomElement.setAttribute('inputmode', 'none');
}
final AutofillInfo? autofill = config.autofill;
if (autofill != null) {
autofill.applyToDomElement(activeDomElement, focusedElement: true);
} else {
activeDomElement.setAttribute('autocomplete', 'off');
}
final String autocorrectValue = config.autocorrect ? 'on' : 'off';
activeDomElement.setAttribute('autocorrect', autocorrectValue);
}
@override
void initializeElementPlacement() {
placeElement();
}
@override
void addEventHandlers() {
if (inputConfiguration.autofillGroup != null) {
subscriptions
.addAll(inputConfiguration.autofillGroup!.addInputEventListeners());
}
// Subscribe to text and selection changes.
subscriptions.add(DomSubscription(activeDomElement, 'input',
allowInterop(handleChange)));
subscriptions.add(DomSubscription(activeDomElement, 'keydown',
allowInterop(maybeSendAction)));
subscriptions.add(DomSubscription(domDocument, 'selectionchange',
allowInterop(handleChange)));
activeDomElement.addEventListener('beforeinput',
allowInterop(handleBeforeInput));
addCompositionEventHandlers(activeDomElement as html.HtmlElement);
// Refocus on the activeDomElement after blur, so that user can keep editing the
// text field.
subscriptions.add(DomSubscription(activeDomElement, 'blur',
allowInterop((_) { activeDomElement.focus(); })));
preventDefaultForMouseEvents();
}
@override
void updateElementPlacement(EditableTextGeometry textGeometry) {
geometry = textGeometry;
if (isEnabled) {
placeElement();
}
}
@override
void updateElementStyle(EditableTextStyle textStyle) {
style = textStyle;
if (isEnabled) {
textStyle.applyToDomElement(activeDomElement);
}
}
@override
void disable() {
assert(isEnabled);
isEnabled = false;
lastEditingState = null;
_editingDeltaState = null;
style = null;
geometry = null;
for (int i = 0; i < subscriptions.length; i++) {
subscriptions[i].cancel();
}
subscriptions.clear();
removeCompositionEventHandlers(activeDomElement as html.HtmlElement);
// If focused element is a part of a form, it needs to stay on the DOM
// until the autofill context of the form is finalized.
// More details on `TextInput.finishAutofillContext` call.
if (_appendedToForm &&
inputConfiguration.autofillGroup?.formElement != null) {
// Subscriptions are removed, listeners won't be triggered.
activeDomElement.blur();
_hideAutofillElements(activeDomElement, isOffScreen: true);
inputConfiguration.autofillGroup?.storeForm();
} else {
activeDomElement.remove();
}
domElement = null;
}
@override
void setEditingState(EditingState? editingState) {
lastEditingState = editingState;
if (!isEnabled || !editingState!.isValid) {
return;
}
lastEditingState!.applyToDomElement(domElement);
}
void placeElement() {
activeDomElement.focus();
}
void placeForm() {
inputConfiguration.autofillGroup!.placeForm(activeDomElement);
_appendedToForm = true;
}
void handleChange(DomEvent event) {
assert(isEnabled);
EditingState newEditingState = EditingState.fromDomElement(activeDomElement);
newEditingState = determineCompositionState(newEditingState);
TextEditingDeltaState? newTextEditingDeltaState;
if (inputConfiguration.enableDeltaModel) {
editingDeltaState.composingOffset = newEditingState.composingBaseOffset;
editingDeltaState.composingExtent = newEditingState.composingExtentOffset;
newTextEditingDeltaState = TextEditingDeltaState.inferDeltaState(newEditingState, lastEditingState, editingDeltaState);
}
if (newEditingState != lastEditingState) {
lastEditingState = newEditingState;
_editingDeltaState = newTextEditingDeltaState;
onChange!(lastEditingState, _editingDeltaState);
// Flush delta after it has been sent to framework.
_editingDeltaState = null;
}
}
void handleBeforeInput(DomEvent event) {
// In some cases the beforeinput event is not fired such as when the selection
// of a text field is updated. In this case only the oninput event is fired.
// We still want a delta generated in these cases so we can properly update
// the selection. We begin to set the deltaStart and deltaEnd in beforeinput
// because a change in the selection will not have a delta range, it will only
// have a baseOffset and extentOffset. If these are set inside of inferDeltaState
// then the method will incorrectly report a deltaStart and deltaEnd for a non
// text update delta.
final String? eventData = getJsProperty<void>(event, 'data') as String?;
final String? inputType = getJsProperty<void>(event, 'inputType') as String?;
if (inputType != null) {
if (inputType.contains('delete')) {
// The deltaStart is set in handleChange because there is where we get access
// to the new selection baseOffset which is our new deltaStart.
editingDeltaState.deltaText = '';
editingDeltaState.deltaEnd = lastEditingState!.extentOffset!;
} else if (inputType == 'insertLineBreak'){
// event.data is null on a line break, so we manually set deltaText as a line break by setting it to '\n'.
editingDeltaState.deltaText = '\n';
editingDeltaState.deltaStart = lastEditingState!.extentOffset!;
editingDeltaState.deltaEnd = lastEditingState!.extentOffset!;
} else if (eventData != null) {
// When event.data is not null we we will begin by considering this delta as an insertion
// at the selection extentOffset. This may change due to logic in handleChange to handle
// composition and other IME behaviors.
editingDeltaState.deltaText = eventData;
editingDeltaState.deltaStart = lastEditingState!.extentOffset!;
editingDeltaState.deltaEnd = lastEditingState!.extentOffset!;
}
}
}
void maybeSendAction(DomEvent e) {
if (domInstanceOfString(e, 'KeyboardEvent')) {
final DomKeyboardEvent event = e as DomKeyboardEvent;
if (event.keyCode == _kReturnKeyCode) {
onAction!(inputConfiguration.inputAction);
}
}
}
/// Enables the element so it can be used to edit text.
///
/// Register [callback] so that it gets invoked whenever any change occurs in
/// the text editing element.
///
/// Changes could be:
/// - Text changes, or
/// - Selection changes.
void enable(
InputConfiguration inputConfig, {
required OnChangeCallback onChange,
required OnActionCallback onAction,
}) {
assert(!isEnabled);
initializeTextEditing(inputConfig, onChange: onChange, onAction: onAction);
addEventHandlers();
if (lastEditingState != null) {
setEditingState(lastEditingState);
}
// Re-focuses after setting editing state.
activeDomElement.focus();
}
/// Prevent default behavior for mouse down, up and move.
///
/// When normal mouse events are not prevented, in desktop browsers, mouse
/// selection conflicts with selection sent from the framework, which creates
/// flickering during selection by mouse.
void preventDefaultForMouseEvents() {
subscriptions.add(
DomSubscription(activeDomElement, 'mousedown', allowInterop((_) {
_.preventDefault();
})));
subscriptions.add(
DomSubscription(activeDomElement, 'mouseup', allowInterop((_) {
_.preventDefault();
})));
subscriptions.add(
DomSubscription(activeDomElement, 'mousemove', allowInterop((_) {
_.preventDefault();
})));
}
}
/// IOS/Safari behaviour for text editing.
///
/// In iOS, the virtual keyboard might shifts the screen up to make input
/// visible depending on the location of the focused input element.
///
/// Due to this [initializeElementPlacement] and [updateElementPlacement]
/// strategies are different.
///
/// [disable] is also different since the [_positionInputElementTimer]
/// also needs to be cleaned.
///
/// inputmodeAttribute needs to be set for mobile devices. Due to this
/// [initializeTextEditing] is different.
class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
IOSTextEditingStrategy(HybridTextEditing owner) : super(owner);
/// Timer that times when to set the location of the input text.
///
/// This is only used for iOS. In iOS, virtual keyboard shifts the screen.
/// There is no callback to know if the keyboard is up and how much the screen
/// has shifted. Therefore instead of listening to the shift and passing this
/// information to Flutter Framework, we are trying to stop the shift.
///
/// In iOS, the virtual keyboard shifts the screen up if the focused input
/// element is under the keyboard or very close to the keyboard. Before the
/// focus is called we are positioning it offscreen. The location of the input
/// in iOS is set to correct place, 100ms after focus. We use this timer for
/// timing this delay.
Timer? _positionInputElementTimer;
static const Duration _delayBeforePlacement = Duration(milliseconds: 100);
/// This interval between the blur subscription and callback is considered to
/// be fast.
///
/// This is only used for iOS. The blur callback may trigger as soon as the
/// creation of the subscription. Occasionally in this case, the virtual
/// keyboard will quickly show and hide again.
///
/// Less than this interval allows the virtual keyboard to keep showing up
/// instead of hiding rapidly.
static const Duration _blurFastCallbackInterval = Duration(milliseconds: 200);
/// Whether or not the input element can be positioned at this point in time.
///
/// This is currently only used in iOS. It's set to false before focusing the
/// input field, and set back to true after a short timer. We do this because
/// if the input field is positioned before focus, it could be pushed to an
/// incorrect position by the virtual keyboard.
///
/// See:
///
/// * [_delayBeforePlacement] which controls how long to wait before
/// positioning the input field.
bool _canPosition = true;
@override
void initializeTextEditing(
InputConfiguration inputConfig, {
required OnChangeCallback onChange,
required OnActionCallback onAction,
}) {
super.initializeTextEditing(inputConfig,
onChange: onChange, onAction: onAction);
inputConfig.inputType.configureInputMode(activeDomElement);
if (hasAutofillGroup) {
placeForm();
}
inputConfig.textCapitalization.setAutocapitalizeAttribute(activeDomElement);
}
@override
void initializeElementPlacement() {
/// Position the element outside of the page before focusing on it. This is
/// useful for not triggering a scroll when iOS virtual keyboard is
/// coming up.
activeDomElement.style.transform = 'translate(-9999px, -9999px)';
_canPosition = false;
}
@override
void addEventHandlers() {
if (inputConfiguration.autofillGroup != null) {
subscriptions
.addAll(inputConfiguration.autofillGroup!.addInputEventListeners());
}
// Subscribe to text and selection changes.
subscriptions.add(DomSubscription(activeDomElement, 'input',
allowInterop(handleChange)));
subscriptions.add(DomSubscription(activeDomElement, 'keydown',
allowInterop(maybeSendAction)));
subscriptions.add(DomSubscription(domDocument, 'selectionchange',
allowInterop(handleChange)));
activeDomElement.addEventListener('beforeinput',
allowInterop(handleBeforeInput));
addCompositionEventHandlers(activeDomElement as html.HtmlElement);
// Position the DOM element after it is focused.
subscriptions.add(DomSubscription(activeDomElement, 'focus',
allowInterop((_) {
// Cancel previous timer if exists.
_schedulePlacement();
})));
_addTapListener();
// Record start time of blur subscription.
final Stopwatch blurWatch = Stopwatch()..start();
// On iOS, blur is trigerred in the following cases:
//
// 1. The browser app is sent to the background (or the tab is changed). In
// this case, the window loses focus (see [windowHasFocus]),
// so we close the input connection with the framework.
// 2. The user taps on another focusable element. In this case, we refocus
// the input field and wait for the framework to manage the focus change.
// 3. The virtual keyboard is closed by tapping "done". We can't detect this
// programmatically, so we end up refocusing the input field. This is
// okay because the virtual keyboard will hide, and as soon as the user
// taps the text field again, the virtual keyboard will come up.
// 4. Safari sometimes sends a blur event immediately after activating the
// input field. In this case, we want to keep the focus on the input field.
// In order to detect this, we measure how much time has passed since the
// input field was activated. If the time is too short, we re-focus the
// input element.
subscriptions.add(DomSubscription(activeDomElement, 'blur',
allowInterop((_) {
final bool isFastCallback = blurWatch.elapsed < _blurFastCallbackInterval;
if (windowHasFocus && isFastCallback) {
activeDomElement.focus();
} else {
owner.sendTextConnectionClosedToFrameworkIfAny();
}
})));
}
@override
void updateElementPlacement(EditableTextGeometry textGeometry) {
geometry = textGeometry;
if (isEnabled && _canPosition) {
placeElement();
}
}
@override
void disable() {
super.disable();
_positionInputElementTimer?.cancel();
_positionInputElementTimer = null;
}
/// On iOS long press works differently than a single tap.
///
/// On a normal tap the virtual keyboard comes up and users can enter text
/// using the keyboard.
///
/// The long press on the other hand focuses on the element without bringing
/// up the virtual keyboard. It allows the users to modify the field by using
/// copy/cut/select/paste etc.
///
/// After a long press [domElement] is positioned to the correct place. If the
/// user later single-tap on the [domElement] the virtual keyboard will come
/// and might shift the page up.
///
/// In order to prevent this shift, on a `click` event the position of the
/// element is again set somewhere outside of the page and
/// [_positionInputElementTimer] timer is restarted. The element will be
/// placed to its correct position after [_delayBeforePlacement].
void _addTapListener() {
subscriptions.add(DomSubscription(activeDomElement, 'click', allowInterop((_) {
// Check if the element is already positioned. If not this does not fall
// under `The user was using the long press, now they want to enter text
// via keyboard` journey.
if (_canPosition) {
// Re-place the element somewhere outside of the screen.
initializeElementPlacement();
// Re-configure the timer to place the element.
_schedulePlacement();
}
})));
}
void _schedulePlacement() {
_positionInputElementTimer?.cancel();
_positionInputElementTimer = Timer(_delayBeforePlacement, () {
_canPosition = true;
placeElement();
});
}
@override
void placeElement() {
activeDomElement.focus();
geometry?.applyToDomElement(activeDomElement);
}
}
/// Android behaviour for text editing.
///
/// inputmodeAttribute needs to be set for mobile devices. Due to this
/// [initializeTextEditing] is different.
///
/// Keyboard acts differently than other devices. [addEventHandlers] handles
/// this case as an extra.
class AndroidTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
AndroidTextEditingStrategy(HybridTextEditing owner) : super(owner);
@override
void initializeTextEditing(
InputConfiguration inputConfig, {
required OnChangeCallback onChange,
required OnActionCallback onAction,
}) {
super.initializeTextEditing(inputConfig,
onChange: onChange, onAction: onAction);
inputConfig.inputType.configureInputMode(activeDomElement);
if (hasAutofillGroup) {
placeForm();
} else {
defaultTextEditingRoot.append(activeDomElement);
}
inputConfig.textCapitalization.setAutocapitalizeAttribute(
activeDomElement);
}
@override
void addEventHandlers() {
if (inputConfiguration.autofillGroup != null) {
subscriptions
.addAll(inputConfiguration.autofillGroup!.addInputEventListeners());
}
// Subscribe to text and selection changes.
subscriptions.add(
DomSubscription(activeDomElement, 'input', allowInterop(handleChange)));
subscriptions.add(
DomSubscription(activeDomElement, 'keydown',
allowInterop(maybeSendAction)));
subscriptions.add(
DomSubscription(domDocument, 'selectionchange',
allowInterop(handleChange)));
activeDomElement.addEventListener('beforeinput',
allowInterop(handleBeforeInput));
addCompositionEventHandlers(activeDomElement as html.HtmlElement);
subscriptions.add(
DomSubscription(activeDomElement, 'blur',
allowInterop((_) {
if (windowHasFocus) {
// Chrome on Android will hide the onscreen keyboard when you tap outside
// the text box. Instead, we want the framework to tell us to hide the
// keyboard via `TextInput.clearClient` or `TextInput.hide`. Therefore
// refocus as long as [windowHasFocus] is true.
activeDomElement.focus();
} else {
owner.sendTextConnectionClosedToFrameworkIfAny();
}
})));
}
@override
void placeElement() {
activeDomElement.focus();
geometry?.applyToDomElement(activeDomElement);
}
}
/// Firefox behaviour for text editing.
///
/// Selections are different in Firefox. [addEventHandlers] strategy is
/// impelemented diefferently in Firefox.
class FirefoxTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
FirefoxTextEditingStrategy(HybridTextEditing owner) : super(owner);
@override
void initializeTextEditing(
InputConfiguration inputConfig, {
required OnChangeCallback onChange,
required OnActionCallback onAction,
}) {
super.initializeTextEditing(inputConfig,
onChange: onChange, onAction: onAction);
if (hasAutofillGroup) {
placeForm();
}
}
@override
void addEventHandlers() {
if (inputConfiguration.autofillGroup != null) {
subscriptions
.addAll(inputConfiguration.autofillGroup!.addInputEventListeners());
}
// Subscribe to text and selection changes.
subscriptions.add(
DomSubscription(activeDomElement, 'input', allowInterop(handleChange)));
subscriptions.add(
DomSubscription(
activeDomElement, 'keydown', allowInterop(maybeSendAction)));
activeDomElement.addEventListener('beforeinput',
allowInterop(handleBeforeInput));
addCompositionEventHandlers(activeDomElement as html.HtmlElement);
// Detects changes in text selection.
//
// In Firefox, when cursor moves, neither selectionChange nor onInput
// events are triggered. We are listening to keyup event. Selection start,
// end values are used to decide if the text cursor moved.
//
// Specific keycodes are not checked since users/applications can bind
// their own keys to move the text cursor.
// Decides if the selection has changed (cursor moved) compared to the
// previous values.
//
// After each keyup, the start/end values of the selection is compared to
// the previously saved editing state.
subscriptions.add(
DomSubscription(
activeDomElement,
'keyup',
allowInterop((DomEvent event) {
handleChange(event);
})));
// In Firefox the context menu item "Select All" does not work without
// listening to onSelect. On the other browsers onSelectionChange is
// enough for covering "Select All" functionality.
subscriptions.add(
DomSubscription(
activeDomElement, 'select', allowInterop(handleChange)));
// Refocus on the activeDomElement after blur, so that user can keep editing the
// text field.
subscriptions.add(
DomSubscription(
activeDomElement,
'blur',
allowInterop((_) {
_postponeFocus();
})));
preventDefaultForMouseEvents();
}
void _postponeFocus() {
// Firefox does not focus on the editing element if we call the focus
// inside the blur event, therefore we postpone the focus.
// Calling focus inside a Timer for `0` milliseconds guarantee that it is
// called after blur event propagation is completed.
Timer(const Duration(milliseconds: 0), () {
activeDomElement.focus();
});
}
@override
void placeElement() {
activeDomElement.focus();
geometry?.applyToDomElement(activeDomElement);
// Set the last editing state if it exists, this is critical for a
// users ongoing work to continue uninterrupted when there is an update to
// the transform.
lastEditingState?.applyToDomElement(activeDomElement);
}
}
/// Base class for all `TextInput` commands sent through the `flutter/textinput`
/// channel.
@immutable
abstract class TextInputCommand {
const TextInputCommand();
/// Executes the logic for this command.
void run(HybridTextEditing textEditing);
}
/// Responds to the 'TextInput.setClient' message.
class TextInputSetClient extends TextInputCommand {
const TextInputSetClient({
required this.clientId,
required this.configuration,
});
final int clientId;
final InputConfiguration configuration;
@override
void run(HybridTextEditing textEditing) {
final bool clientIdChanged = textEditing._clientId != null && textEditing._clientId != clientId;
if (clientIdChanged && textEditing.isEditing) {
// We're connecting a new client. Any pending command for the previous client
// are irrelevant at this point.
textEditing.stopEditing();
}
textEditing._clientId = clientId;
textEditing.configuration = configuration;
}
}
/// Creates the text editing strategy used in non-a11y mode.
DefaultTextEditingStrategy createDefaultTextEditingStrategy(HybridTextEditing textEditing) {
DefaultTextEditingStrategy strategy;
if (browserEngine == BrowserEngine.webkit &&
operatingSystem == OperatingSystem.iOs) {
strategy = IOSTextEditingStrategy(textEditing);
} else if (browserEngine == BrowserEngine.webkit) {
strategy = SafariDesktopTextEditingStrategy(textEditing);
} else if (browserEngine == BrowserEngine.blink &&
operatingSystem == OperatingSystem.android) {
strategy = AndroidTextEditingStrategy(textEditing);
} else if (browserEngine == BrowserEngine.firefox) {
strategy = FirefoxTextEditingStrategy(textEditing);
} else {
strategy = GloballyPositionedTextEditingStrategy(textEditing);
}
return strategy;
}
/// Responds to the 'TextInput.updateConfig' message.
class TextInputUpdateConfig extends TextInputCommand {
const TextInputUpdateConfig();
@override
void run(HybridTextEditing textEditing) {
textEditing.strategy.applyConfiguration(textEditing.configuration!);
}
}
/// Responds to the 'TextInput.setEditingState' message.
class TextInputSetEditingState extends TextInputCommand {
const TextInputSetEditingState({
required this.state,
});
final EditingState state;
@override
void run(HybridTextEditing textEditing) {
textEditing.strategy.setEditingState(state);
}
}
/// Responds to the 'TextInput.show' message.
class TextInputShow extends TextInputCommand {
const TextInputShow();
@override
void run(HybridTextEditing textEditing) {
if (!textEditing.isEditing) {
textEditing._startEditing();
}
}
}
/// Responds to the 'TextInput.setEditableSizeAndTransform' message.
class TextInputSetEditableSizeAndTransform extends TextInputCommand {
const TextInputSetEditableSizeAndTransform({
required this.geometry,
});
final EditableTextGeometry geometry;
@override
void run(HybridTextEditing textEditing) {
textEditing.strategy.updateElementPlacement(geometry);
}
}
/// Responds to the 'TextInput.setStyle' message.
class TextInputSetStyle extends TextInputCommand {
const TextInputSetStyle({
required this.style,
});
final EditableTextStyle style;
@override
void run(HybridTextEditing textEditing) {
textEditing.strategy.updateElementStyle(style);
}
}
/// Responds to the 'TextInput.clearClient' message.
class TextInputClearClient extends TextInputCommand {
const TextInputClearClient();
@override
void run(HybridTextEditing textEditing) {
if (textEditing.isEditing) {
textEditing.stopEditing();
}
}
}
/// Responds to the 'TextInput.hide' message.
class TextInputHide extends TextInputCommand {
const TextInputHide();
@override
void run(HybridTextEditing textEditing) {
if (textEditing.isEditing) {
textEditing.stopEditing();
}
}
}
class TextInputSetMarkedTextRect extends TextInputCommand {
const TextInputSetMarkedTextRect();
@override
void run(HybridTextEditing textEditing) {
// No-op: this message is currently only used on iOS to implement
// UITextInput.firstRecForRange.
}
}
class TextInputSetCaretRect extends TextInputCommand {
const TextInputSetCaretRect();
@override
void run(HybridTextEditing textEditing) {
// No-op: not supported on this platform.
}
}
class TextInputRequestAutofill extends TextInputCommand {
const TextInputRequestAutofill();
@override
void run(HybridTextEditing textEditing) {
// No-op: not supported on this platform.
}
}
class TextInputFinishAutofillContext extends TextInputCommand {
const TextInputFinishAutofillContext({
required this.saveForm,
});
final bool saveForm;
@override
void run(HybridTextEditing textEditing) {
// Close the text editing connection. Form is finalizing.
textEditing.sendTextConnectionClosedToFrameworkIfAny();
if (saveForm) {
saveForms();
}
// Clean the forms from DOM after submitting them.
cleanForms();
}
}
/// Submits the forms currently attached to the DOM.
///
/// Browser will save the information entered to the form.
///
/// Called when the form is finalized with save option `true`.
/// See: https://github.com/flutter/flutter/blob/bf9f3a3dcfea3022f9cf2dfc3ab10b120b48b19d/packages/flutter/lib/src/services/text_input.dart#L1277
void saveForms() {
formsOnTheDom.forEach((String identifier, DomHTMLFormElement form) {
final DomHTMLInputElement submitBtn =
form.getElementsByClassName('submitBtn').first as DomHTMLInputElement;
submitBtn.click();
});
}
/// Removes the forms from the DOM.
///
/// Called when the form is finalized.
void cleanForms() {
for (final DomHTMLFormElement form in formsOnTheDom.values) {
form.remove();
}
formsOnTheDom.clear();
}
/// Translates the message-based communication between the framework and the
/// engine [implementation].
///
/// This class is meant to be used as a singleton.
class TextEditingChannel {
TextEditingChannel(this.implementation);
/// Supplies the implementation that responds to the channel messages.
final HybridTextEditing implementation;
/// Handles "flutter/textinput" platform messages received from the framework.
void handleTextInput(
ByteData? data, ui.PlatformMessageResponseCallback? callback) {
const JSONMethodCodec codec = JSONMethodCodec();
final MethodCall call = codec.decodeMethodCall(data);
final TextInputCommand command;
switch (call.method) {
case 'TextInput.setClient':
command = TextInputSetClient(
clientId: call.arguments[0] as int,
configuration: InputConfiguration.fromFrameworkMessage(call.arguments[1] as Map<String, dynamic>),
);
break;
case 'TextInput.updateConfig':
// Set configuration eagerly because it contains data about the text
// field used to flush the command queue. However, delaye applying the
// configuration because the strategy may not be available yet.
implementation.configuration = InputConfiguration.fromFrameworkMessage(
call.arguments as Map<String, dynamic>
);
command = const TextInputUpdateConfig();
break;
case 'TextInput.setEditingState':
command = TextInputSetEditingState(
state: EditingState.fromFrameworkMessage(
call.arguments as Map<String, dynamic>
),
);
break;
case 'TextInput.show':
command = const TextInputShow();
break;
case 'TextInput.setEditableSizeAndTransform':
command = TextInputSetEditableSizeAndTransform(
geometry: EditableTextGeometry.fromFrameworkMessage(
call.arguments as Map<String, dynamic>
),
);
break;
case 'TextInput.setStyle':
command = TextInputSetStyle(
style: EditableTextStyle.fromFrameworkMessage(
call.arguments as Map<String, dynamic>,
),
);
break;
case 'TextInput.clearClient':
command = const TextInputClearClient();
break;
case 'TextInput.hide':
command = const TextInputHide();
break;
case 'TextInput.requestAutofill':
// There's no API to request autofill on the web. Instead we let the
// browser show autofill options automatically, if available. We
// therefore simply ignore this message.
command = const TextInputRequestAutofill();
break;
case 'TextInput.finishAutofillContext':
command = TextInputFinishAutofillContext(
saveForm: call.arguments as bool,
);
break;
case 'TextInput.setMarkedTextRect':
command = const TextInputSetMarkedTextRect();
break;
case 'TextInput.setCaretRect':
command = const TextInputSetCaretRect();
break;
default:
EnginePlatformDispatcher.instance.replyToPlatformMessage(callback, null);
return;
}
implementation.acceptCommand(command, () {
EnginePlatformDispatcher.instance
.replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true));
});
}
/// Sends the 'TextInputClient.updateEditingState' message to the framework.
void updateEditingState(int? clientId, EditingState? editingState) {
EnginePlatformDispatcher.instance.invokeOnPlatformMessage(
'flutter/textinput',
const JSONMethodCodec().encodeMethodCall(
MethodCall('TextInputClient.updateEditingState', <dynamic>[
clientId,
editingState!.toFlutter(),
]),
),
_emptyCallback,
);
}
/// Sends the 'TextInputClient.updateEditingStateWithDeltas' message to the framework.
void updateEditingStateWithDelta(int? clientId, TextEditingDeltaState? editingDeltaState) {
EnginePlatformDispatcher.instance.invokeOnPlatformMessage(
'flutter/textinput',
const JSONMethodCodec().encodeMethodCall(
MethodCall('TextInputClient.updateEditingStateWithDeltas', <dynamic>[
clientId,
editingDeltaState!.toFlutter(),
]),
),
_emptyCallback,
);
}
/// Sends the 'TextInputClient.performAction' message to the framework.
void performAction(int? clientId, String? inputAction) {
EnginePlatformDispatcher.instance.invokeOnPlatformMessage(
'flutter/textinput',
const JSONMethodCodec().encodeMethodCall(
MethodCall(
'TextInputClient.performAction',
<dynamic>[clientId, inputAction],
),
),
_emptyCallback,
);
}
/// Sends the 'TextInputClient.onConnectionClosed' message to the framework.
void onConnectionClosed(int? clientId) {
EnginePlatformDispatcher.instance.invokeOnPlatformMessage(
'flutter/textinput',
const JSONMethodCodec().encodeMethodCall(
MethodCall(
'TextInputClient.onConnectionClosed',
<dynamic>[clientId],
),
),
_emptyCallback,
);
}
}
/// Text editing singleton.
final HybridTextEditing textEditing = HybridTextEditing();
/// Map for storing forms left attached on the DOM.
///
/// Used for keeping the form elements on the DOM until user confirms to
/// save or cancel them.
///
/// See: https://github.com/flutter/flutter/blob/bf9f3a3dcfea3022f9cf2dfc3ab10b120b48b19d/packages/flutter/lib/src/services/text_input.dart#L1277
final Map<String, DomHTMLFormElement> formsOnTheDom =
<String, DomHTMLFormElement>{};
/// Should be used as a singleton to provide support for text editing in
/// Flutter Web.
///
/// The approach is "hybrid" because it relies on Flutter for
/// displaying, and HTML for user interactions:
///
/// - HTML's contentEditable feature handles typing and text changes.
/// - HTML's selection API handles selection changes and cursor movements.
class HybridTextEditing {
/// Private constructor so this class can be a singleton.
///
/// The constructor also decides which text editing strategy to use depending
/// on the operating system and browser engine.
HybridTextEditing();
late final TextEditingChannel channel = TextEditingChannel(this);
/// A CSS class name used to identify all elements used for text editing.
@visibleForTesting
static const String textEditingClass = 'flt-text-editing';
int? _clientId;
/// Flag which shows if there is an ongoing editing.
///
/// Also used to define if a keyboard is needed.
bool isEditing = false;
InputConfiguration? configuration;
DefaultTextEditingStrategy? debugTextEditingStrategyOverride;
/// Supplies the DOM element used for editing.
late final DefaultTextEditingStrategy strategy =
debugTextEditingStrategyOverride ??
(EngineSemanticsOwner.instance.semanticsEnabled
? SemanticsTextEditingStrategy.ensureInitialized(this)
: createDefaultTextEditingStrategy(this));
void acceptCommand(TextInputCommand command, ui.VoidCallback callback) {
if (_debugPrintTextInputCommands) {
print('flutter/textinput channel command: ${command.runtimeType}');
}
command.run(this);
callback();
}
void _startEditing() {
assert(!isEditing);
isEditing = true;
strategy.enable(
configuration!,
onChange: (EditingState? editingState, TextEditingDeltaState? editingDeltaState) {
if (configuration!.enableDeltaModel) {
channel.updateEditingStateWithDelta(_clientId, editingDeltaState);
} else {
channel.updateEditingState(_clientId, editingState);
}
},
onAction: (String? inputAction) {
channel.performAction(_clientId, inputAction);
},
);
}
void stopEditing() {
assert(isEditing);
isEditing = false;
strategy.disable();
}
void sendTextConnectionClosedToFrameworkIfAny() {
if (isEditing) {
stopEditing();
channel.onConnectionClosed(_clientId);
}
}
}
/// Information on the font and alignment of a text editing element.
///
/// This information is received via TextInput.setStyle message.
class EditableTextStyle {
EditableTextStyle({
required this.textDirection,
required this.fontSize,
required this.textAlign,
required this.fontFamily,
required this.fontWeight,
});
factory EditableTextStyle.fromFrameworkMessage(
Map<String, dynamic> flutterStyle) {
assert(flutterStyle.containsKey('fontSize'));
assert(flutterStyle.containsKey('fontFamily'));
assert(flutterStyle.containsKey('textAlignIndex'));
assert(flutterStyle.containsKey('textDirectionIndex'));
final int textAlignIndex = flutterStyle['textAlignIndex'] as int;
final int textDirectionIndex = flutterStyle['textDirectionIndex'] as int;
final int? fontWeightIndex = flutterStyle['fontWeightIndex'] as int?;
// Convert [fontWeightIndex] to its CSS equivalent value.
final String fontWeight = fontWeightIndex != null
? fontWeightIndexToCss(fontWeightIndex: fontWeightIndex)
: 'normal';
// Also convert [textAlignIndex] and [textDirectionIndex] to their
// corresponding enum values in [ui.TextAlign] and [ui.TextDirection]
// respectively.
return EditableTextStyle(
fontSize: flutterStyle.tryDouble('fontSize'),
fontFamily: flutterStyle.tryString('fontFamily'),
textAlign: ui.TextAlign.values[textAlignIndex],
textDirection: ui.TextDirection.values[textDirectionIndex],
fontWeight: fontWeight,
);
}
/// This information will be used for changing the style of the hidden input
/// element, which will match it's size to the size of the editable widget.
final double? fontSize;
final String fontWeight;
final String? fontFamily;
final ui.TextAlign textAlign;
final ui.TextDirection textDirection;
String? get align => textAlignToCssValue(textAlign, textDirection);
String get cssFont =>
'$fontWeight ${fontSize}px ${canonicalizeFontFamily(fontFamily)}';
void applyToDomElement(DomHTMLElement domElement) {
domElement.style
..textAlign = align!
..font = cssFont;
}
}
/// Describes the location and size of the editing element on the screen.
///
/// This information is received via "TextInput.setEditableSizeAndTransform"
/// message from the framework.
@immutable
class EditableTextGeometry {
const EditableTextGeometry({
required this.width,
required this.height,
required this.globalTransform,
});
/// Parses the geometry from a message sent by the framework.
factory EditableTextGeometry.fromFrameworkMessage(
Map<String, dynamic> encodedGeometry,
) {
assert(encodedGeometry.containsKey('width'));
assert(encodedGeometry.containsKey('height'));
assert(encodedGeometry.containsKey('transform'));
final List<double> transformList =
List<double>.from(encodedGeometry.readList('transform'));
return EditableTextGeometry(
width: encodedGeometry.readDouble('width'),
height: encodedGeometry.readDouble('height'),
globalTransform: Float32List.fromList(transformList),
);
}
/// The width of the editable in local coordinates, i.e. before applying [globalTransform].
final double width;
/// The height of the editable in local coordinates, i.e. before applying [globalTransform].
final double height;
/// The aggregate transform rooted at the global (screen) coordinate system
/// that places and sizes the editable.
///
/// For correct sizing this transform must be applied to the [width] and
/// [height] fields.
final Float32List globalTransform;
/// Applies this geometry to the DOM element.
///
/// This assumes that the parent of the [domElement] has identity transform
/// applied to it (i.e. the default). If the parent has a non-identity
/// transform applied, this method will misplace the [domElement]. For
/// example, if the editable DOM element is nested inside the semantics
/// tree the semantics tree provides the placement parameters, in which
/// case this method should not be used.
void applyToDomElement(DomHTMLElement domElement) {
final String cssTransform = float64ListToCssTransform(globalTransform);
domElement.style
..width = '${width}px'
..height = '${height}px'
..transform = cssTransform;
}
}