blob: 5fcee58e8951fbc87eab50bc457e142c8d90c4e6 [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.
// @dart = 2.6
part of engine;
/// Make the content editable span visible to facilitate debugging.
const bool _debugVisibleTextEditing = false;
/// The `keyCode` of the "Enter" key.
const int _kReturnKeyCode = 13;
void _emptyCallback(dynamic _) {}
/// 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';
/// 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) {
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'
..textShadow = 'transparent'
..transformOrigin = '0 0 0';
/// 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({this.formElement, this.elements, this.items});
final html.FormElement formElement;
final Map<String, html.HtmlElement> elements;
final Map<String, AutofillInfo> items;
factory 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 bool singleElement = (fields == null);
final AutofillInfo focusedElement =
AutofillInfo.fromFrameworkMessage(focusedElementAutofill);
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;
_hideAutofillElements(formElement);
if (!singleElement) {
for (Map<String, dynamic> field in fields) {
final Map<String, dynamic> autofillInfo = field['autofill'];
final AutofillInfo autofill =
AutofillInfo.fromFrameworkMessage(autofillInfo);
// The focused text editing element will not be created here.
if (autofill.uniqueIdentifier != focusedElement.uniqueIdentifier) {
EngineInputType engineInputType =
EngineInputType.fromName(field['inputType']['name']);
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);
}
}
}
return EngineAutofillForm(
formElement: formElement,
elements: elements,
items: items,
);
}
void placeForm(html.HtmlElement mainTextEditingElement) {
formElement.append(mainTextEditingElement);
domRenderer.glassPaneElement.append(formElement);
}
void removeForm() {
formElement.remove();
}
/// 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() {
Iterable<String> keys = elements.keys;
List<StreamSubscription<html.Event>> subscriptions =
<StreamSubscription<html.Event>>[];
keys.forEach((String key) {
final html.Element element = elements[key];
subscriptions.add(element.onInput.listen((html.Event e) {
_handleChange(element, key);
}));
});
return subscriptions;
}
void _handleChange(html.Element domElement, String tag) {
EditingState newEditingState = EditingState.fromDomElement(domElement);
_sendAutofillEditingState(tag, newEditingState);
}
/// Sends the 'TextInputClient.updateEditingStateWithTag' message to the framework.
void _sendAutofillEditingState(String tag, EditingState editingState) {
if (window._onPlatformMessage != null) {
window.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({this.editingState, this.uniqueIdentifier, this.hint});
/// The current text and selection state of a text field.
final EditingState editingState;
/// Unique value set by the developer.
///
/// Used as id of the text field.
final String uniqueIdentifier;
/// 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) {
// Autofill value can be null if no TextFields is set with autofill hint.
if (autofill == null) {
return null;
}
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);
}
void applyToDomElement(html.HtmlElement domElement,
{bool focusedElement = false}) {
domElement.id = hint;
if (domElement is html.InputElement) {
html.InputElement element = domElement;
element.name = hint;
element.id = uniqueIdentifier;
element.autocomplete = hint;
// Do not change the element type for the focused element.
if (focusedElement == false) {
if (hint.contains('password')) {
element.type = 'password';
} else {
element.type = 'text';
}
}
} else if (domElement is html.TextAreaElement) {
html.TextAreaElement element = domElement;
element.name = hint;
element.id = uniqueIdentifier;
element.setAttribute('autocomplete', hint);
}
}
}
/// The current text and selection state of a text field.
@visibleForTesting
class EditingState {
EditingState({this.text, this.baseOffset = 0, this.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: math.max(0, selectionBase),
extentOffset: math.max(0, 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) {
html.InputElement element = domElement;
return EditingState(
text: element.value,
baseOffset: element.selectionStart,
extentOffset: element.selectionEnd);
} else if (domElement is html.TextAreaElement) {
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 ==(dynamic other) {
if (identical(this, other)) {
return true;
}
if (runtimeType != other.runtimeType) {
return false;
}
final EditingState typedOther = other;
return text == typedOther.text &&
baseOffset == typedOther.baseOffset &&
extentOffset == typedOther.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.
void applyToDomElement(html.HtmlElement domElement) {
if (domElement is html.InputElement) {
html.InputElement element = domElement;
element.value = text;
element.setSelectionRange(baseOffset, extentOffset);
} else if (domElement is html.TextAreaElement) {
html.TextAreaElement element = domElement;
element.value = text;
element.setSelectionRange(baseOffset, extentOffset);
} 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({
@required this.inputType,
@required this.inputAction,
@required this.obscureText,
@required this.autocorrect,
this.autofill,
this.autofillGroup,
});
InputConfiguration.fromFrameworkMessage(
Map<String, dynamic> flutterInputConfiguration)
: inputType = EngineInputType.fromName(
flutterInputConfiguration['inputType']['name']),
inputAction = flutterInputConfiguration['inputAction'],
obscureText = flutterInputConfiguration['obscureText'],
autocorrect = flutterInputConfiguration['autocorrect'],
autofill = AutofillInfo.fromFrameworkMessage(
flutterInputConfiguration['autofill']),
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 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;
}
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() {
super.placeElement();
_geometry?.applyToDomElement(domElement);
}
}
/// 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);
@visibleForTesting
bool isEnabled = false;
html.HtmlElement domElement;
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>>[];
@override
void initializeTextEditing(
InputConfiguration inputConfig, {
@required _OnChangeCallback onChange,
@required _OnActionCallback onAction,
}) {
assert(!isEnabled);
this._inputConfiguration = inputConfig;
domElement = inputConfig.inputType.createDomElement();
if (inputConfig.obscureText) {
domElement.setAttribute('type', 'password');
}
inputConfig.autofill?.applyToDomElement(domElement, focusedElement: true);
final String autocorrectValue = inputConfig.autocorrect ? 'on' : 'off';
domElement.setAttribute('autocorrect', autocorrectValue);
_setStaticStyleAttributes(domElement);
_style?.applyToDomElement(domElement);
if (_inputConfiguration.autofillGroup != null) {
_inputConfiguration.autofillGroup.placeForm(domElement);
} else {
domRenderer.glassPaneElement.append(domElement);
}
initializeElementPlacement();
isEnabled = true;
_onChange = onChange;
_onAction = onAction;
}
@override
void initializeElementPlacement() {
placeElement();
}
@override
void addEventHandlers() {
if (_inputConfiguration.autofillGroup != null) {
_subscriptions
.addAll(_inputConfiguration.autofillGroup.addInputEventListeners());
}
// Subscribe to text and selection changes.
_subscriptions.add(domElement.onInput.listen(_handleChange));
_subscriptions.add(domElement.onKeyDown.listen(_maybeSendAction));
_subscriptions.add(html.document.onSelectionChange.listen(_handleChange));
// The behavior for blur in DOM elements changes depending on the reason of
// blur:
//
// (1) If the blur is triggered due to tab change or browser minimize, same
// element receives the focus as soon as the page reopens. Hence, text
// editing connection does not need to be closed. In this case we dot blur
// the DOM element.
//
// (2) On the other hand if the blur is triggered due to interaction with
// another element on the page, the current text connection is obsolete so
// connection close request is send to Flutter.
//
// See [HybridTextEditing.sendTextConnectionClosedToFlutterIfAny].
//
// In order to detect between these two cases, after a blur event is
// triggered [domRenderer.windowHasFocus] method which checks the window
// focus is called.
_subscriptions.add(domElement.onBlur.listen((_) {
if (domRenderer.windowHasFocus) {
// Focus is still on the body. Continue with blur.
owner.sendTextConnectionClosedToFrameworkIfAny();
} else {
// Refocus.
domElement.focus();
}
}));
preventDefaultForMouseEvents();
}
@override
void updateElementPlacement(EditableTextGeometry geometry) {
_geometry = geometry;
if (isEnabled) {
placeElement();
}
}
@mustCallSuper
@override
void updateElementStyle(EditableTextStyle style) {
_style = style;
if (isEnabled) {
_style.applyToDomElement(domElement);
}
}
@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();
domElement.remove();
domElement = null;
_inputConfiguration.autofillGroup?.removeForm();
}
@mustCallSuper
@override
void setEditingState(EditingState editingState) {
_lastEditingState = editingState;
if (!isEnabled || !editingState.isValid) {
return;
}
_lastEditingState.applyToDomElement(domElement);
}
/// Puts the DOM element used for text editing on the UI at the appropriate
/// location and sizes it accordingly.
@mustCallSuper
void placeElement() {
domElement.focus();
}
void _handleChange(html.Event event) {
assert(isEnabled);
assert(domElement != null);
EditingState newEditingState = EditingState.fromDomElement(domElement);
assert(newEditingState != null);
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(this._lastEditingState);
}
// Re-focuses after setting editing state.
domElement.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(domElement.onMouseDown.listen((_) {
_.preventDefault();
}));
_subscriptions.add(domElement.onMouseUp.listen((_) {
_.preventDefault();
}));
_subscriptions.add(domElement.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 =
const 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(domElement);
}
@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.
domElement.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(domElement.onInput.listen(_handleChange));
_subscriptions.add(domElement.onKeyDown.listen(_maybeSendAction));
_subscriptions.add(html.document.onSelectionChange.listen(_handleChange));
// Position the DOM element after it is focused.
_subscriptions.add(domElement.onFocus.listen((_) {
// Cancel previous timer if exists.
_schedulePlacement();
}));
_addTapListener();
// On iOS, blur is trigerred if the virtual keyboard is closed or the
// browser is sent to background or the browser tab is changed.
//
// Since in all these cases, the connection needs to be closed,
// [domRenderer.windowHasFocus] is not checked in [IOSTextEditingStrategy].
_subscriptions.add(domElement.onBlur.listen((_) {
owner.sendTextConnectionClosedToFrameworkIfAny();
}));
}
@override
void updateElementPlacement(EditableTextGeometry geometry) {
_geometry = geometry;
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(domElement.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();
});
}
}
/// 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(domElement);
}
@override
void addEventHandlers() {
if (_inputConfiguration.autofillGroup != null) {
_subscriptions
.addAll(_inputConfiguration.autofillGroup.addInputEventListeners());
}
// Subscribe to text and selection changes.
_subscriptions.add(domElement.onInput.listen(_handleChange));
_subscriptions.add(domElement.onKeyDown.listen(_maybeSendAction));
_subscriptions.add(html.document.onSelectionChange.listen(_handleChange));
_subscriptions.add(domElement.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.
domElement.focus();
} else {
owner.sendTextConnectionClosedToFrameworkIfAny();
}
}));
}
}
/// 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 addEventHandlers() {
if (_inputConfiguration.autofillGroup != null) {
_subscriptions
.addAll(_inputConfiguration.autofillGroup.addInputEventListeners());
}
// Subscribe to text and selection changes.
_subscriptions.add(domElement.onInput.listen(_handleChange));
_subscriptions.add(domElement.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(domElement.onKeyUp.listen((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(domElement.onSelect.listen(_handleChange));
// For Firefox, we also use the same approach as the parent class.
//
// Do not blur the DOM element if the user goes to another tab or minimizes
// the browser. See [super.addEventHandlers] for more comments.
//
// The different part is, in Firefox, we are not able to get correct value
// when we check the window focus like [domRendered.windowHasFocus].
//
// However [document.activeElement] always equals to [domElement] if the
// user goes to another tab, minimizes the browser or opens the dev tools.
// Hence [document.activeElement] is checked in this listener.
_subscriptions.add(domElement.onBlur.listen((_) {
html.Element activeElement = html.document.activeElement;
if (activeElement != domElement) {
// Focus is still on the body. Continue with blur.
owner.sendTextConnectionClosedToFrameworkIfAny();
} else {
// Refocus.
domElement.focus();
}
}));
preventDefaultForMouseEvents();
}
}
/// 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);
switch (call.method) {
case 'TextInput.setClient':
implementation.setClient(
call.arguments[0],
InputConfiguration.fromFrameworkMessage(call.arguments[1]),
);
break;
case 'TextInput.setEditingState':
implementation
.setEditingState(EditingState.fromFrameworkMessage(call.arguments));
break;
case 'TextInput.show':
implementation.show();
break;
case 'TextInput.setEditableSizeAndTransform':
implementation.setEditableSizeAndTransform(
EditableTextGeometry.fromFrameworkMessage(call.arguments));
break;
case 'TextInput.setStyle':
implementation
.setStyle(EditableTextStyle.fromFrameworkMessage(call.arguments));
break;
case 'TextInput.clearClient':
implementation.clearClient();
break;
case 'TextInput.hide':
implementation.hide();
break;
case 'TextInput.requestAutofill':
// No-op: This message is sent by the framework to requests the platform autofill UI to appear.
// Since autofill UI is a part of the browser, web engine does not need to utilize this method.
break;
default:
throw StateError(
'Unsupported method call on the flutter/textinput channel: ${call.method}');
}
window._replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true));
}
/// Sends the 'TextInputClient.updateEditingState' message to the framework.
void updateEditingState(int clientId, EditingState editingState) {
if (window._onPlatformMessage != null) {
window.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) {
if (window._onPlatformMessage != null) {
window.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) {
if (window._onPlatformMessage != null) {
window.invokeOnPlatformMessage(
'flutter/textinput',
const JSONMethodCodec().encodeMethodCall(
MethodCall(
'TextInputClient.onConnectionClosed',
<dynamic>[clientId],
),
),
_emptyCallback,
);
}
}
}
/// Text editing singleton.
final HybridTextEditing textEditing = HybridTextEditing();
/// 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() {
if (browserEngine == BrowserEngine.webkit &&
operatingSystem == OperatingSystem.iOs) {
this._defaultEditingElement = IOSTextEditingStrategy(this);
} else if (browserEngine == BrowserEngine.blink &&
operatingSystem == OperatingSystem.android) {
this._defaultEditingElement = AndroidTextEditingStrategy(this);
} else if (browserEngine == BrowserEngine.firefox) {
this._defaultEditingElement = FirefoxTextEditingStrategy(this);
} else {
this._defaultEditingElement = GloballyPositionedTextEditingStrategy(this);
}
channel = TextEditingChannel(this);
}
TextEditingChannel channel;
/// The text editing stategy used. It can change depending on the
/// formfactor/browser.
///
/// It uses an HTML element to manage editing state when a custom element is
/// not provided via [useCustomEditableElement]
DefaultTextEditingStrategy _defaultEditingElement;
/// The HTML element used to manage editing state.
///
/// This field is populated using [useCustomEditableElement]. If `null` the
/// [_defaultEditingElement] is used instead.
DefaultTextEditingStrategy _customEditingElement;
DefaultTextEditingStrategy get editingElement {
if (_customEditingElement != null) {
return _customEditingElement;
}
return _defaultEditingElement;
}
/// Responds to the 'TextInput.setClient' message.
void setClient(int clientId, InputConfiguration configuration) {
final bool clientIdChanged = _clientId != null && _clientId != clientId;
if (clientIdChanged && isEditing) {
stopEditing();
}
_clientId = clientId;
_configuration = configuration;
}
/// Responds to the 'TextInput.setEditingState' message.
void setEditingState(EditingState state) {
editingElement.setEditingState(state);
}
/// Responds to the 'TextInput.show' message.
void show() {
if (!isEditing) {
_startEditing();
}
}
/// Responds to the 'TextInput.setEditableSizeAndTransform' message.
void setEditableSizeAndTransform(EditableTextGeometry geometry) {
editingElement.updateElementPlacement(geometry);
}
/// Responds to the 'TextInput.setStyle' message.
void setStyle(EditableTextStyle style) {
editingElement.updateElementStyle(style);
}
/// Responds to the 'TextInput.clearClient' message.
void clearClient() {
// We do not distinguish between "clearClient" and "hide" on the Web.
hide();
}
/// Responds to the 'TextInput.hide' message.
void hide() {
if (isEditing) {
stopEditing();
}
}
/// A CSS class name used to identify all elements used for text editing.
@visibleForTesting
static const String textEditingClass = 'flt-text-editing';
static bool isEditingElement(html.Element element) {
return element.classes.contains(textEditingClass);
}
/// Requests that [customEditingElement] is used for managing text editing state
/// instead of the hidden default element.
///
/// Use [stopUsingCustomEditableElement] to switch back to default element.
void useCustomEditableElement(
DefaultTextEditingStrategy customEditingElement) {
if (isEditing && customEditingElement != _customEditingElement) {
stopEditing();
}
_customEditingElement = customEditingElement;
}
/// Switches back to using the built-in default element for managing text
/// editing state.
void stopUsingCustomEditableElement() {
useCustomEditableElement(null);
}
int _clientId;
/// Flag which shows if there is an ongoing editing.
///
/// Also used to define if a keyboard is needed.
@visibleForTesting
bool isEditing = false;
InputConfiguration _configuration;
void _startEditing() {
assert(!isEditing);
isEditing = true;
editingElement.enable(
_configuration,
onChange: (EditingState editingState) {
channel.updateEditingState(_clientId, editingState);
},
onAction: (String inputAction) {
channel.performAction(_clientId, inputAction);
},
);
}
void stopEditing() {
assert(isEditing);
isEditing = false;
editingElement.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 ${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 {
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;
}
}