• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2013 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5part of engine;
6
7/// Make the content editable span visible to facilitate debugging.
8const bool _debugVisibleTextEditing = false;
9
10void _emptyCallback(dynamic _) {}
11
12void _styleEditingElement(html.HtmlElement domElement) {
13  domElement.style
14    ..position = 'fixed'
15    ..whiteSpace = 'pre';
16  if (_debugVisibleTextEditing) {
17    domElement.style
18      ..bottom = '0'
19      ..right = '0'
20      ..font = '24px sans-serif'
21      ..color = 'purple'
22      ..backgroundColor = 'pink';
23  } else {
24    domElement.style
25      ..overflow = 'hidden'
26      ..transform = 'translate(-99999px, -99999px)'
27      // width and height can't be zero because then the element would stop
28      // receiving edits when its content is empty.
29      ..width = '1px'
30      ..height = '1px';
31  }
32  if (browserEngine == BrowserEngine.webkit) {
33    // TODO(flutter_web): Remove once webkit issue of paragraphs incorrectly
34    // rendering (shifting up) is resolved. Temporarily force relayout
35    // a frame after input is created.
36    html.window.animationFrame.then((num _) {
37      domElement.style
38        ..position = 'absolute'
39        ..bottom = '0'
40        ..right = '0';
41    });
42  }
43}
44
45html.InputElement _createInputElement() {
46  final html.InputElement input = html.InputElement();
47  _styleEditingElement(input);
48  return input;
49}
50
51html.TextAreaElement _createTextAreaElement() {
52  final html.TextAreaElement textarea = html.TextAreaElement();
53  _styleEditingElement(textarea);
54  return textarea;
55}
56
57/// The current text and selection state of a text field.
58class EditingState {
59  EditingState({this.text, this.baseOffset = 0, this.extentOffset = 0});
60
61  /// Creates an [EditingState] instance using values from an editing state Map
62  /// coming from Flutter.
63  ///
64  /// The `editingState` Map has the following structure:
65  /// ```json
66  /// {
67  ///   "text": "The text here",
68  ///   "selectionBase": 0,
69  ///   "selectionExtent": 0,
70  ///   "selectionAffinity": "TextAffinity.upstream",
71  ///   "selectionIsDirectional": false,
72  ///   "composingBase": -1,
73  ///   "composingExtent": -1
74  /// }
75  /// ```
76  EditingState.fromFlutter(Map<String, dynamic> flutterEditingState)
77      : text = flutterEditingState['text'],
78        baseOffset = flutterEditingState['selectionBase'],
79        extentOffset = flutterEditingState['selectionExtent'];
80
81  /// The counterpart of [EditingState.fromFlutter]. It generates a Map that
82  /// can be sent to Flutter.
83  // TODO(mdebbar): Should we get `selectionAffinity` and other properties from flutter's editing state?
84  Map<String, dynamic> toFlutter() => <String, dynamic>{
85        'text': text,
86        'selectionBase': baseOffset,
87        'selectionExtent': extentOffset,
88      };
89
90  /// The current text being edited.
91  final String text;
92
93  /// The offset at which the text selection originates.
94  final int baseOffset;
95
96  /// The offset at which the text selection terminates.
97  final int extentOffset;
98
99  /// Whether the current editing state is valid or not.
100  bool get isValid => baseOffset >= 0 && extentOffset >= 0;
101
102  @override
103  int get hashCode => ui.hashValues(text, baseOffset, extentOffset);
104
105  @override
106  bool operator ==(dynamic other) {
107    if (identical(this, other)) {
108      return true;
109    }
110    if (runtimeType != other.runtimeType) {
111      return false;
112    }
113    final EditingState typedOther = other;
114    return text == typedOther.text &&
115        baseOffset == typedOther.baseOffset &&
116        extentOffset == typedOther.extentOffset;
117  }
118
119  @override
120  String toString() {
121    return assertionsEnabled
122        ? 'EditingState("$text", base:$baseOffset, extent:$extentOffset)'
123        : super.toString();
124  }
125}
126
127/// Various types of inputs used in text fields.
128///
129/// These types are coming from Flutter's [TextInputType]. Currently, we don't
130/// support all the types. We fallback to [InputType.text] when Flutter sends
131/// a type that isn't supported.
132// TODO(flutter_web): Support more types.
133enum InputType {
134  /// Single-line plain text.
135  text,
136
137  /// Multi-line text.
138  multiline,
139}
140
141InputType _getInputTypeFromString(String inputType) {
142  switch (inputType) {
143    case 'TextInputType.multiline':
144      return InputType.multiline;
145
146    case 'TextInputType.text':
147    default:
148      return InputType.text;
149  }
150}
151
152/// Controls the appearance of the input control being edited.
153///
154/// For example, [inputType] determines whether we should use `<input>` or
155/// `<textarea>` as a backing DOM element.
156///
157/// This corresponds to Flutter's [TextInputConfiguration].
158class InputConfiguration {
159  InputConfiguration({
160    this.inputType,
161    this.obscureText = false,
162  });
163
164  InputConfiguration.fromFlutter(Map<String, dynamic> flutterInputConfiguration)
165      : inputType = _getInputTypeFromString(
166            flutterInputConfiguration['inputType']['name']),
167        obscureText = flutterInputConfiguration['obscureText'];
168
169  /// The type of information being edited in the input control.
170  final InputType inputType;
171
172  /// Whether to hide the text being edited.
173  final bool obscureText;
174}
175
176typedef _OnChangeCallback = void Function(EditingState editingState);
177
178enum ElementType {
179  /// The backing element is an `<input>`.
180  input,
181
182  /// The backing element is a `<textarea>`.
183  textarea,
184
185  /// The backing element is a `<span contenteditable="true">`.
186  contentEditable,
187}
188
189ElementType _getTypeFromElement(html.HtmlElement domElement) {
190  if (domElement is html.InputElement) {
191    return ElementType.input;
192  }
193  if (domElement is html.TextAreaElement) {
194    return ElementType.textarea;
195  }
196  final String contentEditable = domElement.contentEditable;
197  if (contentEditable != null &&
198      contentEditable.isNotEmpty &&
199      contentEditable != 'inherit') {
200    return ElementType.contentEditable;
201  }
202  return null;
203}
204
205/// Wraps the DOM element used to provide text editing capabilities.
206///
207/// The backing DOM element could be one of:
208///
209/// 1. `<input>`.
210/// 2. `<textarea>`.
211/// 3. `<span contenteditable="true">`.
212class TextEditingElement {
213  /// Creates a non-persistent [TextEditingElement].
214  ///
215  /// See [TextEditingElement.persistent] to understand what persistent mode is.
216  TextEditingElement();
217
218  bool _enabled = false;
219
220  html.HtmlElement domElement;
221  EditingState _lastEditingState;
222  _OnChangeCallback _onChange;
223
224  final List<StreamSubscription<html.Event>> _subscriptions =
225      <StreamSubscription<html.Event>>[];
226
227  ElementType get _elementType {
228    final ElementType type = _getTypeFromElement(domElement);
229    assert(type != null);
230    return type;
231  }
232
233  /// Enables the element so it can be used to edit text.
234  ///
235  /// Register [callback] so that it gets invoked whenever any change occurs in
236  /// the text editing element.
237  ///
238  /// Changes could be:
239  /// - Text changes, or
240  /// - Selection changes.
241  void enable(
242    InputConfiguration inputConfig, {
243    @required _OnChangeCallback onChange,
244  }) {
245    assert(!_enabled);
246
247    _initDomElement(inputConfig);
248    _enabled = true;
249    _onChange = onChange;
250
251    // Chrome on Android will hide the onscreen keyboard when you tap outside
252    // the text box. Instead, we want the framework to tell us to hide the
253    // keyboard via `TextInput.clearClient` or `TextInput.hide`.
254    //
255    // Safari on iOS does not hide the keyboard as a side-effect of tapping
256    // outside the editable box. Instead it provides an explicit "done" button,
257    // which is reported as "blur", so we must not reacquire focus when we see
258    // a "blur" event and let the keyboard disappear.
259    if (browserEngine == BrowserEngine.blink ||
260        browserEngine == BrowserEngine.unknown) {
261      _subscriptions.add(domElement.onBlur.listen((_) {
262        if (_enabled) {
263          _refocus();
264        }
265      }));
266    }
267
268    domElement.focus();
269
270    if (_lastEditingState != null) {
271      setEditingState(_lastEditingState);
272    }
273
274    // Subscribe to text and selection changes.
275    _subscriptions
276      ..add(html.document.onSelectionChange.listen(_handleChange))
277      ..add(domElement.onInput.listen(_handleChange));
278  }
279
280  /// Disables the element so it's no longer used for text editing.
281  ///
282  /// Calling [disable] also removes any registered event listeners.
283  void disable() {
284    assert(_enabled);
285
286    _enabled = false;
287    _lastEditingState = null;
288
289    for (int i = 0; i < _subscriptions.length; i++) {
290      _subscriptions[i].cancel();
291    }
292    _subscriptions.clear();
293    _removeDomElement();
294  }
295
296  void _initDomElement(InputConfiguration inputConfig) {
297    switch (inputConfig.inputType) {
298      case InputType.text:
299        domElement = _createInputElement();
300        break;
301
302      case InputType.multiline:
303        domElement = _createTextAreaElement();
304        break;
305
306      default:
307        throw UnsupportedError(
308            'Unsupported input type: ${inputConfig.inputType}');
309    }
310    html.document.body.append(domElement);
311  }
312
313  void _removeDomElement() {
314    domElement.remove();
315    domElement = null;
316  }
317
318  void _refocus() {
319    domElement.focus();
320  }
321
322  void setEditingState(EditingState editingState) {
323    _lastEditingState = editingState;
324    if (!_enabled || !editingState.isValid) {
325      return;
326    }
327
328    switch (_elementType) {
329      case ElementType.input:
330        final html.InputElement input = domElement;
331        input.value = editingState.text;
332        input.setSelectionRange(
333          editingState.baseOffset,
334          editingState.extentOffset,
335        );
336        break;
337
338      case ElementType.textarea:
339        final html.TextAreaElement textarea = domElement;
340        textarea.value = editingState.text;
341        textarea.setSelectionRange(
342          editingState.baseOffset,
343          editingState.extentOffset,
344        );
345        break;
346
347      case ElementType.contentEditable:
348        domRenderer.clearDom(domElement);
349        domElement.append(html.Text(editingState.text));
350        html.window.getSelection()
351          ..removeAllRanges()
352          ..addRange(_createRange(editingState));
353        break;
354    }
355
356    // Safari on iOS requires that we focus explicitly. Otherwise, the on-screen
357    // keyboard won't show up.
358    domElement.focus();
359  }
360
361  /// Swap out the current DOM element and replace it with a new one of type
362  /// [newElementType].
363  ///
364  /// Ideally, swapping the underlying DOM element should be seamless to the
365  /// user of this class.
366  ///
367  /// See also:
368  ///
369  /// * [PersistentTextEditingElement._swapDomElement], which notifies its users
370  ///   that the element has been swapped.
371  void _swapDomElement(ElementType newElementType) {
372    // TODO(mdebbar): Create the appropriate dom element and initialize it.
373  }
374
375  void _handleChange(html.Event event) {
376    _lastEditingState = calculateEditingState();
377    _onChange(_lastEditingState);
378  }
379
380  @visibleForTesting
381  EditingState calculateEditingState() {
382    assert(domElement != null);
383
384    EditingState editingState;
385    switch (_elementType) {
386      case ElementType.input:
387        final html.InputElement inputElement = domElement;
388        editingState = EditingState(
389          text: inputElement.value,
390          baseOffset: inputElement.selectionStart,
391          extentOffset: inputElement.selectionEnd,
392        );
393        break;
394
395      case ElementType.textarea:
396        final html.TextAreaElement textAreaElement = domElement;
397        editingState = EditingState(
398          text: textAreaElement.value,
399          baseOffset: textAreaElement.selectionStart,
400          extentOffset: textAreaElement.selectionEnd,
401        );
402        break;
403
404      case ElementType.contentEditable:
405        // In a contenteditable element, we want `innerText` since it correctly
406        // converts <br> to newline characters, for example.
407        //
408        // If we later decide to use <input> and/or <textarea> then we can go back
409        // to using `textContent` (or `value` in the case of <input>)
410        final String text = js_util.getProperty(domElement, 'innerText');
411        if (domElement.childNodes.length > 1) {
412          // Having multiple child nodes in a content editable element means one of
413          // two things:
414          // 1. Text contains new lines.
415          // 2. User pasted rich text.
416          final int prevSelectionEnd = math.max(
417              _lastEditingState.baseOffset, _lastEditingState.extentOffset);
418          final String prevText = _lastEditingState.text;
419          final int offsetFromEnd = prevText.length - prevSelectionEnd;
420
421          final int newSelectionExtent = text.length - offsetFromEnd;
422          // TODO(mdebbar): we may need to `setEditingState()` here.
423          editingState = EditingState(
424            text: text,
425            baseOffset: newSelectionExtent,
426            extentOffset: newSelectionExtent,
427          );
428        } else {
429          final html.Selection selection = html.window.getSelection();
430          editingState = EditingState(
431            text: text,
432            baseOffset: selection.baseOffset,
433            extentOffset: selection.extentOffset,
434          );
435        }
436    }
437
438    assert(editingState != null);
439    return editingState;
440  }
441
442  html.Range _createRange(EditingState editingState) {
443    final html.Node firstChild = domElement.firstChild;
444    return html.document.createRange()
445      ..setStart(firstChild, editingState.baseOffset)
446      ..setEnd(firstChild, editingState.extentOffset);
447  }
448}
449
450/// The implementation of a persistent mode for [TextEditingElement].
451///
452/// Persistent mode assumes the caller will own the creation, insertion and
453/// disposal of the DOM element.
454///
455/// This class is still responsible for hooking up the DOM element with the
456/// [HybridTextEditing] instance so that changes are communicated to Flutter.
457///
458/// Persistent mode is useful for callers that want to have full control over
459/// the placement and lifecycle of the DOM element. An example of such a caller
460/// is Semantic's TextField that needs to put the DOM element inside the
461/// semantic tree. It also requires that the DOM element remains in the tree
462/// when the user isn't editing.
463class PersistentTextEditingElement extends TextEditingElement {
464  /// Creates a [PersistentTextEditingElement] that eagerly instantiates
465  /// [domElement] so the caller can insert it before calling
466  /// [PersistentTextEditingElement.enable].
467  PersistentTextEditingElement(
468    html.HtmlElement domElement, {
469    @required html.VoidCallback onDomElementSwap,
470  })  : _onDomElementSwap = onDomElementSwap,
471        // Make sure the dom element is of a type that we support for text editing.
472        assert(_getTypeFromElement(domElement) != null) {
473    this.domElement = domElement;
474  }
475
476  final html.VoidCallback _onDomElementSwap;
477
478  @override
479  void _initDomElement(InputConfiguration inputConfig) {
480    // In persistent mode, the user of this class is supposed to insert the
481    // [domElement] on their own. Let's make sure they did.
482    assert(domElement != null);
483    assert(html.document.body.contains(domElement));
484  }
485
486  @override
487  void _removeDomElement() {
488    // In persistent mode, we don't want to remove the DOM element because the
489    // caller is responsible for that.
490    //
491    // Remove focus from the editable element to cause the keyboard to hide.
492    // Otherwise, the keyboard stays on screen even when the user navigates to
493    // a different screen (e.g. by hitting the "back" button).
494    domElement.blur();
495  }
496
497  @override
498  void _refocus() {
499    // The semantic text field on Android listens to the focus event in order to
500    // switch to a new text field. If we refocus here, we break that
501    // functionality and the user can't switch from one text field to another in
502    // accessibility mode.
503  }
504
505  @override
506  void _swapDomElement(ElementType newElementType) {
507    super._swapDomElement(newElementType);
508
509    // Unfortunately, in persistent mode, the user of this class has to be
510    // notified that the element is being swapped.
511    // TODO(mdebbar): do we need to call `old.replaceWith(new)` here?
512    _onDomElementSwap();
513  }
514}
515
516/// Text editing singleton.
517final HybridTextEditing textEditing = HybridTextEditing();
518
519/// Should be used as a singleton to provide support for text editing in
520/// Flutter Web.
521///
522/// The approach is "hybrid" because it relies on Flutter for
523/// displaying, and HTML for user interactions:
524///
525/// - HTML's contentEditable feature handles typing and text changes.
526/// - HTML's selection API handles selection changes and cursor movements.
527class HybridTextEditing {
528  /// The default HTML element used to manage editing state when a custom
529  /// element is not provided via [useCustomEditableElement].
530  TextEditingElement _defaultEditingElement = TextEditingElement();
531
532  /// The HTML element used to manage editing state.
533  ///
534  /// This field is populated using [useCustomEditableElement]. If `null` the
535  /// [_defaultEditableElement] is used instead.
536  TextEditingElement _customEditingElement;
537
538  TextEditingElement get editingElement {
539    if (_customEditingElement != null) {
540      return _customEditingElement;
541    }
542    return _defaultEditingElement;
543  }
544
545  /// Requests that [customEditingElement] is used for managing text editing state
546  /// instead of the hidden default element.
547  ///
548  /// Use [stopUsingCustomEditableElement] to switch back to default element.
549  void useCustomEditableElement(TextEditingElement customEditingElement) {
550    if (_isEditing && customEditingElement != _customEditingElement) {
551      _stopEditing();
552    }
553    _customEditingElement = customEditingElement;
554  }
555
556  /// Switches back to using the built-in default element for managing text
557  /// editing state.
558  void stopUsingCustomEditableElement() {
559    useCustomEditableElement(null);
560  }
561
562  int _clientId;
563  bool _isEditing = false;
564  Map<String, dynamic> _configuration;
565
566  /// All "flutter/textinput" platform messages should be sent to this method.
567  void handleTextInput(ByteData data) {
568    final MethodCall call = const JSONMethodCodec().decodeMethodCall(data);
569    switch (call.method) {
570      case 'TextInput.setClient':
571        _clientId = call.arguments[0];
572        _configuration = call.arguments[1];
573        break;
574
575      case 'TextInput.setEditingState':
576        editingElement
577            .setEditingState(EditingState.fromFlutter(call.arguments));
578        break;
579
580      case 'TextInput.show':
581        if (!_isEditing) {
582          _startEditing();
583        }
584        break;
585
586      case 'TextInput.clearClient':
587      case 'TextInput.hide':
588        if (_isEditing) {
589          _stopEditing();
590        }
591        break;
592    }
593  }
594
595  void _startEditing() {
596    assert(!_isEditing);
597    _isEditing = true;
598    editingElement.enable(
599      InputConfiguration.fromFlutter(_configuration),
600      onChange: _syncEditingStateToFlutter,
601    );
602  }
603
604  void _stopEditing() {
605    assert(_isEditing);
606    _isEditing = false;
607    editingElement.disable();
608  }
609
610  void _syncEditingStateToFlutter(EditingState editingState) {
611    ui.window.onPlatformMessage(
612      'flutter/textinput',
613      const JSONMethodCodec().encodeMethodCall(
614        MethodCall('TextInputClient.updateEditingState', <dynamic>[
615          _clientId,
616          editingState.toFlutter(),
617        ]),
618      ),
619      _emptyCallback,
620    );
621  }
622}
623