blob: a5cb3e475ecbdcc71f7eba66dc6cd486197fe9f5 [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_renderer.dart';
import '../host_node.dart';
import '../platform_dispatcher.dart';
import '../semantics.dart';
import '../services.dart';
import '../text/paragraph.dart';
import '../util.dart';
import 'autofill_hint.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 => domRenderer.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(html.HtmlElement domElement) {
domElement.classes.add(HybridTextEditing.textEditingClass);
final html.CssStyleDeclaration 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.classes.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(html.HtmlElement domElement,
{bool isOffScreen = false}) {
final html.CssStyleDeclaration 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.classes.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.
///
/// These values are to be used when autofill is enabled and there is a group of
/// text fields with more than one text field.
class EngineAutofillForm {
EngineAutofillForm(
{required this.formElement,
this.elements,
this.items,
this.formIdentifier = ''});
final html.FormElement formElement;
final Map<String, html.HtmlElement>? 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;
static EngineAutofillForm? fromFrameworkMessage(
Map<String, dynamic>? focusedElementAutofill,
List<dynamic>? fields,
) {
// Autofill value can be null if focused text element does not have an
// autofill hint set.
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, html.HtmlElement> elements = <String, html.HtmlElement>{};
final Map<String, AutofillInfo> items = <String, AutofillInfo>{};
final html.FormElement formElement = html.FormElement();
// Validation is in the framework side.
formElement.noValidate = true;
formElement.method = 'post';
formElement.action = '#';
formElement.addEventListener('submit', (html.Event 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['autofill'];
final AutofillInfo autofill = AutofillInfo.fromFrameworkMessage(
autofillInfo,
textCapitalization: TextCapitalizationConfig.fromInputConfiguration(
field['textCapitalization']));
ids.add(autofill.uniqueIdentifier);
if (autofill.uniqueIdentifier != focusedElement.uniqueIdentifier) {
final EngineInputType engineInputType =
EngineInputType.fromName(field['inputType']['name']);
final html.HtmlElement 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 html.FormElement? 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 html.InputElement submitButton = html.InputElement();
_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(html.HtmlElement 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<StreamSubscription<html.Event>> addInputEventListeners() {
final Iterable<String> keys = elements!.keys;
final List<StreamSubscription<html.Event>> subscriptions =
<StreamSubscription<html.Event>>[];
final void Function(String key) addSubscriptionForKey = (String key) {
final html.Element element = elements![key]!;
subscriptions.add(
element.onInput.listen((html.Event e) {
if (items![key] == null) {
throw StateError(
'Autofill would not work withuot Autofill value set');
} else {
final AutofillInfo autofillInfo = items![key]!;
handleChange(element, autofillInfo);
}
})
);
};
keys.forEach(addSubscriptionForKey);
return subscriptions;
}
void handleChange(html.Element domElement, AutofillInfo autofillInfo) {
final EditingState newEditingState = EditingState.fromDomElement(
domElement as html.HtmlElement?);
_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.hint,
required this.textCapitalization});
/// 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;
/// Attribute used for autofill.
///
/// 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 hint;
factory AutofillInfo.fromFrameworkMessage(Map<String, dynamic> autofill,
{TextCapitalizationConfig textCapitalization =
const TextCapitalizationConfig.defaultCapitalization()}) {
assert(autofill != null); // ignore: unnecessary_null_comparison
final String uniqueIdentifier = autofill['uniqueIdentifier']!;
final List<dynamic> hintsList = autofill['hints'];
final EditingState editingState =
EditingState.fromFrameworkMessage(autofill['editingValue']);
return AutofillInfo(
uniqueIdentifier: uniqueIdentifier,
hint: BrowserAutofillHints.instance.flutterToEngine(hintsList[0]),
editingState: editingState,
textCapitalization: textCapitalization);
}
void applyToDomElement(html.HtmlElement domElement,
{bool focusedElement = false}) {
domElement.id = hint;
if (domElement is html.InputElement) {
final html.InputElement element = domElement;
element.name = hint;
element.id = hint;
element.autocomplete = hint;
if (hint.contains('password')) {
element.type = 'password';
} else {
element.type = 'text';
}
} else if (domElement is html.TextAreaElement) {
final html.TextAreaElement element = domElement;
element.name = hint;
element.id = hint;
element.setAttribute('autocomplete', hint);
}
}
}
/// The current text and selection state of a text field.
class EditingState {
EditingState({this.text, int? baseOffset, int? extentOffset}) :
// 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 int selectionBase = flutterEditingState['selectionBase'];
final int selectionExtent = flutterEditingState['selectionExtent'];
final String? text = flutterEditingState['text'];
return EditingState(
text: text,
baseOffset: selectionBase,
extentOffset: selectionExtent,
);
}
/// 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(html.HtmlElement? domElement) {
if (domElement is html.InputElement) {
final html.InputElement element = domElement;
return EditingState(
text: element.value,
baseOffset: element.selectionStart,
extentOffset: element.selectionEnd);
} else if (domElement is html.TextAreaElement) {
final html.TextAreaElement element = domElement;
return EditingState(
text: element.value,
baseOffset: element.selectionStart,
extentOffset: element.selectionEnd);
} else {
throw UnsupportedError('Initialized with unsupported input type');
}
}
/// 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,
};
/// 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;
/// Whether the current editing state is valid or not.
bool get isValid => baseOffset! >= 0 && extentOffset! >= 0;
@override
int get hashCode => ui.hashValues(text, baseOffset, extentOffset);
@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;
}
@override
String toString() {
return assertionsEnabled
? 'EditingState("$text", base:$baseOffset, extent:$extentOffset)'
: 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(html.HtmlElement? domElement) {
if (domElement is html.InputElement) {
final html.InputElement element = domElement;
element.value = text;
element.setSelectionRange(baseOffset!, extentOffset!);
} else if (domElement is html.TextAreaElement) {
final html.TextAreaElement element = domElement;
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(html.HtmlElement? domElement) {
if (domElement is html.InputElement) {
final html.InputElement element = domElement;
element.value = text;
} else if (domElement is html.TextAreaElement) {
final html.TextAreaElement element = domElement;
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,
});
InputConfiguration.fromFrameworkMessage(
Map<String, dynamic> flutterInputConfiguration)
: inputType = EngineInputType.fromName(
flutterInputConfiguration['inputType']['name'],
isDecimal: flutterInputConfiguration['inputType']['decimal'] ?? false,
),
inputAction =
flutterInputConfiguration['inputAction'] ?? 'TextInputAction.done',
obscureText = flutterInputConfiguration['obscureText'] ?? false,
readOnly = flutterInputConfiguration['readOnly'] ?? false,
autocorrect = flutterInputConfiguration['autocorrect'] ?? true,
textCapitalization = TextCapitalizationConfig.fromInputConfiguration(
flutterInputConfiguration['textCapitalization'],
),
autofill = flutterInputConfiguration.containsKey('autofill')
? AutofillInfo.fromFrameworkMessage(
flutterInputConfiguration['autofill'])
: null,
autofillGroup = EngineAutofillForm.fromFrameworkMessage(
flutterInputConfiguration['autofill'],
flutterInputConfiguration['fields']);
/// 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 AutofillInfo? autofill;
final EngineAutofillForm? autofillGroup;
final TextCapitalizationConfig textCapitalization;
}
typedef OnChangeCallback = void Function(EditingState? editingState);
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() {
if (hasAutofillGroup) {
geometry?.applyToDomElement(focusedFormElement!);
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.
if (lastEditingState != null) {
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();
} else {
geometry?.applyToDomElement(activeDomElement);
}
}
}
/// 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 will
// arrive after `show` call. 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();
if (lastEditingState != null) {
lastEditingState!.applyToDomElement(activeDomElement);
}
}
}
@override
void initializeElementPlacement() {
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 implements TextEditingStrategy {
final HybridTextEditing owner;
DefaultTextEditingStrategy(this.owner);
bool isEnabled = false;
/// The DOM element used for editing, if any.
html.HtmlElement? 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.
html.HtmlElement get activeDomElement {
assert(
domElement != null,
'The DOM element of this text editing strategy is not currently active.',
);
return domElement!;
}
late InputConfiguration inputConfiguration;
EditingState? lastEditingState;
/// 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<StreamSubscription<html.Event>> subscriptions =
<StreamSubscription<html.Event>>[];
bool get hasAutofillGroup => inputConfiguration.autofillGroup != null;
/// Whether the focused input element is part of a form.
bool get appendedToForm => _appendedToForm;
bool _appendedToForm = false;
html.FormElement? 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');
}
config.autofill?.applyToDomElement(activeDomElement, focusedElement: true);
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(activeDomElement.onInput.listen(handleChange));
subscriptions.add(activeDomElement.onKeyDown.listen(maybeSendAction));
subscriptions.add(html.document.onSelectionChange.listen(handleChange));
// Refocus on the activeDomElement after blur, so that user can keep editing the
// text field.
subscriptions.add(activeDomElement.onBlur.listen((_) {
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;
style = null;
geometry = null;
for (int i = 0; i < subscriptions.length; i++) {
subscriptions[i].cancel();
}
subscriptions.clear();
// 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(html.Event event) {
assert(isEnabled);
final EditingState newEditingState = EditingState.fromDomElement(activeDomElement);
if (newEditingState != lastEditingState) {
lastEditingState = newEditingState;
onChange!(lastEditingState);
}
}
void maybeSendAction(html.Event event) {
if (event is html.KeyboardEvent) {
if (inputConfiguration.inputType.submitActionOnEnter &&
event.keyCode == _kReturnKeyCode) {
event.preventDefault();
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(activeDomElement.onMouseDown.listen((_) {
_.preventDefault();
}));
subscriptions.add(activeDomElement.onMouseUp.listen((_) {
_.preventDefault();
}));
subscriptions.add(activeDomElement.onMouseMove.listen((_) {
_.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);
/// 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(activeDomElement.onInput.listen(handleChange));
subscriptions.add(activeDomElement.onKeyDown.listen(maybeSendAction));
subscriptions.add(html.document.onSelectionChange.listen(handleChange));
// Position the DOM element after it is focused.
subscriptions.add(activeDomElement.onFocus.listen((_) {
// Cancel previous timer if exists.
_schedulePlacement();
}));
_addTapListener();
// 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 [domRenderer.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.
subscriptions.add(activeDomElement.onBlur.listen((_) {
if (domRenderer.windowHasFocus) {
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(activeDomElement.onClick.listen((_) {
// 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(activeDomElement.onInput.listen(handleChange));
subscriptions.add(activeDomElement.onKeyDown.listen(maybeSendAction));
subscriptions.add(html.document.onSelectionChange.listen(handleChange));
subscriptions.add(activeDomElement.onBlur.listen((_) {
if (domRenderer.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 [domRenderer.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(activeDomElement.onInput.listen(handleChange));
subscriptions.add(activeDomElement.onKeyDown.listen(maybeSendAction));
// 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(activeDomElement.onKeyUp.listen((html.KeyboardEvent 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(activeDomElement.onSelect.listen(handleChange));
// Refocus on the activeDomElement after blur, so that user can keep editing the
// text field.
subscriptions.add(activeDomElement.onBlur.listen((_) {
_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.
if (lastEditingState != null) {
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, html.FormElement form) {
final html.InputElement submitBtn =
form.getElementsByClassName('submitBtn').first as html.InputElement;
submitBtn.click();
});
}
/// Removes the forms from the DOM.
///
/// Called when the form is finalized.
void cleanForms() {
for (final html.FormElement 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],
configuration: InputConfiguration.fromFrameworkMessage(call.arguments[1]),
);
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);
command = const TextInputUpdateConfig();
break;
case 'TextInput.setEditingState':
command = TextInputSetEditingState(
state: EditingState.fromFrameworkMessage(call.arguments),
);
break;
case 'TextInput.show':
command = const TextInputShow();
break;
case 'TextInput.setEditableSizeAndTransform':
command = TextInputSetEditableSizeAndTransform(
geometry: EditableTextGeometry.fromFrameworkMessage(call.arguments),
);
break;
case 'TextInput.setStyle':
command = TextInputSetStyle(
style: EditableTextStyle.fromFrameworkMessage(call.arguments),
);
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.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, html.FormElement> formsOnTheDom =
<String, html.FormElement>{};
/// 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() {
channel = TextEditingChannel(this);
}
late TextEditingChannel channel;
/// 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) {
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'];
final int textDirectionIndex = flutterStyle['textDirectionIndex'];
final int? fontWeightIndex = flutterStyle['fontWeightIndex'];
// 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['fontSize'],
fontFamily: flutterStyle['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(html.HtmlElement 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['transform']);
return EditableTextGeometry(
width: encodedGeometry['width'],
height: encodedGeometry['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(html.HtmlElement domElement) {
final String cssTransform = float64ListToCssTransform(globalTransform);
domElement.style
..width = '${width}px'
..height = '${height}px'
..transform = cssTransform;
}
}