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