| // 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; |
| } |
| } |