diff --git a/lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart b/lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart index 1dc10b9fef846..4ec158546337b 100644 --- a/lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart +++ b/lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart @@ -95,11 +95,8 @@ final class ViewFocusBinding { } int? _viewId(DomElement? element) { - final DomElement? rootElement = element?.closest(DomManager.flutterViewTagName); - if (rootElement == null) { - return null; - } - return _viewManager.viewIdForRootElement(rootElement); + final FlutterViewManager viewManager = EnginePlatformDispatcher.instance.viewManager; + return viewManager.findViewForElement(element)?.viewId; } void _handleViewCreated(int viewId) { diff --git a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart index 672a5ad3be61d..ac3c7b187fedf 100644 --- a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart +++ b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart @@ -18,6 +18,8 @@ import '../semantics.dart'; import '../services.dart'; import '../text/paragraph.dart'; import '../util.dart'; +import '../view_embedder/flutter_view_manager.dart'; +import '../window.dart'; import 'autofill_hint.dart'; import 'composition_aware_mixin.dart'; import 'input_action.dart'; @@ -48,12 +50,6 @@ const String transparentTextEditingClass = 'transparentTextEditing'; void _emptyCallback(dynamic _) {} -/// The default [HostNode] that hosts all DOM required for text editing when a11y is not enabled. -@visibleForTesting -// TODO(mdebbar): There could be multiple views with multiple text editing hosts. -// https://github.com/flutter/flutter/issues/137344 -DomElement get defaultTextEditingRoot => EnginePlatformDispatcher.instance.implicitView!.dom.textEditingHost; - /// These style attributes are constant throughout the life time of an input /// element. /// @@ -147,6 +143,37 @@ void _styleAutofillElements( elementStyle.setProperty('caret-color', 'transparent'); } +void _ensureEditingElementInView(DomElement element, int viewId) { + final bool isAlreadyAppended = element.isConnected ?? false; + if (!isAlreadyAppended) { + // If the element is not already appended to a view, we don't need to move + // it anywhere. + return; + } + + final FlutterViewManager viewManager = EnginePlatformDispatcher.instance.viewManager; + final EngineFlutterView? currentView = viewManager.findViewForElement(element); + if (currentView == null) { + // For some reason, the input element was in the DOM, but it wasn't part of + // any Flutter view. Should we throw? + return; + } + + if (currentView.viewId != viewId) { + _insertEditingElementInView(element, viewId); + } +} + +void _insertEditingElementInView(DomElement element, int viewId) { + final FlutterViewManager viewManager = EnginePlatformDispatcher.instance.viewManager; + final EngineFlutterView? view = viewManager[viewId]; + assert( + view != null, + 'Could not find View with id $viewId. This should never happen, please file a bug!', + ); + view!.dom.textEditingHost.append(element); +} + /// Form that contains all the fields in the same AutofillGroup. /// /// An [EngineAutofillForm] will only be constructed when autofill is enabled @@ -154,6 +181,7 @@ void _styleAutofillElements( /// static method. class EngineAutofillForm { EngineAutofillForm({ + required this.viewId, required this.formElement, this.elements, this.items, @@ -177,6 +205,9 @@ class EngineAutofillForm { /// See [formsOnTheDom]. final String formIdentifier; + /// The ID of the view that this form is rendered into. + final int viewId; + /// Creates an [EngineAutofillFrom] from the JSON representation of a Flutter /// framework `TextInputConfiguration` object. /// @@ -189,6 +220,7 @@ class EngineAutofillForm { /// /// Returns null if autofill is disabled for the input field. static EngineAutofillForm? fromFrameworkMessage( + int viewId, Map? focusedElementAutofill, List? fields, ) { @@ -312,6 +344,7 @@ class EngineAutofillForm { insertionReferenceNode ??= submitButton; return EngineAutofillForm( + viewId: viewId, formElement: formElement, elements: elements, items: items, @@ -330,7 +363,7 @@ class EngineAutofillForm { } formElement.insertBefore(mainTextEditingElement, insertionReferenceNode); - defaultTextEditingRoot.append(formElement); + _insertEditingElementInView(formElement, viewId); } void storeForm() { @@ -944,6 +977,7 @@ class EditingState { /// This corresponds to Flutter's [TextInputConfiguration]. class InputConfiguration { InputConfiguration({ + required this.viewId, this.inputType = EngineInputType.text, this.inputAction = 'TextInputAction.done', this.obscureText = false, @@ -958,7 +992,8 @@ class InputConfiguration { InputConfiguration.fromFrameworkMessage( Map flutterInputConfiguration) - : inputType = EngineInputType.fromName( + : viewId = flutterInputConfiguration.tryInt('viewId') ?? kImplicitViewId, + inputType = EngineInputType.fromName( flutterInputConfiguration.readJson('inputType').readString('name'), isDecimal: flutterInputConfiguration.readJson('inputType').tryBool('decimal') ?? false, isMultiline: flutterInputConfiguration.readJson('inputType').tryBool('isMultiline') ?? false, @@ -976,11 +1011,15 @@ class InputConfiguration { flutterInputConfiguration.readJson('autofill')) : null, autofillGroup = EngineAutofillForm.fromFrameworkMessage( + flutterInputConfiguration.tryInt('viewId') ?? kImplicitViewId, flutterInputConfiguration.tryJson('autofill'), flutterInputConfiguration.tryList('fields'), ), enableDeltaModel = flutterInputConfiguration.tryBool('enableDeltaModel') ?? false; + /// The ID of the view that contains the text field. + final int viewId; + /// The type of information being edited in the input control. final EngineInputType inputType; @@ -1257,7 +1296,7 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements // DOM later, when the first location information arrived. // Otherwise, on Blink based Desktop browsers, the autofill menu appears // on top left of the screen. - defaultTextEditingRoot.append(activeDomElement); + _insertEditingElementInView(activeDomElement, inputConfig.viewId); _appendedToForm = false; } @@ -1293,6 +1332,9 @@ abstract class DefaultTextEditingStrategy with CompositionAwareMixin implements autofill.applyToDomElement(activeDomElement, focusedElement: true); } else { activeDomElement.setAttribute('autocomplete', 'off'); + // When the new input configuration contains a different view ID, we need + // to move the input element to the new view. + _ensureEditingElementInView(activeDomElement, inputConfiguration.viewId); } final String autocorrectValue = config.autocorrect ? 'on' : 'off'; @@ -1757,7 +1799,7 @@ class AndroidTextEditingStrategy extends GloballyPositionedTextEditingStrategy { if (hasAutofillGroup) { placeForm(); } else { - defaultTextEditingRoot.append(activeDomElement); + _insertEditingElementInView(activeDomElement, inputConfig.viewId); } inputConfig.textCapitalization.setAutocapitalizeAttribute( activeDomElement); diff --git a/lib/web_ui/lib/src/engine/view_embedder/flutter_view_manager.dart b/lib/web_ui/lib/src/engine/view_embedder/flutter_view_manager.dart index 684e5ea667c22..2d2bc639e361a 100644 --- a/lib/web_ui/lib/src/engine/view_embedder/flutter_view_manager.dart +++ b/lib/web_ui/lib/src/engine/view_embedder/flutter_view_manager.dart @@ -12,12 +12,15 @@ class FlutterViewManager { // A map of EngineFlutterViews indexed by their viewId. final Map _viewData = {}; + // A map of (optional) JsFlutterViewOptions, indexed by their viewId. final Map _jsViewOptions = {}; + // The controller of the [onViewCreated] stream. final StreamController _onViewCreatedController = StreamController.broadcast(sync: true); + // The controller of the [onViewDisposed] stream. final StreamController _onViewDisposedController = StreamController.broadcast(sync: true); @@ -82,7 +85,7 @@ class FlutterViewManager { /// /// Returns its [JsFlutterViewOptions] (if any). JsFlutterViewOptions? unregisterView(int viewId) { - _viewData.remove(viewId); // .dispose(); + _viewData.remove(viewId); final JsFlutterViewOptions? jsViewOptions = _jsViewOptions.remove(viewId); _onViewDisposedController.add(viewId); return jsViewOptions; @@ -96,14 +99,13 @@ class FlutterViewManager { return _jsViewOptions[viewId]; } - /// Returns the [viewId] if [rootElement] corresponds to any of the [views]. - int? viewIdForRootElement(DomElement rootElement) { - for(final EngineFlutterView view in views) { - if (view.dom.rootElement == rootElement) { - return view.viewId; - } - } - return null; + EngineFlutterView? findViewForElement(DomElement? element) { + const String viewRootSelector = + '${DomManager.flutterViewTagName}[${GlobalHtmlAttributes.flutterViewIdAttributeName}]'; + final DomElement? viewRoot = element?.closest(viewRootSelector); + final String? viewIdAttribute = viewRoot?.getAttribute(GlobalHtmlAttributes.flutterViewIdAttributeName); + final int? viewId = viewIdAttribute == null ? null : int.parse(viewIdAttribute); + return viewId == null ? null : _viewData[viewId]; } void dispose() { diff --git a/lib/web_ui/test/engine/composition_test.dart b/lib/web_ui/test/engine/composition_test.dart index 6e743139bd47d..75551745b3d56 100644 --- a/lib/web_ui/test/engine/composition_test.dart +++ b/lib/web_ui/test/engine/composition_test.dart @@ -6,14 +6,13 @@ import 'dart:async'; import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; -import 'package:ui/src/engine/browser_detection.dart'; - -import 'package:ui/src/engine/dom.dart'; -import 'package:ui/src/engine/text_editing/composition_aware_mixin.dart'; -import 'package:ui/src/engine/text_editing/text_editing.dart'; +import 'package:ui/src/engine.dart'; import '../common/test_initialization.dart'; +DomElement get defaultTextEditingRoot => + EnginePlatformDispatcher.instance.implicitView!.dom.textEditingHost; + void main() { internalBootstrapBrowserTest(() => testMain); } @@ -36,7 +35,10 @@ GloballyPositionedTextEditingStrategy _enableEditingStrategy({ }) { final HybridTextEditing owner = HybridTextEditing(); - owner.configuration = InputConfiguration(enableDeltaModel: deltaModel); + owner.configuration = InputConfiguration( + viewId: kImplicitViewId, + enableDeltaModel: deltaModel, + ); final GloballyPositionedTextEditingStrategy editingStrategy = GloballyPositionedTextEditingStrategy(owner); diff --git a/lib/web_ui/test/engine/semantics/text_field_test.dart b/lib/web_ui/test/engine/semantics/text_field_test.dart index 60e4cbaa31487..52a41b1ae197d 100644 --- a/lib/web_ui/test/engine/semantics/text_field_test.dart +++ b/lib/web_ui/test/engine/semantics/text_field_test.dart @@ -15,9 +15,10 @@ import 'package:ui/ui.dart' as ui; import '../../common/test_initialization.dart'; import 'semantics_tester.dart'; -final InputConfiguration singlelineConfig = InputConfiguration(); +final InputConfiguration singlelineConfig = InputConfiguration(viewId: kImplicitViewId); final InputConfiguration multilineConfig = InputConfiguration( + viewId: kImplicitViewId, inputType: EngineInputType.multiline, inputAction: 'TextInputAction.newline', ); diff --git a/lib/web_ui/test/engine/text_editing_test.dart b/lib/web_ui/test/engine/text_editing_test.dart index a9fa99443eb5b..1d9b026f2414f 100644 --- a/lib/web_ui/test/engine/text_editing_test.dart +++ b/lib/web_ui/test/engine/text_editing_test.dart @@ -8,16 +8,7 @@ import 'dart:typed_data'; import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; - -import 'package:ui/src/engine/browser_detection.dart'; -import 'package:ui/src/engine/dom.dart'; -import 'package:ui/src/engine/raw_keyboard.dart'; -import 'package:ui/src/engine/services.dart'; -import 'package:ui/src/engine/text_editing/autofill_hint.dart'; -import 'package:ui/src/engine/text_editing/input_type.dart'; -import 'package:ui/src/engine/text_editing/text_editing.dart'; -import 'package:ui/src/engine/util.dart'; -import 'package:ui/src/engine/vector_math.dart'; +import 'package:ui/src/engine.dart'; import 'package:ui/ui.dart' as ui; import '../common/spy.dart'; @@ -28,6 +19,11 @@ const int _kReturnKeyCode = 13; const MethodCodec codec = JSONMethodCodec(); +EnginePlatformDispatcher get dispatcher => EnginePlatformDispatcher.instance; + +DomElement get defaultTextEditingRoot => + dispatcher.implicitView!.dom.textEditingHost; + /// Add unit tests for [FirefoxTextEditingStrategy]. // TODO(mdebbar): https://github.com/flutter/flutter/issues/46891 @@ -36,11 +32,14 @@ EditingState? lastEditingState; TextEditingDeltaState? editingDeltaState; String? lastInputAction; -final InputConfiguration singlelineConfig = InputConfiguration(); +final InputConfiguration singlelineConfig = InputConfiguration( + viewId: kImplicitViewId, +); final Map flutterSinglelineConfig = createFlutterConfig('text'); final InputConfiguration multilineConfig = InputConfiguration( + viewId: kImplicitViewId, inputType: EngineInputType.multiline, inputAction: 'TextInputAction.newline', ); @@ -129,8 +128,38 @@ Future testMain() async { domDocument.body); }); + test('inserts element in the correct view', () { + final DomElement host = createDomElement('div'); + domDocument.body!.append(host); + final EngineFlutterView view = EngineFlutterView(dispatcher, host); + dispatcher.viewManager.registerView(view); + final DomElement textEditingHost = view.dom.textEditingHost; + + expect(domDocument.getElementsByTagName('input'), hasLength(0)); + expect(textEditingHost.getElementsByTagName('input'), hasLength(0)); + + final InputConfiguration config = InputConfiguration(viewId: view.viewId); + editingStrategy!.enable( + config, + onChange: trackEditingState, + onAction: trackInputAction, + ); + final DomElement input = editingStrategy!.domElement!; + + // Input is appended to the right view. + expect(textEditingHost.contains(input), isTrue); + + // Cleanup. + editingStrategy!.disable(); + expect(textEditingHost.querySelectorAll('input'), hasLength(0)); + dispatcher.viewManager.unregisterView(view.viewId); + view.dispose(); + host.remove(); + }); + test('Respects read-only config', () { final InputConfiguration config = InputConfiguration( + viewId: kImplicitViewId, readOnly: true, ); editingStrategy!.enable( @@ -148,6 +177,7 @@ Future testMain() async { test('Knows how to create password fields', () { final InputConfiguration config = InputConfiguration( + viewId: kImplicitViewId, obscureText: true, ); editingStrategy!.enable( @@ -165,6 +195,7 @@ Future testMain() async { test('Knows how to create non-default text actions', () { final InputConfiguration config = InputConfiguration( + viewId: kImplicitViewId, inputAction: 'TextInputAction.send' ); editingStrategy!.enable( @@ -186,6 +217,7 @@ Future testMain() async { test('Knows to turn autocorrect off', () { final InputConfiguration config = InputConfiguration( + viewId: kImplicitViewId, autocorrect: false, ); editingStrategy!.enable( @@ -202,7 +234,7 @@ Future testMain() async { }); test('Knows to turn autocorrect on', () { - final InputConfiguration config = InputConfiguration(); + final InputConfiguration config = InputConfiguration(viewId: kImplicitViewId); editingStrategy!.enable( config, onChange: trackEditingState, @@ -217,7 +249,7 @@ Future testMain() async { }); test('Knows to turn autofill off', () { - final InputConfiguration config = InputConfiguration(); + final InputConfiguration config = InputConfiguration(viewId: kImplicitViewId); editingStrategy!.enable( config, onChange: trackEditingState, @@ -352,7 +384,7 @@ Future testMain() async { }); test('Triggers input action', () { - final InputConfiguration config = InputConfiguration(); + final InputConfiguration config = InputConfiguration(viewId: kImplicitViewId); editingStrategy!.enable( config, onChange: trackEditingState, @@ -371,10 +403,10 @@ Future testMain() async { }); test('handling keyboard event prevents triggering input action', () { - final ui.PlatformMessageCallback? savedCallback = ui.PlatformDispatcher.instance.onPlatformMessage; + final ui.PlatformMessageCallback? savedCallback = dispatcher.onPlatformMessage; bool markTextEventHandled = false; - ui.PlatformDispatcher.instance.onPlatformMessage = (String channel, ByteData? data, + dispatcher.onPlatformMessage = (String channel, ByteData? data, ui.PlatformMessageResponseCallback? callback) { final ByteData response = const JSONMessageCodec() .encodeMessage({'handled': markTextEventHandled})!; @@ -382,7 +414,7 @@ Future testMain() async { }; RawKeyboard.initialize(); - final InputConfiguration config = InputConfiguration(); + final InputConfiguration config = InputConfiguration(viewId: kImplicitViewId); editingStrategy!.enable( config, onChange: trackEditingState, @@ -412,12 +444,13 @@ Future testMain() async { // Input action received. expect(lastInputAction, 'TextInputAction.done'); - ui.PlatformDispatcher.instance.onPlatformMessage = savedCallback; + dispatcher.onPlatformMessage = savedCallback; RawKeyboard.instance?.dispose(); }); test('Triggers input action in multi-line mode', () { final InputConfiguration config = InputConfiguration( + viewId: kImplicitViewId, inputType: EngineInputType.multiline, ); editingStrategy!.enable( @@ -443,6 +476,7 @@ Future testMain() async { test('Triggers input action in multiline-none mode', () { final InputConfiguration config = InputConfiguration( + viewId: kImplicitViewId, inputType: EngineInputType.multilineNone, ); editingStrategy!.enable( @@ -468,7 +502,7 @@ Future testMain() async { test('Triggers input action and prevent new line key event for single line field', () { // Regression test for https://github.com/flutter/flutter/issues/113559 - final InputConfiguration config = InputConfiguration(); + final InputConfiguration config = InputConfiguration(viewId: kImplicitViewId); editingStrategy!.enable( config, onChange: trackEditingState, @@ -590,16 +624,24 @@ Future testMain() async { /// Returns the `clientId` used in the platform message. int showKeyboard({ required String inputType, + int? viewId, String? inputAction, bool decimal = false, bool isMultiline = false, + bool autofillEnabled = true, }) { final MethodCall setClient = MethodCall( 'TextInput.setClient', [ ++clientId, - createFlutterConfig(inputType, - inputAction: inputAction, decimal: decimal, isMultiline: isMultiline), + createFlutterConfig( + inputType, + viewId: viewId, + inputAction: inputAction, + decimal: decimal, + isMultiline: isMultiline, + autofillEnabled: autofillEnabled, + ), ], ); sendFrameworkMessage(codec.encodeMethodCall(setClient)); @@ -2481,6 +2523,186 @@ Future testMain() async { expect(event.defaultPrevented, isFalse); }); + test('inserts element in the correct view', () async { + final DomElement host = createDomElement('div'); + domDocument.body!.append(host); + final EngineFlutterView view = EngineFlutterView(dispatcher, host); + dispatcher.viewManager.registerView(view); + + textEditing = HybridTextEditing(); + showKeyboard(inputType: 'text', viewId: view.viewId); + // The Safari strategy doesn't insert the input element into the DOM until + // it has received the geometry information. + final List transform = Matrix4.identity().storage.toList(); + final MethodCall setSizeAndTransform = configureSetSizeAndTransformMethodCall(10, 10, transform); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + + await waitForDesktopSafariFocus(); + + final DomElement input = textEditing!.strategy.domElement!; + + + // Input is appended to the right view. + expect(view.dom.textEditingHost.contains(input), isTrue); + + // Cleanup. + hideKeyboard(); + dispatcher.viewManager.unregisterView(view.viewId); + view.dispose(); + host.remove(); + }); + + test('moves element to correct view', () { + final DomElement host1 = createDomElement('div'); + domDocument.body!.append(host1); + final EngineFlutterView view1 = EngineFlutterView(dispatcher, host1); + dispatcher.viewManager.registerView(view1); + + final DomElement host2 = createDomElement('div'); + domDocument.body!.append(host2); + final EngineFlutterView view2 = EngineFlutterView(dispatcher, host2); + dispatcher.viewManager.registerView(view2); + + textEditing = HybridTextEditing(); + showKeyboard(inputType: 'text', viewId: view1.viewId, autofillEnabled: false); + + final DomElement input = textEditing!.strategy.domElement!; + + // Input is appended to view1. + expect(view1.dom.textEditingHost.contains(input), isTrue); + + sendFrameworkMessage(codec.encodeMethodCall(MethodCall( + 'TextInput.updateConfig', + createFlutterConfig('text', viewId: view2.viewId, autofillEnabled: false), + ))); + + // The input element is the same (no new element was created), but it has + // moved to view2. + expect(textEditing!.strategy.domElement, input); + expect(view2.dom.textEditingHost.contains(input), isTrue); + + // Cleanup. + hideKeyboard(); + dispatcher.viewManager.unregisterView(view1.viewId); + view1.dispose(); + dispatcher.viewManager.unregisterView(view2.viewId); + view2.dispose(); + host1.remove(); + host2.remove(); + }); + + test('places autofill form in the correct view', () async { + final DomElement host = createDomElement('div'); + domDocument.body!.append(host); + final EngineFlutterView view = EngineFlutterView(dispatcher, host); + dispatcher.viewManager.registerView(view); + + textEditing = HybridTextEditing(); + + // Create a configuration with an AutofillGroup of three text fields. + final Map flutterMultiAutofillElementConfig = + createFlutterConfig( + 'text', + viewId: view.viewId, + autofillHint: 'username', + autofillHintsForFields: ['username', 'email', 'name'], + ); + final MethodCall setClient = MethodCall( + 'TextInput.setClient', + [123, flutterMultiAutofillElementConfig], + ); + sendFrameworkMessage(codec.encodeMethodCall(setClient)); + + const MethodCall show = MethodCall('TextInput.show'); + sendFrameworkMessage(codec.encodeMethodCall(show)); + // The Safari strategy doesn't insert the input element into the DOM until + // it has received the geometry information. + final List transform = Matrix4.identity().storage.toList(); + final MethodCall setSizeAndTransform = configureSetSizeAndTransformMethodCall(10, 10, transform); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + + await waitForDesktopSafariFocus(); + + final DomElement input = textEditing!.strategy.domElement!; + final DomElement form = textEditing!.configuration!.autofillGroup!.formElement; + + // Input and form are appended to the right view. + expect(view.dom.textEditingHost.contains(input), isTrue); + expect(view.dom.textEditingHost.contains(form), isTrue); + + // Cleanup. + hideKeyboard(); + dispatcher.viewManager.unregisterView(view.viewId); + view.dispose(); + host.remove(); + }); + + test('moves autofill form to the correct view', () async { + final DomElement host1 = createDomElement('div'); + domDocument.body!.append(host1); + final EngineFlutterView view1 = EngineFlutterView(dispatcher, host1); + dispatcher.viewManager.registerView(view1); + + final DomElement host2 = createDomElement('div'); + domDocument.body!.append(host2); + final EngineFlutterView view2 = EngineFlutterView(dispatcher, host2); + dispatcher.viewManager.registerView(view2); + + textEditing = HybridTextEditing(); + + // Create a configuration with an AutofillGroup of three text fields. + final Map autofillConfig1 = createFlutterConfig( + 'text', + viewId: view1.viewId, + autofillHint: 'username', + autofillHintsForFields: ['username', 'email', 'name'], + ); + final MethodCall setClient = MethodCall( + 'TextInput.setClient', + [123, autofillConfig1], + ); + sendFrameworkMessage(codec.encodeMethodCall(setClient)); + + const MethodCall show = MethodCall('TextInput.show'); + sendFrameworkMessage(codec.encodeMethodCall(show)); + + await waitForDesktopSafariFocus(); + + final DomElement input = textEditing!.strategy.domElement!; + final DomElement form = textEditing!.configuration!.autofillGroup!.formElement; + + // Input and form are appended to view1. + expect(view1.dom.textEditingHost.contains(input), isTrue); + expect(view1.dom.textEditingHost.contains(form), isTrue); + + // Move the input and form to view2. + final Map autofillConfig2 = createFlutterConfig( + 'text', + viewId: view2.viewId, + autofillHint: 'username', + autofillHintsForFields: ['username', 'email', 'name'], + ); + sendFrameworkMessage(codec.encodeMethodCall(MethodCall( + 'TextInput.updateConfig', + autofillConfig2, + ))); + + // Input and form are in view2. + expect(view2.dom.textEditingHost.contains(input), isTrue); + expect(view2.dom.textEditingHost.contains(form), isTrue); + + // Cleanup. + hideKeyboard(); + dispatcher.viewManager.unregisterView(view1.viewId); + view1.dispose(); + dispatcher.viewManager.unregisterView(view2.viewId); + view2.dispose(); + host1.remove(); + host2.remove(); + // TODO(mdebbar): Autofill forms don't get updated in the current system. + // https://github.com/flutter/flutter/issues/145101 + }, skip: true); + tearDown(() { clearForms(); }); @@ -2493,7 +2715,10 @@ Future testMain() async { ['field1', 'field2', 'field3']); final EngineAutofillForm autofillForm = EngineAutofillForm.fromFrameworkMessage( - createAutofillInfo('username', 'field1'), fields)!; + kImplicitViewId, + createAutofillInfo('username', 'field1'), + fields, + )!; // Number of elements if number of fields sent to the constructor minus // one (for the focused text element). @@ -2550,7 +2775,10 @@ Future testMain() async { ['zzyyxx', 'aabbcc', 'jjkkll']); final EngineAutofillForm autofillForm = EngineAutofillForm.fromFrameworkMessage( - createAutofillInfo('username', 'field1'), fields)!; + kImplicitViewId, + createAutofillInfo('username', 'field1'), + fields, + )!; expect(autofillForm.formIdentifier, 'aabbcc*jjkkll*zzyyxx'); }); @@ -2563,7 +2791,10 @@ Future testMain() async { ['field1', 'fields2', 'field3']); final EngineAutofillForm autofillForm = EngineAutofillForm.fromFrameworkMessage( - createAutofillInfo('username', 'field1'), fields)!; + kImplicitViewId, + createAutofillInfo('username', 'field1'), + fields, + )!; final DomHTMLInputElement testInputElement = createDomHTMLInputElement(); autofillForm.placeForm(testInputElement); @@ -2590,7 +2821,10 @@ Future testMain() async { ); final EngineAutofillForm autofillForm = EngineAutofillForm.fromFrameworkMessage( - createAutofillInfo('username', 'field1'), fields)!; + kImplicitViewId, + createAutofillInfo('username', 'field1'), + fields, + )!; // The focused element is the only field. Form should be empty after // the initialization (focus element is appended later). @@ -2615,7 +2849,7 @@ Future testMain() async { ['field1'], ); final EngineAutofillForm? autofillForm = - EngineAutofillForm.fromFrameworkMessage(null, fields); + EngineAutofillForm.fromFrameworkMessage(kImplicitViewId, null, fields); expect(autofillForm, isNull); }); @@ -2632,7 +2866,10 @@ Future testMain() async { ]); final EngineAutofillForm autofillForm = EngineAutofillForm.fromFrameworkMessage( - createAutofillInfo('email', 'field1'), fields)!; + kImplicitViewId, + createAutofillInfo('email', 'field1'), + fields, + )!; expect(autofillForm.elements, hasLength(2)); @@ -2675,7 +2912,10 @@ Future testMain() async { ]); final EngineAutofillForm autofillForm = EngineAutofillForm.fromFrameworkMessage( - createAutofillInfo('email', 'field1'), fields)!; + kImplicitViewId, + createAutofillInfo('email', 'field1'), + fields, + )!; final List formChildNodes = autofillForm.formElement.childNodes.toList() as List; @@ -2707,7 +2947,10 @@ Future testMain() async { ]); final EngineAutofillForm autofillForm = EngineAutofillForm.fromFrameworkMessage( - createAutofillInfo('email', 'field1'), fields)!; + kImplicitViewId, + createAutofillInfo('email', 'field1'), + fields, + )!; final List formChildNodes = autofillForm.formElement.childNodes.toList() as List; @@ -2738,7 +2981,10 @@ Future testMain() async { ]); final EngineAutofillForm autofillForm = EngineAutofillForm.fromFrameworkMessage( - createAutofillInfo('email', 'field1'), fields)!; + kImplicitViewId, + createAutofillInfo('email', 'field1'), + fields, + )!; final DomHTMLInputElement testInputElement = createDomHTMLInputElement(); testInputElement.name = 'email'; @@ -3378,6 +3624,7 @@ void checkTextAreaEditingState( /// simplicity. Map createFlutterConfig( String inputType, { + int? viewId, bool readOnly = false, bool obscureText = false, bool autocorrect = true, @@ -3397,6 +3644,7 @@ Map createFlutterConfig( if (decimal) 'decimal': true, if (isMultiline) 'isMultiline': true, }, + if (viewId != null) 'viewId': viewId, 'readOnly': readOnly, 'obscureText': obscureText, 'autocorrect': autocorrect, diff --git a/lib/web_ui/test/engine/view_embedder/flutter_view_manager_test.dart b/lib/web_ui/test/engine/view_embedder/flutter_view_manager_test.dart index 5dbec513268d0..3ff2cd027e1df 100644 --- a/lib/web_ui/test/engine/view_embedder/flutter_view_manager_test.dart +++ b/lib/web_ui/test/engine/view_embedder/flutter_view_manager_test.dart @@ -102,13 +102,74 @@ Future doTests() async { }); }); - group('viewIdForRootElement', () { - test('works', () { - final EngineFlutterView view = EngineFlutterView(platformDispatcher, createDomElement('div')); - final int viewId = view.viewId; + group('findViewForElement', () { + test('finds view for root and descendant elements', () { + final DomElement host = createDomElement('div'); + final EngineFlutterView view = EngineFlutterView(platformDispatcher, host); + + viewManager.registerView(view); + + final DomElement rootElement = view.dom.rootElement; + final DomElement child1 = createDomElement('div'); + final DomElement child2 = createDomElement('div'); + final DomElement child3 = createDomElement('div'); + rootElement.append(child1); + rootElement.append(child2); + child2.append(child3); + + expect(viewManager.findViewForElement(rootElement), view); + expect(viewManager.findViewForElement(child1), view); + expect(viewManager.findViewForElement(child2), view); + expect(viewManager.findViewForElement(child3), view); + }); + + test('returns null for host element', () { + final DomElement host = createDomElement('div'); + final EngineFlutterView view = EngineFlutterView(platformDispatcher, host); + viewManager.registerView(view); + + expect(viewManager.findViewForElement(host), isNull); + }); + + test("returns null for elements that don't belong to any view", () { + final DomElement host = createDomElement('div'); + final EngineFlutterView view = EngineFlutterView(platformDispatcher, host); + viewManager.registerView(view); + + final DomElement disconnectedElement = createDomElement('div'); + final DomElement childOfBody = createDomElement('div'); + + domDocument.body!.append(childOfBody); + + expect(viewManager.findViewForElement(disconnectedElement), isNull); + expect(viewManager.findViewForElement(childOfBody), isNull); + expect(viewManager.findViewForElement(domDocument.body), isNull); + }); + + test('does not recognize elements from unregistered views', () { + final DomElement host = createDomElement('div'); + final EngineFlutterView view = EngineFlutterView(platformDispatcher, host); viewManager.registerView(view); - expect(viewManager.viewIdForRootElement(view.dom.rootElement), viewId); + final DomElement rootElement = view.dom.rootElement; + final DomElement child1 = createDomElement('div'); + final DomElement child2 = createDomElement('div'); + final DomElement child3 = createDomElement('div'); + rootElement.append(child1); + rootElement.append(child2); + child2.append(child3); + + expect(viewManager.findViewForElement(rootElement), view); + expect(viewManager.findViewForElement(child1), view); + expect(viewManager.findViewForElement(child2), view); + expect(viewManager.findViewForElement(child3), view); + + viewManager.unregisterView(view.viewId); + + expect(viewManager.findViewForElement(rootElement), isNull); + expect(viewManager.findViewForElement(child1), isNull); + expect(viewManager.findViewForElement(child2), isNull); + expect(viewManager.findViewForElement(child3), isNull); }); }); });