diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index c8fc375a77e55..ed9fa709a2549 100755 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -543,6 +543,9 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/onscreen_logging.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/picture.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_dispatcher.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_views.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_views/message_handler.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_views/slots.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/plugins.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/pointer_binding.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/pointer_converter.dart diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart index 0ddb456d21d09..e99a759eed549 100644 --- a/lib/web_ui/lib/src/engine.dart +++ b/lib/web_ui/lib/src/engine.dart @@ -248,6 +248,9 @@ part 'engine/onscreen_logging.dart'; part 'engine/picture.dart'; part 'engine/platform_dispatcher.dart'; part 'engine/platform_views.dart'; +part 'engine/platform_views/content_manager.dart'; +part 'engine/platform_views/message_handler.dart'; +part 'engine/platform_views/slots.dart'; part 'engine/profiler.dart'; part 'engine/rrect_renderer.dart'; part 'engine/semantics/accessibility.dart'; @@ -413,6 +416,11 @@ class NullTreeSanitizer implements html.NodeTreeSanitizer { void sanitizeTree(html.Node node) {} } +/// The shared instance of PlatformViewManager shared across the engine to handle +/// rendering of PlatformViews into the web app. +/// TODO(dit): How to make this overridable from tests? +final PlatformViewManager platformViewManager = PlatformViewManager(); + /// Converts a matrix represented using [Float64List] to one represented using /// [Float32List]. /// diff --git a/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart b/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart index 808805d94bf06..a1d0eb6fbfab6 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart @@ -3,13 +3,11 @@ // found in the LICENSE file. import 'dart:html' as html; -import 'dart:typed_data'; -import 'package:ui/src/engine.dart' show window, NullTreeSanitizer; +import 'package:ui/src/engine.dart' show window, NullTreeSanitizer, platformViewManager, createPlatformViewSlot; import 'package:ui/ui.dart' as ui; import '../html/path_to_svg_clip.dart'; -import '../services.dart'; import '../util.dart'; import '../vector_math.dart'; import 'canvas.dart'; @@ -37,11 +35,13 @@ class HtmlViewEmbedder { final Map _currentCompositionParams = {}; - /// The HTML element associated with the given view id. - final Map _views = {}; - - /// The root view in the stack of mutator elements for the view id. - final Map _rootViews = {}; + /// The clip chain for a view Id. + /// + /// This contains: + /// * The root view in the stack of mutator elements for the view id. + /// * The slot view in the stack (what shows the actual platform view contents). + /// * The number of clipping elements used last time the view was composited. + final Map _viewClipChains = {}; /// Surfaces used to draw on top of platform views, keyed by platform view ID. /// @@ -51,18 +51,12 @@ class HtmlViewEmbedder { /// The views that need to be recomposited into the scene on the next frame. final Set _viewsToRecomposite = {}; - /// The views that need to be disposed of on the next frame. - final Set _viewsToDispose = {}; - /// The list of view ids that should be composited, in order. List _compositionOrder = []; /// The most recent composition order. List _activeCompositionOrder = []; - /// The number of clipping elements used last time the view was composited. - Map _clipCount = {}; - /// The size of the frame, in physical pixels. ui.Size _frameSize = ui.window.physicalSize; @@ -74,76 +68,6 @@ class HtmlViewEmbedder { _frameSize = size; } - void handlePlatformViewCall( - ByteData? data, - ui.PlatformMessageResponseCallback? callback, - ) { - const MethodCodec codec = StandardMethodCodec(); - final MethodCall decoded = codec.decodeMethodCall(data); - - switch (decoded.method) { - case 'create': - _create(decoded, callback); - return; - case 'dispose': - _dispose(decoded, callback!); - return; - } - callback!(null); - } - - void _create( - MethodCall methodCall, ui.PlatformMessageResponseCallback? callback) { - final Map args = methodCall.arguments; - final int? viewId = args['id']; - final String? viewType = args['viewType']; - const MethodCodec codec = StandardMethodCodec(); - - if (_views[viewId] != null) { - callback!(codec.encodeErrorEnvelope( - code: 'recreating_view', - message: 'trying to create an already created view', - details: 'view id: $viewId', - )); - return; - } - - final ui.PlatformViewFactory? factory = - ui.platformViewRegistry.registeredFactories[viewType]; - if (factory == null) { - callback!(codec.encodeErrorEnvelope( - code: 'unregistered_view_type', - message: 'trying to create a view with an unregistered type', - details: 'unregistered view type: $viewType', - )); - return; - } - - // TODO(het): Support creation parameters. - html.Element embeddedView = factory(viewId!); - _views[viewId] = embeddedView; - - _rootViews[viewId] = embeddedView; - - callback!(codec.encodeSuccessEnvelope(null)); - } - - void _dispose( - MethodCall methodCall, ui.PlatformMessageResponseCallback callback) { - final int? viewId = methodCall.arguments; - const MethodCodec codec = StandardMethodCodec(); - if (viewId == null || !_views.containsKey(viewId)) { - callback(codec.encodeErrorEnvelope( - code: 'unknown_view', - message: 'trying to dispose an unknown view', - details: 'view id: $viewId', - )); - return; - } - _viewsToDispose.add(viewId); - callback(codec.encodeSuccessEnvelope(null)); - } - List getCurrentCanvases() { final List canvases = []; for (int i = 0; i < _compositionOrder.length; i++) { @@ -179,28 +103,39 @@ class HtmlViewEmbedder { } void _compositeWithParams(int viewId, EmbeddedViewParams params) { - final html.Element platformView = _views[viewId]!; - platformView.style.width = '${params.size.width}px'; - platformView.style.height = '${params.size.height}px'; - platformView.style.position = 'absolute'; + // If we haven't seen this viewId yet, cache it for clips/transforms. + ViewClipChain clipChain = _viewClipChains.putIfAbsent(viewId, () { + return ViewClipChain(view: createPlatformViewSlot(viewId)); + }); - // disables pointer events. Reenable them here because the - // underlying platform view would want to handle the pointer events. - platformView.style.pointerEvents = 'auto'; + html.Element slot = clipChain.slot; + // See `apply()` in the PersistedPlatformView class for the HTML version + // of this code. + slot.style + ..width = '${params.size.width}px' + ..height = '${params.size.height}px' + ..position = 'absolute'; + + // Recompute the position in the DOM of the `slot` element... final int currentClippingCount = _countClips(params.mutators); - final int? previousClippingCount = _clipCount[viewId]; + final int previousClippingCount = clipChain.clipCount; if (currentClippingCount != previousClippingCount) { - _clipCount[viewId] = currentClippingCount; - html.Element oldPlatformViewRoot = _rootViews[viewId]!; - html.Element? newPlatformViewRoot = _reconstructClipViewsChain( + html.Element oldPlatformViewRoot = clipChain.root; + html.Element newPlatformViewRoot = _reconstructClipViewsChain( currentClippingCount, - platformView, + slot, oldPlatformViewRoot, ); - _rootViews[viewId] = newPlatformViewRoot; + // Store the updated root element, and clip count + clipChain.updateClipChain( + root: newPlatformViewRoot, + clipCount: currentClippingCount, + ); } - _applyMutators(params.mutators, platformView, viewId); + + // Apply mutators to the slot + _applyMutators(params.mutators, slot, viewId); } int _countClips(MutatorsStack mutators) { @@ -213,7 +148,7 @@ class HtmlViewEmbedder { return clipCount; } - html.Element? _reconstructClipViewsChain( + html.Element _reconstructClipViewsChain( int numClips, html.Element platformView, html.Element headClipView, @@ -386,8 +321,6 @@ class HtmlViewEmbedder { } void submitFrame() { - disposeViews(); - for (int i = 0; i < _compositionOrder.length; i++) { int viewId = _compositionOrder[i]; _ensureOverlayInitialized(viewId); @@ -412,7 +345,7 @@ class HtmlViewEmbedder { int viewId = _compositionOrder[i]; if (assertionsEnabled) { - if (!_views.containsKey(viewId)) { + if (!platformViewManager.knowsViewId(viewId)) { debugInvalidViewIds ??= []; debugInvalidViewIds.add(viewId); continue; @@ -420,7 +353,7 @@ class HtmlViewEmbedder { } unusedViews.remove(viewId); - html.Element platformViewRoot = _rootViews[viewId]!; + html.Element platformViewRoot = _viewClipChains[viewId]!.root; html.Element overlay = _overlays[viewId]!.htmlElement; platformViewRoot.remove(); skiaSceneHost!.append(platformViewRoot); @@ -428,12 +361,10 @@ class HtmlViewEmbedder { skiaSceneHost!.append(overlay); _activeCompositionOrder.add(viewId); } + _compositionOrder.clear(); - for (final int unusedViewId in unusedViews) { - _releaseOverlay(unusedViewId); - _rootViews[unusedViewId]?.remove(); - } + disposeViews(unusedViews); if (assertionsEnabled) { if (debugInvalidViewIds != null && debugInvalidViewIds.isNotEmpty) { @@ -445,24 +376,18 @@ class HtmlViewEmbedder { } } - void disposeViews() { - if (_viewsToDispose.isEmpty) { - return; - } - - for (final int viewId in _viewsToDispose) { - final html.Element rootView = _rootViews[viewId]!; - rootView.remove(); - _views.remove(viewId); - _rootViews.remove(viewId); + void disposeViews(Set viewsToDispose) { + for (final int viewId in viewsToDispose) { + // Remove viewId from the _viewClipChains Map, and then from the DOM. + ViewClipChain clipChain = _viewClipChains.remove(viewId)!; + clipChain.root.remove(); + // More cleanup _releaseOverlay(viewId); _currentCompositionParams.remove(viewId); - _clipCount.remove(viewId); _viewsToRecomposite.remove(viewId); _cleanUpClipDefs(viewId); _svgClipDefs.remove(viewId); } - _viewsToDispose.clear(); } void _releaseOverlay(int viewId) { @@ -499,6 +424,29 @@ class HtmlViewEmbedder { } } +/// Represents a Clip Chain (for a view). +/// +/// Objects of this class contain: +/// * The root view in the stack of mutator elements for the view id. +/// * The slot view in the stack (the actual contents of the platform view). +/// * The number of clipping elements used last time the view was composited. +class ViewClipChain { + html.Element _root; + html.Element _slot; + int _clipCount = -1; + + ViewClipChain({required html.Element view}) : this._root = view, this._slot = view; + + html.Element get root => _root; + html.Element get slot => _slot; + int get clipCount => _clipCount; + + void updateClipChain({required html.Element root, required int clipCount}) { + _root = root; + _clipCount = clipCount; + } +} + /// Caches surfaces used to overlay platform views. class OverlayCache { static const int kDefaultCacheSize = 5; diff --git a/lib/web_ui/lib/src/engine/dom_renderer.dart b/lib/web_ui/lib/src/engine/dom_renderer.dart index cb7825938d282..683da328ed53d 100644 --- a/lib/web_ui/lib/src/engine/dom_renderer.dart +++ b/lib/web_ui/lib/src/engine/dom_renderer.dart @@ -12,7 +12,10 @@ class DomRenderer { reset(); - TextMeasurementService.initialize(rulerCacheCapacity: 10); + TextMeasurementService.initialize( + rulerCacheCapacity: 10, + root: _glassPaneShadow!, + ); assert(() { _setupHotRestart(); @@ -26,6 +29,9 @@ class DomRenderer { static const int vibrateHeavyImpact = 30; static const int vibrateSelectionClick = 10; + // The tag name for the root view of the flutter app (glass-pane) + static const String _glassPaneTagName = 'flt-glass-pane'; + /// Fires when browser language preferences change. static const html.EventStreamProvider languageChangeEvent = const html.EventStreamProvider('languagechange'); @@ -154,6 +160,10 @@ class DomRenderer { html.Element? get glassPaneElement => _glassPaneElement; html.Element? _glassPaneElement; + /// The ShadowRoot of the [glassPaneElement]. + html.ShadowRoot? get glassPaneShadow => _glassPaneShadow; + html.ShadowRoot? _glassPaneShadow; + final html.Element rootElement = html.document.body!; void addElementClass(html.Element element, String className) { @@ -252,11 +262,8 @@ class DomRenderer { static const String defaultCssFont = '$defaultFontStyle $defaultFontWeight ${defaultFontSize}px $defaultFontFamily'; - void reset() { - _styleElement?.remove(); - _styleElement = html.StyleElement(); - html.document.head!.append(_styleElement!); - final html.CssStyleSheet sheet = _styleElement!.sheet as html.CssStyleSheet; + // Applies the required global CSS to an incoming [html.CssStyleSheet] `sheet`. + void _applyCssRulesToSheet(html.CssStyleSheet sheet) { final bool isWebKit = browserEngine == BrowserEngine.webkit; final bool isFirefox = browserEngine == BrowserEngine.firefox; // TODO(butterfly): use more efficient CSS selectors; descendant selectors @@ -341,7 +348,7 @@ flt-semantics [contentEditable="true"] { // on using gray background. This CSS rule disables that. if (isWebKit) { sheet.insertRule(''' -flt-glass-pane * { +$_glassPaneTagName * { -webkit-tap-highlight-color: transparent; } ''', sheet.cssRules.length); @@ -360,6 +367,16 @@ flt-glass-pane * { } ''', sheet.cssRules.length); } + } + + void reset() { + final bool isWebKit = browserEngine == BrowserEngine.webkit; + + _styleElement?.remove(); + _styleElement = html.StyleElement(); + html.document.head!.append(_styleElement!); + final html.CssStyleSheet sheet = _styleElement!.sheet as html.CssStyleSheet; + _applyCssRulesToSheet(sheet); final html.BodyElement bodyElement = html.document.body!; @@ -432,7 +449,7 @@ flt-glass-pane * { // IMPORTANT: the glass pane element must come after the scene element in the DOM node list so // it can intercept input events. _glassPaneElement?.remove(); - final html.Element glassPaneElement = createElement('flt-glass-pane'); + final html.Element glassPaneElement = createElement(_glassPaneTagName); _glassPaneElement = glassPaneElement; glassPaneElement.style ..position = 'absolute' @@ -440,9 +457,28 @@ flt-glass-pane * { ..right = '0' ..bottom = '0' ..left = '0'; + + // Create a Shadow Root under the glass panel, and attach everything there, + // instead of directly underneath the glass panel. + final html.ShadowRoot glassPaneElementShadowRoot = glassPaneElement.attachShadow({ + 'mode': 'open', + 'delegatesFocus': 'true', + }); + _glassPaneShadow = glassPaneElementShadowRoot; + bodyElement.append(glassPaneElement); - _sceneHostElement = createElement('flt-scene-host'); + final html.StyleElement shadowRootStyleElement = html.StyleElement(); + // The shadowRootStyleElement must be appended to the DOM, or its `sheet` will be null later... + glassPaneElementShadowRoot.append(shadowRootStyleElement); + + final html.CssStyleSheet shadowRootStyleSheet = shadowRootStyleElement.sheet as html.CssStyleSheet; + _applyCssRulesToSheet(shadowRootStyleSheet); // TODO: Apply only rules for the shadow root + + // Don't allow the scene to receive pointer events. + _sceneHostElement = createElement('flt-scene-host') + ..style + .pointerEvents = 'none'; final html.Element semanticsHostElement = createElement('flt-semantics-host'); @@ -451,23 +487,16 @@ flt-glass-pane * { ..transformOrigin = '0 0 0'; _semanticsHostElement = semanticsHostElement; updateSemanticsScreenProperties(); - glassPaneElement.append(semanticsHostElement); - - // Don't allow the scene to receive pointer events. - _sceneHostElement!.style.pointerEvents = 'none'; - - glassPaneElement.append(_sceneHostElement!); - final html.Element _accesibilityPlaceholder = EngineSemanticsOwner + final html.Element _accessibilityPlaceholder = EngineSemanticsOwner .instance.semanticsHelper .prepareAccessibilityPlaceholder(); - // Insert the semantics placeholder after the scene host. For all widgets - // in the scene, except for platform widgets, the scene host will pass the - // pointer events through to the semantics tree. However, for platform - // views, the pointer events will not pass through, and will be handled - // by the platform view. - glassPaneElement.insertBefore(_accesibilityPlaceholder, _sceneHostElement); + glassPaneElementShadowRoot.nodes.addAll([ + semanticsHostElement, + _accessibilityPlaceholder, + _sceneHostElement!, + ]); // When debugging semantics, make the scene semi-transparent so that the // semantics tree is visible. diff --git a/lib/web_ui/lib/src/engine/html/platform_view.dart b/lib/web_ui/lib/src/engine/html/platform_view.dart index c06b7e69c80a5..ad1e1da413d63 100644 --- a/lib/web_ui/lib/src/engine/html/platform_view.dart +++ b/lib/web_ui/lib/src/engine/html/platform_view.dart @@ -12,48 +12,11 @@ class PersistedPlatformView extends PersistedLeafSurface { final double width; final double height; - late html.ShadowRoot _shadowRoot; - PersistedPlatformView(this.viewId, this.dx, this.dy, this.width, this.height); @override html.Element createElement() { - html.Element element = defaultCreateElement('flt-platform-view'); - - // Allow the platform view host element to receive pointer events. - // - // This is to allow platform view HTML elements to be interactive. - // - // ACCESSIBILITY NOTE: The way we enable accessibility on Flutter for web - // is to have a full-page button which waits for a double tap. Placing this - // full-page button in front of the scene would cause platform views not - // to receive pointer events. The tradeoff is that by placing the scene in - // front of the semantics placeholder will cause platform views to block - // pointer events from reaching the placeholder. This means that in order - // to enable accessibility, you must double tap the app *outside of a - // platform view*. As a consequence, a full-screen platform view will make - // it impossible to enable accessibility. - element.style.pointerEvents = 'auto'; - - // Enforce the effective size of the PlatformView. - element.style.overflow = 'hidden'; - - _shadowRoot = element.attachShadow({'mode': 'open'}); - final html.StyleElement _styleReset = html.StyleElement(); - _styleReset.innerHtml = ''' - :host { - all: initial; - cursor: inherit; - }'''; - _shadowRoot.append(_styleReset); - final html.Element? platformView = - ui.platformViewRegistry.getCreatedView(viewId); - if (platformView != null) { - _shadowRoot.append(platformView); - } else { - printWarning('No platform view created for id $viewId'); - } - return element; + return createPlatformViewSlot(viewId); } @override @@ -61,18 +24,12 @@ class PersistedPlatformView extends PersistedLeafSurface { @override void apply() { + // See `_compositeWithParams` in the HtmlViewEmbedder for the canvaskit equivalent. rootElement!.style ..transform = 'translate(${dx}px, ${dy}px)' ..width = '${width}px' - ..height = '${height}px'; - // Set size of the root element created by the PlatformView. - final html.Element? platformView = - ui.platformViewRegistry.getCreatedView(viewId); - if (platformView != null) { - platformView.style - ..width = '${width}px' - ..height = '${height}px'; - } + ..height = '${height}px' + ..position = 'absolute'; } // Platform Views can only be updated if their viewId matches. diff --git a/lib/web_ui/lib/src/engine/platform_dispatcher.dart b/lib/web_ui/lib/src/engine/platform_dispatcher.dart index 7adfbfbb578db..303ec4016db1b 100644 --- a/lib/web_ui/lib/src/engine/platform_dispatcher.dart +++ b/lib/web_ui/lib/src/engine/platform_dispatcher.dart @@ -314,6 +314,8 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { }; } + PlatformViewMessageHandler? _platformViewMessageHandler; + void _sendPlatformMessage( String name, ByteData? data, @@ -449,12 +451,13 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { return; case 'flutter/platform_views': - if (useCanvasKit) { - rasterizer!.surface.viewEmbedder - .handlePlatformViewCall(data, callback); - } else { - ui.handlePlatformViewCall(data!, callback!); - } + _platformViewMessageHandler ??= PlatformViewMessageHandler( + contentManager: platformViewManager, + contentHandler: (html.Element content) { + domRenderer.glassPaneElement!.append(content); + } + ); + _platformViewMessageHandler!.handlePlatformViewCall(data, callback!); return; case 'flutter/accessibility': diff --git a/lib/web_ui/lib/src/engine/platform_views/content_manager.dart b/lib/web_ui/lib/src/engine/platform_views/content_manager.dart new file mode 100644 index 0000000000000..971addc562dcd --- /dev/null +++ b/lib/web_ui/lib/src/engine/platform_views/content_manager.dart @@ -0,0 +1,155 @@ +// 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. + +part of engine; + +/// A function which takes a unique `id` and some `params` and creates an HTML element. +/// +/// This is made available to end-users through dart:ui in web. +typedef ParameterizedPlatformViewFactory = html.Element Function( + int viewId, { + Object? params, +}); +/// A function which takes a unique `id` and creates an HTML element. +/// +/// This is made available to end-users through dart:ui in web. +typedef PlatformViewFactory = html.Element Function(int viewId); + +/// This class handles the lifecycle of Platform Views in the DOM of a Flutter Web App. +/// +/// There are three important parts of Platform Views. This class manages two of +/// them: +/// +/// * `factories`: The functions used to render the contents of any given Platform +/// View by its `viewType`. +/// * `contents`: The result [html.Element] of calling a `factory` function. +/// +/// The third part is `slots`, which are created on demand by the +/// [createPlatformViewSlot] function. +/// +/// This class keeps a registry of `factories`, `contents` so the framework can +/// CRUD Platform Views as needed, regardless of the rendering backend. +class PlatformViewManager { + // The factory functions, indexed by the viewType + final Map _factories = {}; + + // The references to content tags, indexed by their framework-given ID. + final Map _contents = {}; + + /// Returns `true` if the passed in `viewType` has been registered before. + /// + /// See [registerViewFactory] to understand how factories are registered. + bool knowsViewType(String viewType) { + return _factories.containsKey(viewType); + } + + /// Returns `true` if the passed in `viewId` has been rendered (and not disposed) before. + /// + /// See [renderContent] and [createPlatformViewSlot] to understand how platform views are + /// rendered. + bool knowsViewId(int viewId) { + return _contents.containsKey(viewId); + } + + /// Registers a `factoryFunction` that knows how to render a Platform View of `viewType`. + /// + /// `viewType` is selected by the programmer, but it can't be overridden once + /// it's been set. + /// + /// `factoryFunction` needs to be a [PlatformViewFactory]. + bool registerFactory(String viewType, Function factoryFunction) { + assert(factoryFunction is PlatformViewFactory || + factoryFunction is ParameterizedPlatformViewFactory); + + if (_factories.containsKey(viewType)) { + return false; + } + _factories[viewType] = factoryFunction; + return true; + } + + /// Creates the HTML markup for the `contents` of a Platform View. + /// + /// The result of this call is cached in the `_contents` Map. This is only + /// cached so it can be disposed of later by [clearPlatformView]. _Note that + /// there's no `getContents` function in this class._ + /// + /// The resulting DOM for the `contents` of a Platform View looks like this: + /// + /// ```html + /// + /// + /// + /// ``` + /// + /// The `arbitrary-html-elements` are the result of the call to the user-supplied + /// `factory` function for this Platform View (see [registerFactory]). + /// + /// The outer `flt-platform-view` tag is a simple wrapper that we add to have + /// a place where to attach the `slot` property, that will tell the browser + /// what `slot` tag will reveal this `contents`, **without modifying the returned + /// html from the `factory` function**. + html.Element renderContent( + String viewType, + int viewId, + Object? params, + ) { + assert(knowsViewType(viewType), + 'Attempted to render contents of unregistered viewType: $viewType'); + + final String slotName = getPlatformViewSlotName(viewId); + + return _contents.putIfAbsent(viewId, () { + final html.Element wrapper = html.document + .createElement('flt-platform-view') + ..setAttribute('slot', slotName); + + final Function factoryFunction = _factories[viewType]!; + late html.Element content; + + if (factoryFunction is ParameterizedPlatformViewFactory) { + content = factoryFunction(viewId, params: params); + } else { + content = factoryFunction(viewId); + } + + _ensureContentCorrectlySized(content, viewType); + + return wrapper..append(content); + }); + } + + /// Removes a PlatformView by its `viewId` from the manager, and from the DOM. + /// + /// Once a view has been cleared, calls [knowsViewId] will fail, as if it had + /// never been rendered before. + void clearPlatformView(int viewId) { + // Remove from our cache, and then from the DOM... + _contents.remove(viewId)?.remove(); + } + + /// Attempt to ensure that the contents of the user-supplied DOM element will + /// fill the space allocated for this platform view by the framework. + void _ensureContentCorrectlySized(html.Element content, String viewType) { + // Scrutinize closely any other modifications to `content`. + // We shouldn't modify users' returned `content` if at all possible. + // Note there's also no getContent(viewId) function anymore, to prevent + // from later modifications too. + if (content.style.height.isEmpty) { + printWarning('Height of Platform View type: [$viewType] may not be set.' + ' Defaulting to `height: 100%`.\n' + 'Set `style.height` to any appropriate value to stop this message.'); + + content.style.height = '100%'; + } + + if (content.style.width.isEmpty) { + printWarning('Width of Platform View type: [$viewType] may not be set.' + ' Defaulting to `width: 100%`.\n' + 'Set `style.width` to any appropriate value to stop this message.'); + + content.style.width = '100%'; + } + } +} diff --git a/lib/web_ui/lib/src/engine/platform_views/message_handler.dart b/lib/web_ui/lib/src/engine/platform_views/message_handler.dart new file mode 100644 index 0000000000000..9d97ff8c2a19e --- /dev/null +++ b/lib/web_ui/lib/src/engine/platform_views/message_handler.dart @@ -0,0 +1,144 @@ +// 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. + +part of engine; + +/// The signature for a callback for a Platform Message. From the `ui` package. +/// Copied here so there's no circular dependencies. +typedef _PlatformMessageResponseCallback = void Function(ByteData? data); + +/// A function that handle a newly created [html.Element] with the contents of a +/// platform view with a unique [int] id. +typedef PlatformViewContentHandler = void Function(html.Element); + +/// This class handles incoming framework messages to create/dispose Platform Views. +/// +/// (An instance of this class is connected to the `flutter/platform_views` +/// Platform Channel in the [EnginePlatformDispatcher] class.) +/// +/// It uses a [PlatformViewManager] to handle the CRUD of the DOM of Platform Views. +/// This `contentManager` is shared across the engine, to perform +/// all operations related to platform views (registration, rendering, etc...), +/// regardless of the rendering backend. +/// +/// When the `contents` of a Platform View are created, a [PlatformViewContentHandler] +/// function (passed from the outside) will decide where in the DOM to inject +/// said content. +/// +/// The rendering/compositing of Platform Views can create the other "half" of a +/// Platform View: the `slot`, through the [createPlatformViewSlot] method. +/// +/// When a Platform View is disposed of, it is removed from the cache (and DOM) +/// directly by the `contentManager`. The canvaskit rendering backend needs to do +/// some extra cleanup of its internal state, but it can do it automatically. See +/// [HtmlViewEmbedder.disposeViews] +class PlatformViewMessageHandler { + final MethodCodec _codec = StandardMethodCodec(); + + final PlatformViewManager _contentManager; + final PlatformViewContentHandler? _contentHandler; + + PlatformViewMessageHandler({ + required PlatformViewManager contentManager, + PlatformViewContentHandler? contentHandler, + }) : this._contentManager = contentManager, + this._contentHandler = contentHandler; + + /// Handle a `create` Platform View message. + /// + /// This will attempt to render the `contents` and of a Platform View, if its + /// `viewType` has been registered previously. + /// + /// (See [PlatformViewContentManager.registerFactory] for more details.) + /// + /// The `contents` are delegated to a [_contentHandler] function, so the + /// active rendering backend can inject them in the right place of the DOM. + /// + /// If all goes well, this function will `callback` with an empty success envelope. + /// In case of error, this will `callback` with an error envelope describing the error. + void _createPlatformView( + MethodCall methodCall, + _PlatformMessageResponseCallback callback, + ) { + final Map args = methodCall.arguments; + final int viewId = args['id']; + final String viewType = args['viewType']; + + if (!_contentManager.knowsViewType(viewType)) { + callback(_codec.encodeErrorEnvelope( + code: 'unregistered_view_type', + message: 'trying to create a view with an unregistered type', + details: 'unregistered view type: $viewType', + )); + return; + } + + if (_contentManager.knowsViewId(viewId)) { + callback(_codec.encodeErrorEnvelope( + code: 'recreating_view', + message: 'trying to create an already created view', + details: 'view id: $viewId', + )); + return; + } + + // TODO: How can users add extra `args` from the HtmlElementView widget? + final html.Element content = _contentManager.renderContent( + viewType, + viewId, + args, + ); + + // For now, we don't need anything fancier. If needed, this can be converted + // to a PlatformViewStrategy class for each web-renderer backend? + if (_contentHandler != null) { + _contentHandler!(content); + } + callback(_codec.encodeSuccessEnvelope(null)); + } + + /// Handle a `dispose` Platform View message. + /// + /// This will clear the cached information that the framework has about a given + /// `viewId`, through the [_contentManager]. + /// + /// Once that's done, the dispose call is delegated to the [_disposeHandler] + /// function, so the active rendering backend can dispose of whatever resources + /// it needed to get ahold of. + /// + /// This function should always `callback` with an empty success envelope. + void _disposePlatformView( + MethodCall methodCall, + _PlatformMessageResponseCallback callback, + ) { + final int viewId = methodCall.arguments; + + // The contentManager removes the slot and the contents from its internal + // cache, and the DOM. + _contentManager.clearPlatformView(viewId); + + callback(_codec.encodeSuccessEnvelope(null)); + } + + /// Handles a PlatformViewCall to the `flutter/platform_views` channel. + /// + /// This method handles two possible messages: + /// * `create`: See [_createPlatformView] + /// * `dispose`: See [_disposePlatformView] + void handlePlatformViewCall( + ByteData? data, + _PlatformMessageResponseCallback callback, + ) { + final MethodCall decoded = _codec.decodeMethodCall(data); + switch (decoded.method) { + case 'create': + _createPlatformView(decoded, callback); + return; + case 'dispose': + _disposePlatformView(decoded, callback); + return; + } + callback(null); + } +} diff --git a/lib/web_ui/lib/src/engine/platform_views/slots.dart b/lib/web_ui/lib/src/engine/platform_views/slots.dart new file mode 100644 index 0000000000000..b57f8b5386b5c --- /dev/null +++ b/lib/web_ui/lib/src/engine/platform_views/slots.dart @@ -0,0 +1,46 @@ +// 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. + +part of engine; + +/// Returns the name of a slot from its `viewId`. +/// +/// This is used by the [renderContent] function of the [PlatformViewManager] +/// class, and the [createPlatformViewSlot] method below, to keep the slot name +/// attribute consistent across the framework. +String getPlatformViewSlotName(int viewId) { + return 'flt-pv-slot-$viewId'; +} + +/// Creates the HTML markup for the `slot` of a Platform View. +/// +/// The resulting DOM for a `slot` looks like this: +/// +/// ```html +/// +/// +/// +/// ``` +/// +/// The inner `SLOT` tag is standard HTML to reveal an element that is rendered +/// elsewhere in the DOM. Its `name` attribute must match the value of the `slot` +/// attribute of the contents being revealed (see [getPlatformViewSlotName].) +/// +/// The outer `flt-platform-view-slot` tag is a simple wrapper that the framework +/// can position/style as needed. +/// +/// (When the framework accesses a `slot`, it's really accessing its wrapper +/// `flt-platform-view-slot` tag) +html.Element createPlatformViewSlot(int viewId) { + final String slotName = getPlatformViewSlotName(viewId); + + final html.Element wrapper = html.document + .createElement('flt-platform-view-slot') + ..style.pointerEvents = 'auto'; + + final html.Element slot = html.document.createElement('slot') + ..setAttribute('name', slotName); + + return wrapper..append(slot); +} diff --git a/lib/web_ui/lib/src/engine/text/measurement.dart b/lib/web_ui/lib/src/engine/text/measurement.dart index 6352b91db95ad..eaca5a1cbcfee 100644 --- a/lib/web_ui/lib/src/engine/text/measurement.dart +++ b/lib/web_ui/lib/src/engine/text/measurement.dart @@ -22,9 +22,11 @@ bool _newlinePredicate(int char) { prop == LineCharProperty.CR; } -/// Hosts ruler DOM elements in a hidden container. +/// Hosts ruler DOM elements in a hidden container under a `root` [html.Node]. +/// +/// The `root` [html.Node] is optional. Defaults to [domRenderer.glassPaneShadow]. class RulerHost { - RulerHost() { + RulerHost({html.Node? root}) { _rulerHost.style ..position = 'fixed' ..visibility = 'hidden' @@ -33,7 +35,8 @@ class RulerHost { ..left = '0' ..width = '0' ..height = '0'; - html.document.body!.append(_rulerHost); + + (root ?? domRenderer.glassPaneShadow!).append(_rulerHost); registerHotRestartListener(dispose); } @@ -62,8 +65,14 @@ class RulerHost { /// [ParagraphGeometricStyle]. /// /// All instances of [ParagraphRuler] should be created through this class. +/// +/// An optional `root` [html.Node] can be passed, under which the DOM required +/// to perform measurements will be hosted. class RulerManager extends RulerHost { - RulerManager({required this.rulerCacheCapacity}): super(); + RulerManager({ + required this.rulerCacheCapacity, + html.Node? root, + }) : super(root: root); final int rulerCacheCapacity; @@ -174,10 +183,16 @@ abstract class TextMeasurementService { /// Initializes the text measurement service with a specific /// [rulerCacheCapacity] that gets passed to the [RulerManager]. - static void initialize({required int rulerCacheCapacity}) { + /// + /// An optional `root` [html.Node] can be passed, under which the DOM required + /// to perform measurements will be hosted. Defaults to [domRenderer.glassPaneShadow]. + static void initialize({required int rulerCacheCapacity, html.Node? root}) { rulerManager?.dispose(); rulerManager = null; - rulerManager = RulerManager(rulerCacheCapacity: rulerCacheCapacity); + rulerManager = RulerManager( + rulerCacheCapacity: rulerCacheCapacity, + root: root, + ); } @visibleForTesting 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 c07cc09bbd0c1..33ccdde7e8547 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 @@ -26,6 +26,12 @@ const String transparentTextEditingClass = 'transparentTextEditing'; void _emptyCallback(dynamic _) {} +/// The default root that hosts all DOM required for text editing when a11y is not enabled. +/// +/// This is something similar to [html.Document]. Currently, it's a [html.ShadowRoot]. +@visibleForTesting +html.ShadowRoot get defaultTextEditingRoot => domRenderer.glassPaneShadow!; + /// These style attributes are constant throughout the life time of an input /// element. /// @@ -232,7 +238,7 @@ class EngineAutofillForm { void placeForm(html.HtmlElement mainTextEditingElement) { formElement.append(mainTextEditingElement); - domRenderer.glassPaneElement!.append(formElement); + defaultTextEditingRoot.append(formElement); } void storeForm() { @@ -832,7 +838,7 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy { // DOM later, when the first location information arrived. // Otherwise, on Blink based Desktop browsers, the autofill menu appears // on top left of the screen. - domRenderer.glassPaneElement!.append(activeDomElement); + defaultTextEditingRoot.append(activeDomElement); _appendedToForm = false; } @@ -1207,7 +1213,7 @@ class AndroidTextEditingStrategy extends GloballyPositionedTextEditingStrategy { if (hasAutofillGroup) { placeForm(); } else { - domRenderer.glassPaneElement!.append(activeDomElement); + defaultTextEditingRoot.append(activeDomElement); } inputConfig.textCapitalization.setAutocapitalizeAttribute(activeDomElement); } diff --git a/lib/web_ui/lib/ui.dart b/lib/web_ui/lib/ui.dart index 5390bda376b8c..7b59a3d2d68dc 100644 --- a/lib/web_ui/lib/ui.dart +++ b/lib/web_ui/lib/ui.dart @@ -58,93 +58,20 @@ void webOnlySetPluginHandler(Future Function(String, ByteData?, PlatformMe // does not allow exported non-migrated libraries from migrated libraries. When `dart:_engine` // is migrated, we can move it back. +/// A function which takes a unique `id` and creates an HTML element. +typedef PlatformViewFactory = html.Element Function(int viewId); + /// A registry for factories that create platform views. class PlatformViewRegistry { - final Map registeredFactories = - {}; - - final Map _createdViews = {}; - - /// Private constructor so this class can be a singleton. - PlatformViewRegistry._(); - /// Register [viewTypeId] as being creating by the given [factory]. - bool registerViewFactory(String viewTypeId, PlatformViewFactory factory) { - if (registeredFactories.containsKey(viewTypeId)) { - return false; - } - registeredFactories[viewTypeId] = factory; - return true; - } - - /// Returns the view that has been created with the given [id], or `null` if - /// no such view exists. - html.Element? getCreatedView(int id) { - return _createdViews[id]; + bool registerViewFactory(String viewTypeId, PlatformViewFactory viewFactory) { + // TODO(web): Deprecate this once there's another way of calling `registerFactory` (js interop?) + return engine.platformViewManager.registerFactory(viewTypeId, viewFactory); } } -/// A function which takes a unique [id] and creates an HTML element. -typedef PlatformViewFactory = html.Element Function(int viewId); - /// The platform view registry for this app. -final PlatformViewRegistry platformViewRegistry = PlatformViewRegistry._(); - -/// Handles a platform call to `flutter/platform_views`. -/// -/// Used to create platform views. -void handlePlatformViewCall( - ByteData data, - PlatformMessageResponseCallback callback, -) { - const engine.MethodCodec codec = engine.StandardMethodCodec(); - final engine.MethodCall decoded = codec.decodeMethodCall(data); - - switch (decoded.method) { - case 'create': - _createPlatformView(decoded, callback); - return; - case 'dispose': - _disposePlatformView(decoded, callback); - return; - } - callback(null); -} - -void _createPlatformView( - engine.MethodCall methodCall, PlatformMessageResponseCallback callback) { - final Map args = methodCall.arguments; - final int id = args['id']; - final String viewType = args['viewType']; - const engine.MethodCodec codec = engine.StandardMethodCodec(); - - // TODO(het): Use 'direction', 'width', and 'height'. - final PlatformViewFactory? platformViewFactory = platformViewRegistry.registeredFactories[viewType]; - if (platformViewFactory == null) { - callback(codec.encodeErrorEnvelope( - code: 'Unregistered factory', - message: "No factory registered for viewtype '$viewType'", - )); - return; - } - // TODO(het): Use creation parameters. - final html.Element element = platformViewFactory(id); - - platformViewRegistry._createdViews[id] = element; - callback(codec.encodeSuccessEnvelope(null)); -} - -void _disposePlatformView( - engine.MethodCall methodCall, PlatformMessageResponseCallback callback) { - final int id = methodCall.arguments; - const engine.MethodCodec codec = engine.StandardMethodCodec(); - - // Remove the root element of the view from the DOM. - platformViewRegistry._createdViews[id]?.remove(); - platformViewRegistry._createdViews.remove(id); - - callback(codec.encodeSuccessEnvelope(null)); -} +final PlatformViewRegistry platformViewRegistry = PlatformViewRegistry(); // TODO(yjbanov): remove _Callback, _Callbacker, and _futurize. They are here only // because the analyzer wasn't able to infer the correct types during diff --git a/lib/web_ui/test/canvaskit/embedded_views_test.dart b/lib/web_ui/test/canvaskit/embedded_views_test.dart index 03d4439941ce1..47a20a5b41f70 100644 --- a/lib/web_ui/test/canvaskit/embedded_views_test.dart +++ b/lib/web_ui/test/canvaskit/embedded_views_test.dart @@ -45,14 +45,25 @@ void testMain() { sb.pushOffset(0, 0); sb.addPlatformView(0, width: 10, height: 10); dispatcher.rasterizer!.draw(sb.build().layerTree); - expect( - domRenderer.sceneElement! - .querySelectorAll('#view-0') - .single - .style - .pointerEvents, - 'auto', - ); + + // The platform view is now split in two parts. The contents live + // as a child of the glassPane, and the slot lives in the glassPane + // shadow root. The slot is the one that has pointer events auto. + final contents = domRenderer.glassPaneElement!.querySelector('#view-0')!; + final slot = domRenderer.sceneElement!.querySelector('slot')!; + final contentsHost = contents.parent!; + final slotHost = slot.parent!; + + expect(contents, isNotNull, + reason: 'The view from the factory is injected in the DOM.'); + + expect(contentsHost.tagName, equalsIgnoringCase('flt-platform-view')); + expect(slotHost.tagName, equalsIgnoringCase('flt-platform-view-slot')); + + expect(slotHost.style.pointerEvents, 'auto', + reason: 'The slot reenables pointer events.'); + expect(contentsHost.getAttribute('slot'), slot.getAttribute('name'), + reason: 'The contents and slot are correctly related.'); }); test('clips platform views with RRects', () async { @@ -69,6 +80,7 @@ void testMain() { sb.pushClipRRect(ui.RRect.fromLTRBR(0, 0, 10, 10, ui.Radius.circular(3))); sb.addPlatformView(0, width: 10, height: 10); dispatcher.rasterizer!.draw(sb.build().layerTree); + expect( domRenderer.sceneElement!.querySelectorAll('#sk_path_defs').single, isNotNull, @@ -109,12 +121,12 @@ void testMain() { sb.pushOffset(3, 3); sb.addPlatformView(0, width: 10, height: 10); dispatcher.rasterizer!.draw(sb.build().layerTree); + + // Transformations happen on the slot element. + final html.Element slotHost = domRenderer.sceneElement!.querySelector('flt-platform-view-slot')!; + expect( - domRenderer.sceneElement! - .querySelectorAll('#view-0') - .single - .style - .transform, + slotHost.style.transform, // We should apply the scale matrix first, then the offset matrix. // So the translate should be 515 (5 * 100 + 5 * 3), and not // 503 (5 * 100 + 3). @@ -150,11 +162,12 @@ void testMain() { sb.pushOffset(3, 3); sb.addPlatformView(0, width: 10, height: 10); dispatcher.rasterizer!.draw(sb.build().layerTree); - final html.Element viewHost = - domRenderer.sceneElement!.querySelectorAll('#view-0').single; + + // Transformations happen on the slot element. + final html.Element slotHost = domRenderer.sceneElement!.querySelector('flt-platform-view-slot')!; expect( - getTransformChain(viewHost), + getTransformChain(slotHost), ['matrix(0.25, 0, 0, 0.25, 1.5, 1.5)'], ); }); @@ -177,11 +190,12 @@ void testMain() { sb.pushOffset(9, 9); sb.addPlatformView(0, width: 10, height: 10); dispatcher.rasterizer!.draw(sb.build().layerTree); - final html.Element viewHost = - domRenderer.sceneElement!.querySelectorAll('#view-0').single; + + // Transformations happen on the slot element. + final html.Element slotHost = domRenderer.sceneElement!.querySelector('flt-platform-view-slot')!; expect( - getTransformChain(viewHost), + getTransformChain(slotHost), [ 'matrix(1, 0, 0, 1, 9, 9)', 'matrix(1, 0, 0, 1, 6, 6)', @@ -314,8 +328,12 @@ void testMain() { dispatcher.rasterizer!.draw(sb.build().layerTree); expect( - domRenderer.sceneElement!.querySelectorAll('#view-0'), - hasLength(1), + domRenderer.sceneElement!.querySelector('flt-platform-view-slot'), + isNotNull, + ); + expect( + domRenderer.glassPaneElement!.querySelector('flt-platform-view'), + isNotNull, ); await _disposePlatformView(0); @@ -325,8 +343,12 @@ void testMain() { dispatcher.rasterizer!.draw(sb.build().layerTree); expect( - domRenderer.sceneElement!.querySelectorAll('#view-0'), - hasLength(0), + domRenderer.sceneElement!.querySelector('flt-platform-view-slot'), + isNull, + ); + expect( + domRenderer.glassPaneElement!.querySelector('flt-platform-view'), + isNull, ); }); @@ -346,8 +368,12 @@ void testMain() { dispatcher.rasterizer!.draw(sb.build().layerTree); expect( - domRenderer.sceneElement!.querySelectorAll('#view-0'), - hasLength(1), + domRenderer.sceneElement!.querySelector('flt-platform-view-slot'), + isNotNull, + ); + expect( + domRenderer.glassPaneElement!.querySelector('flt-platform-view'), + isNotNull, ); // Render a frame without a platform view, but also without disposing of @@ -357,8 +383,14 @@ void testMain() { dispatcher.rasterizer!.draw(sb.build().layerTree); expect( - domRenderer.sceneElement!.querySelectorAll('#view-0'), - hasLength(0), + domRenderer.sceneElement!.querySelector('flt-platform-view-slot'), + isNull, + ); + // The actual contents of the platform view are kept in the dom, until + // it's actually disposed of! + expect( + domRenderer.glassPaneElement!.querySelector('flt-platform-view'), + isNotNull, ); }); diff --git a/lib/web_ui/test/dom_renderer_test.dart b/lib/web_ui/test/dom_renderer_test.dart index 387661c53cf55..92357af1cdc13 100644 --- a/lib/web_ui/test/dom_renderer_test.dart +++ b/lib/web_ui/test/dom_renderer_test.dart @@ -111,9 +111,11 @@ void testMain() { browserEngine == BrowserEngine.edge)); test('accesibility placeholder is attached after creation', () { - DomRenderer(); + final DomRenderer renderer = DomRenderer(); - expect(html.document.getElementsByTagName('flt-semantics-placeholder'), - isNotEmpty); + expect( + renderer.glassPaneShadow?.querySelectorAll('flt-semantics-placeholder'), + isNotEmpty, + ); }); } diff --git a/lib/web_ui/test/engine/platform_views/content_manager_test.dart b/lib/web_ui/test/engine/platform_views/content_manager_test.dart new file mode 100644 index 0000000000000..5a7ac5938e42b --- /dev/null +++ b/lib/web_ui/test/engine/platform_views/content_manager_test.dart @@ -0,0 +1,148 @@ +// 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. + +import 'dart:html' as html; + +import 'package:ui/src/engine.dart'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; + +import '../../matchers.dart'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +void testMain() { + group('PlatformViewManager', () { + final String viewType = 'forTest'; + final int viewId = 6; + + late PlatformViewManager contentManager; + + setUp(() { + contentManager = PlatformViewManager(); + }); + + group('knowsViewType', () { + test('recognizes viewTypes after registering them', () async { + expect(contentManager.knowsViewType(viewType), isFalse); + + contentManager.registerFactory(viewType, (int id) => html.DivElement()); + + expect(contentManager.knowsViewType(viewType), isTrue); + }); + }); + + group('knowsViewId', () { + test('recognizes viewIds after *rendering* them', () async { + expect(contentManager.knowsViewId(viewId), isFalse); + + contentManager.registerFactory(viewType, (int id) => html.DivElement()); + + expect(contentManager.knowsViewId(viewId), isFalse); + + contentManager.renderContent(viewType, viewId, null); + + expect(contentManager.knowsViewId(viewId), isTrue); + }); + + test('forgets viewIds after clearing them', () { + contentManager.registerFactory(viewType, (int id) => html.DivElement()); + contentManager.renderContent(viewType, viewId, null); + + expect(contentManager.knowsViewId(viewId), isTrue); + + contentManager.clearPlatformView(viewId); + + expect(contentManager.knowsViewId(viewId), isFalse); + }); + }); + + group('registerFactory', () { + test('does NOT re-register factories', () async { + contentManager.registerFactory( + viewType, (int id) => html.DivElement()..id = 'pass'); + // this should be rejected + contentManager.registerFactory( + viewType, (int id) => html.SpanElement()..id = 'fail'); + + final html.Element contents = + contentManager.renderContent(viewType, viewId, null); + + expect(contents.querySelector('#pass'), isNotNull); + expect(contents.querySelector('#fail'), isNull, + reason: 'Factories cannot be overridden once registered'); + }); + }); + + group('renderContent', () { + final String unregisteredViewType = 'unregisteredForTest'; + final String anotherViewType = 'anotherViewType'; + + setUp(() { + contentManager.registerFactory(viewType, (int id) { + return html.DivElement()..setAttribute('data-viewId', '$id'); + }); + + contentManager.registerFactory(anotherViewType, (int id) { + return html.DivElement() + ..setAttribute('data-viewId', '$id') + ..style.height = 'auto' + ..style.width = '55%'; + }); + }); + + test('refuse to render views for unregistered factories', () async { + try { + contentManager.renderContent(unregisteredViewType, viewId, null); + fail('renderContent should have thrown an Assertion error!'); + } catch (e) { + expect(e, isAssertionError); + expect((e as AssertionError).message, contains(unregisteredViewType)); + } + }); + + test('rendered markup contains required attributes', () async { + final html.Element content = + contentManager.renderContent(viewType, viewId, null); + expect(content.getAttribute('slot'), contains('$viewId')); + + final html.Element userContent = content.querySelector('div')!; + expect(userContent.style.height, '100%'); + expect(userContent.style.width, '100%'); + }); + + test('slot property has the same value as createPlatformViewSlot', () async { + final html.Element content = + contentManager.renderContent(viewType, viewId, null); + final html.Element slot = createPlatformViewSlot(viewId); + final html.Element innerSlot = slot.querySelector('slot')!; + + expect(content.getAttribute('slot'), innerSlot.getAttribute('name'), + reason: + 'The slot attribute of the rendered content must match the name attribute of the SLOT of a given viewId'); + }); + + test('do not modify style.height / style.width if passed by the user (anotherViewType)', + () async { + final html.Element content = + contentManager.renderContent(anotherViewType, viewId, null); + final html.Element userContent = content.querySelector('div')!; + expect(userContent.style.height, 'auto'); + expect(userContent.style.width, '55%'); + }); + + test('returns cached instances of already-rendered content', () async { + final html.Element firstRender = + contentManager.renderContent(viewType, viewId, null); + final html.Element anotherRender = + contentManager.renderContent(viewType, viewId, null); + + expect(firstRender, same(anotherRender)); + }); + }); + }); +} diff --git a/lib/web_ui/test/engine/platform_views/message_handler_test.dart b/lib/web_ui/test/engine/platform_views/message_handler_test.dart new file mode 100644 index 0000000000000..90b714926c96d --- /dev/null +++ b/lib/web_ui/test/engine/platform_views/message_handler_test.dart @@ -0,0 +1,181 @@ +// 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. + +import 'dart:async'; +import 'dart:html' as html; +import 'dart:typed_data'; + +import 'package:ui/src/engine.dart'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +final MethodCodec codec = StandardMethodCodec(); + +void testMain() { + group('PlatformViewMessageHandler', () { + group('handlePlatformViewCall', () { + final String viewType = 'forTest'; + final int viewId = 6; + late PlatformViewManager contentManager; + late Completer completer; + late Completer contentCompleter; + + setUp(() { + contentManager = PlatformViewManager(); + completer = Completer(); + contentCompleter = Completer(); + }); + + group('"create" message', () { + test('unregistered viewType, fails with descriptive exception', + () async { + final messageHandler = PlatformViewMessageHandler( + contentManager: contentManager, + ); + final ByteData? message = _getCreateMessage(viewType, viewId); + + messageHandler.handlePlatformViewCall(message, completer.complete); + + final ByteData? response = await completer.future; + try { + codec.decodeEnvelope(response!); + } on PlatformException catch (e) { + expect(e.code, 'unregistered_view_type'); + expect(e.details, contains(viewType)); + } + }); + + test('duplicate viewId, fails with descriptive exception', () async { + contentManager.registerFactory( + viewType, (int id) => html.DivElement()); + contentManager.renderContent(viewType, viewId, null); + final messageHandler = PlatformViewMessageHandler( + contentManager: contentManager, + ); + final ByteData? message = _getCreateMessage(viewType, viewId); + + messageHandler.handlePlatformViewCall(message, completer.complete); + + final ByteData? response = await completer.future; + try { + codec.decodeEnvelope(response!); + } on PlatformException catch (e) { + expect(e.code, 'recreating_view'); + expect(e.details, contains('$viewId')); + } + }); + + test('returns a successEnvelope when the view is created normally', + () async { + contentManager.registerFactory( + viewType, (int id) => html.DivElement()..id = 'success'); + final messageHandler = PlatformViewMessageHandler( + contentManager: contentManager, + ); + final ByteData? message = _getCreateMessage(viewType, viewId); + + messageHandler.handlePlatformViewCall(message, completer.complete); + + final ByteData? response = await completer.future; + expect(codec.decodeEnvelope(response!), isNull, + reason: + 'The response should be a success envelope, with null in it.'); + }); + + test('calls a contentHandler with the result of creating a view', + () async { + contentManager.registerFactory( + viewType, (int id) => html.DivElement()..id = 'success'); + final messageHandler = PlatformViewMessageHandler( + contentManager: contentManager, + contentHandler: contentCompleter.complete, + ); + final ByteData? message = _getCreateMessage(viewType, viewId); + + messageHandler.handlePlatformViewCall(message, completer.complete); + + final html.Element contents = await contentCompleter.future; + final ByteData? response = await completer.future; + + expect(contents.querySelector('div#success'), isNotNull, + reason: + 'The element created by the factory should be present in the created view.'); + expect(codec.decodeEnvelope(response!), isNull, + reason: + 'The response should be a success envelope, with null in it.'); + }); + }); + + group('"dispose" message', () { + late Completer viewIdCompleter; + + setUp(() { + viewIdCompleter = Completer(); + }); + + test('never fails, even for unknown viewIds', () async { + final messageHandler = PlatformViewMessageHandler( + contentManager: contentManager, + ); + final ByteData? message = _getDisposeMessage(viewId); + + messageHandler.handlePlatformViewCall(message, completer.complete); + + final ByteData? response = await completer.future; + expect(codec.decodeEnvelope(response!), isNull, + reason: + 'The response should be a success envelope, with null in it.'); + }); + + test('never fails, even for unknown viewIds', () async { + final messageHandler = PlatformViewMessageHandler( + contentManager: _FakePlatformViewManager(viewIdCompleter.complete), + ); + final ByteData? message = _getDisposeMessage(viewId); + + messageHandler.handlePlatformViewCall(message, completer.complete); + + final int disposedViewId = await viewIdCompleter.future; + expect(disposedViewId, viewId, + reason: + 'The viewId to dispose should be passed to the contentManager'); + }); + }); + }); + }); +} + +class _FakePlatformViewManager extends PlatformViewManager { + _FakePlatformViewManager(void Function(int) clearFunction) + : this._clearPlatformView = clearFunction; + + void Function(int) _clearPlatformView; + + @override + void clearPlatformView(int viewId) { + return _clearPlatformView(viewId); + } +} + +ByteData? _getCreateMessage(String viewType, int viewId) { + return codec.encodeMethodCall(MethodCall( + 'create', + { + 'id': viewId, + 'viewType': viewType, + }, + )); +} + +ByteData? _getDisposeMessage(int viewId) { + return codec.encodeMethodCall(MethodCall( + 'dispose', + viewId, + )); +} diff --git a/lib/web_ui/test/engine/platform_views/slots_test.dart b/lib/web_ui/test/engine/platform_views/slots_test.dart new file mode 100644 index 0000000000000..400477c082c78 --- /dev/null +++ b/lib/web_ui/test/engine/platform_views/slots_test.dart @@ -0,0 +1,41 @@ +// 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. + +import 'dart:html' as html; + +import 'package:ui/src/engine.dart'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +void testMain() { + group('PlatformViewManager', () { + final int viewId = 6; + + group('createPlatformViewSlot', () { + test( + 'can render slot, even for views that might have never been rendered before', + () async { + final html.Element slot = createPlatformViewSlot(viewId); + expect(slot, isNotNull); + expect(slot.querySelector('slot'), isNotNull); + }); + + test('rendered markup contains required attributes', () async { + final html.Element slot = createPlatformViewSlot(viewId); + expect(slot.style.pointerEvents, 'auto', + reason: + 'Should re-enable pointer events for the contents of the view.'); + final html.Element innerSlot = slot.querySelector('slot')!; + expect(innerSlot.getAttribute('name'), contains('$viewId'), + reason: + 'The name attribute of the inner SLOT tag must refer to the viewId.'); + }); + }); + }); +} diff --git a/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart index 96c04c12984a6..582ddf84fece9 100644 --- a/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart = 2.6 +// @dart = 2.9 @TestOn('chrome || safari || firefox') import 'dart:async'; @@ -89,8 +89,10 @@ void _testEngineSemanticsOwner() { // Synthesize a click on the placeholder. final html.Element placeholder = - html.document.querySelectorAll('flt-semantics-placeholder').single; + appShadowRoot.querySelector('flt-semantics-placeholder'); + expect(placeholder.isConnected, true); + final html.Rectangle rect = placeholder.getBoundingClientRect(); placeholder.dispatchEvent(html.MouseEvent( 'click', @@ -113,7 +115,8 @@ void _testEngineSemanticsOwner() { expect(semantics().semanticsEnabled, false); final html.Element placeholder = - html.document.querySelectorAll('flt-semantics-placeholder').single; + appShadowRoot.querySelector('flt-semantics-placeholder'); + expect(placeholder.isConnected, true); // Sending a semantics update should auto-enable engine semantics. @@ -354,9 +357,9 @@ void _testContainer() { '''); final html.Element parentElement = - html.document.querySelector('flt-semantics'); + appShadowRoot.querySelector('flt-semantics'); final html.Element container = - html.document.querySelector('flt-semantics-container'); + appShadowRoot.querySelector('flt-semantics-container'); if (isMacOrIOS) { expect(parentElement.style.top, '0px'); @@ -402,9 +405,9 @@ void _testContainer() { '''); final html.Element parentElement = - html.document.querySelector('flt-semantics'); + appShadowRoot.querySelector('flt-semantics'); final html.Element container = - html.document.querySelector('flt-semantics-container'); + appShadowRoot.querySelector('flt-semantics-container'); expect(parentElement.style.transform, 'matrix(1, 0, 0, 1, 10, 10)'); expect(parentElement.style.transformOrigin, '0px 0px 0px'); @@ -446,10 +449,12 @@ void _testContainer() { '''); } + final html.Element parentElement = - html.document.querySelector('flt-semantics'); + appShadowRoot.querySelector('flt-semantics'); final html.Element container = - html.document.querySelector('flt-semantics-container'); + appShadowRoot.querySelector('flt-semantics-container'); + if (isMacOrIOS) { expect(parentElement.style.top, '0px'); expect(parentElement.style.left, '0px'); @@ -804,8 +809,7 @@ void _testIncrementables() { '''); - final html.InputElement input = - html.document.querySelectorAll('input').single; + final html.InputElement input = appShadowRoot.querySelector('input'); input.value = '2'; input.dispatchEvent(html.Event('change')); @@ -839,8 +843,7 @@ void _testIncrementables() { '''); - final html.InputElement input = - html.document.querySelectorAll('input').single; + final html.InputElement input = appShadowRoot.querySelector('input'); input.value = '0'; input.dispatchEvent(html.Event('change')); @@ -933,20 +936,19 @@ void _testTextField() { semantics().updateSemantics(builder.build()); - final html.Element textField = html.document - .querySelectorAll('input[data-semantics-role="text-field"]') - .single; + final html.Element textField = + appShadowRoot.querySelector('input[data-semantics-role="text-field"]'); - expect(html.document.activeElement, isNot(textField)); + expect(appShadowRoot.activeElement, isNot(textField)); textField.focus(); - expect(html.document.activeElement, textField); + expect(appShadowRoot.activeElement, textField); expect(await logger.idLog.first, 0); expect(await logger.actionLog.first, ui.SemanticsAction.tap); semantics().semanticsEnabled = false; - }, // TODO(nurhan): https://github.com/flutter/flutter/issues/46638 + }, // TODO(nurhan): https://github.com/flutter/flutter/issues/46638 // TODO(nurhan): https://github.com/flutter/flutter/issues/50590 // TODO(nurhan): https://github.com/flutter/flutter/issues/50754 skip: (browserEngine != BrowserEngine.blink)); diff --git a/lib/web_ui/test/engine/semantics/semantics_tester.dart b/lib/web_ui/test/engine/semantics/semantics_tester.dart index 2b0b6fdb06d7d..8faf43a46f5dc 100644 --- a/lib/web_ui/test/engine/semantics/semantics_tester.dart +++ b/lib/web_ui/test/engine/semantics/semantics_tester.dart @@ -12,6 +12,21 @@ import 'package:ui/ui.dart' as ui; import '../../matchers.dart'; +/// Gets the DOM host where the Flutter app is being rendered. +/// +/// This function returns the correct host for the flutter app under testing, +/// so we don't have to hardcode html.document across the test. (The host of a +/// normal flutter app used to be html.document, but now that the app is wrapped +/// in a Shadow DOM, that's not the case anymore.) +/// +/// A [html.ShadowRoot] quacks very similarly to a [html.Document], but +/// unfortunately they don't share any class/implement any interface that let us +/// use them interchangeably. +/// +/// This flutterRoot can be changed to return ShadowRoot or Document without +/// the need to modify (most of) your code. +html.ShadowRoot get appShadowRoot => domRenderer.glassPaneShadow!; + /// CSS style applied to the root of the semantics tree. // TODO(yjbanov): this should be handled internally by [expectSemanticsTree]. // No need for every test to inject it. @@ -336,14 +351,14 @@ class SemanticsTester { /// Verifies the HTML structure of the current semantics tree. void expectSemanticsTree(String semanticsHtml) { expect( - canonicalizeHtml(html.document.querySelector('flt-semantics')!.outerHtml!), + canonicalizeHtml(appShadowRoot.querySelector('flt-semantics')!.outerHtml!), canonicalizeHtml(semanticsHtml), ); } /// Finds the first HTML element in the semantics tree used for scrolling. html.Element? findScrollable() { - return html.document.querySelectorAll('flt-semantics').cast().firstWhere( + return appShadowRoot.querySelectorAll('flt-semantics').cast().firstWhere( (html.Element? element) => element!.style.overflow == 'hidden' || element.style.overflowY == 'scroll' || 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 2d79d42d5ee94..5f9ef17a66099 100644 --- a/lib/web_ui/test/engine/semantics/text_field_test.dart +++ b/lib/web_ui/test/engine/semantics/text_field_test.dart @@ -75,15 +75,14 @@ void testMain() { createTextFieldSemantics(value: 'hello'); - final html.Element textField = html.document - .querySelectorAll('input[data-semantics-role="text-field"]') - .single; + final html.Element textField = appShadowRoot + .querySelector('input[data-semantics-role="text-field"]')!; - expect(html.document.activeElement, isNot(textField)); + expect(appShadowRoot.activeElement, isNot(textField)); textField.focus(); - expect(html.document.activeElement, textField); + expect(appShadowRoot.activeElement, textField); expect(await logger.idLog.first, 0); expect(await logger.actionLog.first, ui.SemanticsAction.tap); @@ -99,6 +98,7 @@ void testMain() { ..semanticsEnabled = true; expect(html.document.activeElement, html.document.body); + expect(appShadowRoot.activeElement, null); int changeCount = 0; int actionCount = 0; @@ -121,8 +121,9 @@ void testMain() { ); TextField textField = textFieldSemantics.debugRoleManagerFor(Role.textField) as TextField; + expect(html.document.activeElement, domRenderer.glassPaneElement); + expect(appShadowRoot.activeElement, strategy.domElement); expect(textField.editableElement, strategy.domElement); - expect(html.document.activeElement, strategy.domElement); expect((textField.editableElement as dynamic).value, 'hello'); expect(textField.editableElement.getAttribute('aria-label'), 'greeting'); expect(textField.editableElement.style.width, '10px'); @@ -137,6 +138,7 @@ void testMain() { ); expect(html.document.activeElement, html.document.body); + expect(appShadowRoot.activeElement, null); expect(strategy.domElement, null); expect((textField.editableElement as dynamic).value, 'bye'); expect(textField.editableElement.getAttribute('aria-label'), 'farewell'); @@ -158,6 +160,8 @@ void testMain() { ..semanticsEnabled = true; expect(html.document.activeElement, html.document.body); + expect(appShadowRoot.activeElement, null); + strategy.enable( singlelineConfig, onChange: (_) {}, @@ -170,11 +174,13 @@ void testMain() { final TextField textField = textFieldSemantics.debugRoleManagerFor(Role.textField) as TextField; expect(textField.editableElement, strategy.domElement); - expect(html.document.activeElement, strategy.domElement); + expect(html.document.activeElement, domRenderer.glassPaneElement); + expect(appShadowRoot.activeElement, strategy.domElement); // The input should not refocus after blur. textField.editableElement.blur(); expect(html.document.activeElement, html.document.body); + expect(appShadowRoot.activeElement, null); strategy.disable(); semantics().semanticsEnabled = false; }); @@ -199,17 +205,19 @@ void testMain() { isFocused: true, ); expect(strategy.domElement, isNotNull); - expect(html.document.activeElement, strategy.domElement); + expect(html.document.activeElement, domRenderer.glassPaneElement); + expect(appShadowRoot.activeElement, strategy.domElement); strategy.disable(); expect(strategy.domElement, isNull); // It doesn't remove the DOM element. final TextField textField = textFieldSemantics.debugRoleManagerFor(Role.textField) as TextField; - expect(html.document.body!.contains(textField.editableElement), isTrue); + expect(appShadowRoot.contains(textField.editableElement), isTrue); // Editing element is not enabled. expect(strategy.isEnabled, isFalse); expect(html.document.activeElement, html.document.body); + expect(appShadowRoot.activeElement, null); semantics().semanticsEnabled = false; }); @@ -229,11 +237,13 @@ void testMain() { isFocused: true, ); expect(strategy.domElement, isNotNull); - expect(html.document.activeElement, strategy.domElement); + expect(html.document.activeElement, domRenderer.glassPaneElement); + expect(appShadowRoot.activeElement, strategy.domElement); // Blur the element without telling the framework. strategy.activeDomElement.blur(); expect(html.document.activeElement, html.document.body); + expect(appShadowRoot.activeElement, null); // The input will have focus after editing state is set and semantics updated. strategy.setEditingState(EditingState(text: 'foo')); @@ -251,7 +261,8 @@ void testMain() { value: 'hello', isFocused: true, ); - expect(html.document.activeElement, strategy.domElement); + expect(html.document.activeElement, domRenderer.glassPaneElement); + expect(appShadowRoot.activeElement, strategy.domElement); strategy.disable(); semantics().semanticsEnabled = false; @@ -274,7 +285,10 @@ void testMain() { ); final html.TextAreaElement textArea = strategy.domElement as html.TextAreaElement; - expect(html.document.activeElement, textArea); + + expect(html.document.activeElement, domRenderer.glassPaneElement); + expect(appShadowRoot.activeElement, strategy.domElement); + strategy.enable( singlelineConfig, onChange: (_) {}, @@ -283,10 +297,11 @@ void testMain() { textArea.blur(); expect(html.document.activeElement, html.document.body); + expect(appShadowRoot.activeElement, null); strategy.disable(); // It doesn't remove the textarea from the DOM. - expect(html.document.body!.contains(textArea), isTrue); + expect(appShadowRoot.contains(textArea), isTrue); // Editing element is not enabled. expect(strategy.isEnabled, isFalse); semantics().semanticsEnabled = false; @@ -376,12 +391,14 @@ void testMain() { final SemanticsTester tester = SemanticsTester(semantics()); createTwoFieldSemantics(tester, focusFieldId: 1); expect(tester.apply().length, 3); - expect(html.document.activeElement, tester.getTextField(1).editableElement); + + expect(html.document.activeElement, domRenderer.glassPaneElement); + expect(appShadowRoot.activeElement, tester.getTextField(1).editableElement); expect(strategy.domElement, tester.getTextField(1).editableElement); createTwoFieldSemantics(tester, focusFieldId: 2); expect(tester.apply().length, 3); - expect(html.document.activeElement, tester.getTextField(2).editableElement); + expect(appShadowRoot.activeElement, tester.getTextField(2).editableElement); expect(strategy.domElement, tester.getTextField(2).editableElement); } diff --git a/lib/web_ui/test/engine/surface/platform_view_test.dart b/lib/web_ui/test/engine/surface/platform_view_test.dart index 9bb4bd56c8be3..3fd19063f4a56 100644 --- a/lib/web_ui/test/engine/surface/platform_view_test.dart +++ b/lib/web_ui/test/engine/surface/platform_view_test.dart @@ -69,14 +69,11 @@ void testMain() { }); group('createElement', () { - test('adds reset to stylesheet', () { + test('creates slot element that can receive pointer events', () { final element = view.createElement(); - _assertShadowRootStylesheetContains(element, 'all: initial;'); - }); - test('creates element transparent to "cursor" property', () { - final element = view.createElement(); - _assertShadowRootStylesheetContains(element, 'cursor: inherit;'); + expect(element.tagName, equalsIgnoringCase('flt-platform-view-slot')); + expect(element.style.pointerEvents, 'auto'); }); }); }); @@ -98,14 +95,3 @@ Future _createPlatformView(int id, String viewType) { ); return completer.future; } - -void _assertShadowRootStylesheetContains(html.Element element, String rule) { - final shadow = element.shadowRoot; - - expect(shadow, isNotNull); - - final html.StyleElement style = shadow.children.first; - - expect(style, isNotNull); - expect(style.innerHtml, contains(rule)); -} diff --git a/lib/web_ui/test/golden_tests/engine/canvas_golden_test.dart b/lib/web_ui/test/golden_tests/engine/canvas_golden_test.dart index 3bc80172226a5..3cba7c9ffbf8b 100644 --- a/lib/web_ui/test/golden_tests/engine/canvas_golden_test.dart +++ b/lib/web_ui/test/golden_tests/engine/canvas_golden_test.dart @@ -27,13 +27,13 @@ void testMain() async { // Create a element to make sure our CSS reset applies correctly. final html.Element testScene = html.Element.tag('flt-scene'); testScene.append(canvas.rootElement); - html.document.querySelector('flt-scene-host')!.append(testScene); + domRenderer.glassPaneShadow!.querySelector('flt-scene-host')!.append(testScene); } setUpStableTestFonts(); tearDown(() { - html.document.querySelector('flt-scene')!.remove(); + domRenderer.glassPaneShadow?.querySelector('flt-scene')?.remove(); }); /// Draws several lines, some aligned precisely with the pixel grid, and some @@ -249,7 +249,7 @@ void testMain() async { final html.Element sceneElement = scene.webOnlyRootElement!; sceneElement.querySelector('flt-clip')!.append(canvas.rootElement); - html.document.querySelector('flt-scene-host')!.append(sceneElement); + domRenderer.glassPaneShadow!.querySelector('flt-scene-host')!.append(sceneElement); await matchGoldenFile( 'bitmap_canvas_draws_text_on_top_of_canvas.png', diff --git a/lib/web_ui/test/text_editing_test.dart b/lib/web_ui/test/text_editing_test.dart index 394604ef1ffac..a8d345b172e44 100644 --- a/lib/web_ui/test/text_editing_test.dart +++ b/lib/web_ui/test/text_editing_test.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart = 2.6 +// @dart = 2.9 import 'dart:async'; import 'dart:html'; import 'dart:js_util' as js_util; @@ -69,6 +69,8 @@ void testMain() { editingStrategy = GloballyPositionedTextEditingStrategy(testTextEditing); testTextEditing.debugTextEditingStrategyOverride = editingStrategy; testTextEditing.configuration = singlelineConfig; + // Ensure the glass-pane and its shadow root exist. + domRenderer.reset(); }); test('Creates element when enabled and removes it when disabled', () { @@ -78,33 +80,38 @@ void testMain() { ); // The focus initially is on the body. expect(document.activeElement, document.body); + expect(defaultTextEditingRoot.activeElement, null); editingStrategy.enable( singlelineConfig, onChange: trackEditingState, onAction: trackInputAction, ); + expect( - document.getElementsByTagName('input'), + defaultTextEditingRoot.querySelectorAll('input'), hasLength(1), ); - final InputElement input = document.getElementsByTagName('input')[0]; + final InputElement input = defaultTextEditingRoot.querySelector('input'); // Now the editing element should have focus. - expect(document.activeElement, input); + + expect(document.activeElement, domRenderer.glassPaneElement); + expect(defaultTextEditingRoot.activeElement, input); + expect(editingStrategy.domElement, input); expect(input.getAttribute('type'), null); - // Input is appended to the glass pane. - expect(domRenderer.glassPaneElement.contains(editingStrategy.domElement), - isTrue); + // Input is appended to the right point of the DOM. + expect(defaultTextEditingRoot.contains(editingStrategy.domElement), isTrue); editingStrategy.disable(); expect( - document.getElementsByTagName('input'), + defaultTextEditingRoot.querySelectorAll('input'), hasLength(0), ); // The focus is back to the body. expect(document.activeElement, document.body); + expect(defaultTextEditingRoot.activeElement, null); }); test('Respects read-only config', () { @@ -116,8 +123,8 @@ void testMain() { onChange: trackEditingState, onAction: trackInputAction, ); - expect(document.getElementsByTagName('input'), hasLength(1)); - final InputElement input = document.getElementsByTagName('input')[0]; + expect(defaultTextEditingRoot.querySelectorAll('input'), hasLength(1)); + final InputElement input = defaultTextEditingRoot.querySelector('input'); expect(editingStrategy.domElement, input); expect(input.getAttribute('readonly'), 'readonly'); @@ -133,8 +140,8 @@ void testMain() { onChange: trackEditingState, onAction: trackInputAction, ); - expect(document.getElementsByTagName('input'), hasLength(1)); - final InputElement input = document.getElementsByTagName('input')[0]; + expect(defaultTextEditingRoot.querySelectorAll('input'), hasLength(1)); + final InputElement input = defaultTextEditingRoot.querySelector('input'); expect(editingStrategy.domElement, input); expect(input.getAttribute('type'), 'password'); @@ -150,8 +157,8 @@ void testMain() { onChange: trackEditingState, onAction: trackInputAction, ); - expect(document.getElementsByTagName('input'), hasLength(1)); - final InputElement input = document.getElementsByTagName('input')[0]; + expect(defaultTextEditingRoot.querySelectorAll('input'), hasLength(1)); + final InputElement input = defaultTextEditingRoot.querySelector('input'); expect(editingStrategy.domElement, input); expect(input.getAttribute('autocorrect'), 'off'); @@ -167,8 +174,8 @@ void testMain() { onChange: trackEditingState, onAction: trackInputAction, ); - expect(document.getElementsByTagName('input'), hasLength(1)); - final InputElement input = document.getElementsByTagName('input')[0]; + expect(defaultTextEditingRoot.querySelectorAll('input'), hasLength(1)); + final InputElement input = defaultTextEditingRoot.querySelector('input'); expect(editingStrategy.domElement, input); expect(input.getAttribute('autocorrect'), 'on'); @@ -224,18 +231,18 @@ void testMain() { onChange: trackEditingState, onAction: trackInputAction, ); - expect(document.getElementsByTagName('textarea'), hasLength(1)); + expect(defaultTextEditingRoot.querySelectorAll('textarea'), hasLength(1)); final TextAreaElement textarea = - document.getElementsByTagName('textarea')[0]; + defaultTextEditingRoot.querySelector('textarea'); // Now the textarea should have focus. - expect(document.activeElement, textarea); + expect(defaultTextEditingRoot.activeElement, textarea); expect(editingStrategy.domElement, textarea); textarea.value = 'foo\nbar'; textarea.dispatchEvent(Event.eventType('Event', 'input')); textarea.setSelectionRange(4, 6); - textarea.dispatchEvent(Event.eventType('Event', 'selectionchange')); + document.dispatchEvent(Event.eventType('Event', 'selectionchange')); // Can read textarea state correctly (and preserves new lines). expect( lastEditingState, @@ -249,9 +256,9 @@ void testMain() { editingStrategy.disable(); // The textarea should be cleaned up. - expect(document.getElementsByTagName('textarea'), hasLength(0)); + expect(defaultTextEditingRoot.querySelectorAll('textarea'), hasLength(0)); // The focus is back to the body. - expect(document.activeElement, document.body); + expect(defaultTextEditingRoot.activeElement, null); // There should be no input action. expect(lastInputAction, isNull); @@ -268,13 +275,13 @@ void testMain() { onChange: trackEditingState, onAction: trackInputAction, ); - expect(document.getElementsByTagName('input'), hasLength(1)); - expect(document.getElementsByTagName('textarea'), hasLength(0)); + expect(defaultTextEditingRoot.querySelectorAll('input'), hasLength(1)); + expect(defaultTextEditingRoot.querySelectorAll('textarea'), hasLength(0)); // Disable and check that all DOM elements were removed. editingStrategy.disable(); - expect(document.getElementsByTagName('input'), hasLength(0)); - expect(document.getElementsByTagName('textarea'), hasLength(0)); + expect(defaultTextEditingRoot.querySelectorAll('input'), hasLength(0)); + expect(defaultTextEditingRoot.querySelectorAll('textarea'), hasLength(0)); // Use multi-line config and expect an `