diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index eece0b9e72c77..c3da2a5a83479 100644 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -1962,6 +1962,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/platform_views/message_handle ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/platform_views/slots.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/plugins.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/pointer_binding.dart + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/pointer_binding/event_position_helper.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/pointer_converter.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/profiler.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/raw_keyboard.dart + ../../../flutter/LICENSE @@ -2017,6 +2018,13 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/ulps.dart + ../../../flutter/ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/util.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/validators.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/vector_math.dart + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/custom_element_dimensions_provider.dart + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/dimensions_provider.dart + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/full_page_dimensions_provider.dart + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy.dart + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/embedding_strategy.dart + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/hot_restart_cache_handler.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/window.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/text.dart + ../../../flutter/LICENSE ORIGIN: ../../../flutter/lib/web_ui/lib/tile_mode.dart + ../../../flutter/LICENSE @@ -4413,6 +4421,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/platform_views/message_handler. 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_binding/event_position_helper.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/pointer_converter.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/profiler.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/raw_keyboard.dart @@ -4468,6 +4477,13 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/ulps.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/util.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/validators.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/vector_math.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/custom_element_dimensions_provider.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/dimensions_provider.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/full_page_dimensions_provider.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/embedding_strategy.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/hot_restart_cache_handler.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/window.dart FILE: ../../../flutter/lib/web_ui/lib/text.dart FILE: ../../../flutter/lib/web_ui/lib/tile_mode.dart diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart index f83e43ce5245d..e86ffb97942ff 100644 --- a/lib/web_ui/lib/src/engine.dart +++ b/lib/web_ui/lib/src/engine.dart @@ -122,6 +122,7 @@ export 'engine/platform_views/message_handler.dart'; export 'engine/platform_views/slots.dart'; export 'engine/plugins.dart'; export 'engine/pointer_binding.dart'; +export 'engine/pointer_binding/event_position_helper.dart'; export 'engine/pointer_converter.dart'; export 'engine/profiler.dart'; export 'engine/raw_keyboard.dart'; @@ -170,4 +171,11 @@ export 'engine/text_editing/text_editing.dart'; export 'engine/util.dart'; export 'engine/validators.dart'; export 'engine/vector_math.dart'; +export 'engine/view_embedder/dimensions_provider/custom_element_dimensions_provider.dart'; +export 'engine/view_embedder/dimensions_provider/dimensions_provider.dart'; +export 'engine/view_embedder/dimensions_provider/full_page_dimensions_provider.dart'; +export 'engine/view_embedder/embedding_strategy/custom_element_embedding_strategy.dart'; +export 'engine/view_embedder/embedding_strategy/embedding_strategy.dart'; +export 'engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart'; +export 'engine/view_embedder/hot_restart_cache_handler.dart'; export 'engine/window.dart'; diff --git a/lib/web_ui/lib/src/engine/dom.dart b/lib/web_ui/lib/src/engine/dom.dart index 61ff5532e68bb..e982f04707538 100644 --- a/lib/web_ui/lib/src/engine/dom.dart +++ b/lib/web_ui/lib/src/engine/dom.dart @@ -174,6 +174,7 @@ class DomEvent {} extension DomEventExtension on DomEvent { external DomEventTarget? get target; + external DomEventTarget? get currentTarget; external double? get timeStamp; external String get type; external void preventDefault(); @@ -461,6 +462,9 @@ class DomHTMLElement extends DomElement {} extension DomHTMLElementExtension on DomHTMLElement { external double get offsetWidth; + external double get offsetLeft; + external double get offsetTop; + external DomHTMLElement? get offsetParent; } @JS() @@ -1089,6 +1093,8 @@ extension DomMouseEventExtension on DomMouseEvent { external double get clientY; external double get offsetX; external double get offsetY; + external double get pageX; + external double get pageY; DomPoint get client => DomPoint(clientX, clientY); DomPoint get offset => DomPoint(offsetX, offsetY); external double get button; @@ -1312,7 +1318,10 @@ class DomStyleSheet {} class DomCSSStyleSheet extends DomStyleSheet {} extension DomCSSStyleSheetExtension on DomCSSStyleSheet { - external DomCSSRuleList get cssRules; + Iterable get cssRules => + createDomListWrapper(js_util + .getProperty<_DomList>(this, 'cssRules')); + double insertRule(String rule, [int? index]) => js_util .callMethod( this, 'insertRule', @@ -1323,6 +1332,12 @@ extension DomCSSStyleSheetExtension on DomCSSStyleSheet { @staticInterop class DomCSSRule {} +@JS() +@staticInterop +extension DomCSSRuleExtension on DomCSSRule { + external String get cssText; +} + @JS() @staticInterop class DomScreen {} @@ -1420,12 +1435,75 @@ extension DomMessageChannelExtension on DomMessageChannel { external DomMessagePort get port2; } +/// ResizeObserver JS binding. +/// +/// See: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver @JS() @staticInterop -class DomCSSRuleList {} +abstract class DomResizeObserver {} -extension DomCSSRuleListExtension on DomCSSRuleList { - external double get length; +/// Creates a DomResizeObserver with a callback. +/// +/// Internally converts the `List` of entries into the expected +/// `List` +DomResizeObserver? createDomResizeObserver(DomResizeObserverCallbackFn fn) { + return domCallConstructorString('ResizeObserver', [ + allowInterop( + (List entries, DomResizeObserver observer) { + fn(entries.cast(), observer); + } + ), + ]) as DomResizeObserver?; +} + +/// ResizeObserver instance methods. +/// +/// See: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#instance_methods +extension DomResizeObserverExtension on DomResizeObserver { + external void disconnect(); + external void observe(DomElement target, [DomResizeObserverObserveOptions options]); + external void unobserve(DomElement target); +} + +/// Options object passed to the `observe` method of a [DomResizeObserver]. +/// +/// See: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/observe#parameters +@JS() +@staticInterop +@anonymous +abstract class DomResizeObserverObserveOptions { + external factory DomResizeObserverObserveOptions({ + String box, + }); +} + +/// Type of the function used to create a Resize Observer. +typedef DomResizeObserverCallbackFn = void Function(List entries, DomResizeObserver observer); + +/// The object passed to the [DomResizeObserverCallbackFn], which allows access to the new dimensions of the observed element. +/// +/// See: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry +@JS() +@staticInterop +abstract class DomResizeObserverEntry {} + +/// ResizeObserverEntry instance properties. +/// +/// See: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry#instance_properties +extension DomResizeObserverEntryExtension on DomResizeObserverEntry { + /// A DOMRectReadOnly object containing the new size of the observed element when the callback is run. + /// + /// Note that this is better supported than the above two properties, but it + /// is left over from an earlier implementation of the Resize Observer API, is + /// still included in the spec for web compat reasons, and may be deprecated + /// in future versions. + external DomRectReadOnly get contentRect; + external DomElement get target; + // Some more future getters: + // + // borderBoxSize + // contentBoxSize + // devicePixelContentBoxSize } /// A factory to create `TrustedTypePolicy` objects. diff --git a/lib/web_ui/lib/src/engine/embedder.dart b/lib/web_ui/lib/src/engine/embedder.dart index 0c98a3d259d86..db28eac7672c9 100644 --- a/lib/web_ui/lib/src/engine/embedder.dart +++ b/lib/web_ui/lib/src/engine/embedder.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'package:ui/ui.dart' as ui; -import '../engine.dart' show buildMode, registerHotRestartListener, renderer; +import '../engine.dart' show buildMode, renderer, window; import 'browser_detection.dart'; import 'configuration.dart'; import 'dom.dart'; @@ -14,11 +14,10 @@ import 'host_node.dart'; import 'keyboard_binding.dart'; import 'platform_dispatcher.dart'; import 'pointer_binding.dart'; -import 'safe_browser_api.dart'; import 'semantics.dart'; import 'text_editing/text_editing.dart'; -import 'util.dart'; -import 'window.dart'; +import 'view_embedder/dimensions_provider/dimensions_provider.dart'; +import 'view_embedder/embedding_strategy/embedding_strategy.dart'; /// Controls the placement and lifecycle of a Flutter view on the web page. /// @@ -34,34 +33,37 @@ import 'window.dart'; /// - [sceneHostElement], the anchor that provides a stable location in the DOM /// tree for the [sceneElement]. /// - [semanticsHostElement], hosts the ARIA-annotated semantics tree. +/// +/// This class is currently a singleton, but it'll possibly need to morph to have +/// multiple instances in a multi-view scenario. (One ViewEmbedder per FlutterView). class FlutterViewEmbedder { - FlutterViewEmbedder() { - assert(() { - _setupHotRestart(); - return true; - }()); + /// Creates a FlutterViewEmbedder. + /// + /// The incoming [hostElement] parameter specifies the root element in the DOM + /// into which Flutter will be rendered. + /// + /// The hostElement is abstracted by an [EmbeddingStrategy] instance, which has + /// different behavior depending on the `hostElement` value: + /// + /// - A `null` `hostElement` will cause Flutter to take over the whole page. + /// - A non-`null` `hostElement` will render flutter inside that element. + FlutterViewEmbedder({DomElement? hostElement}) + : _embeddingStrategy = + EmbeddingStrategy.create(hostElement: hostElement) { + // Configure the EngineWindow so it knows how to measure itself. + // TODO(dit): Refactor ownership according to new design, https://github.com/flutter/flutter/issues/117098 + window.configureDimensionsProvider(DimensionsProvider.create( + hostElement: hostElement, + )); + reset(); - assert(() { - _registerHotRestartCleanUp(); - return true; - }()); } - // The tag name for the root view of the flutter app (glass-pane) - static const String _glassPaneTagName = 'flt-glass-pane'; - - /// Listens to window resize events - DomSubscription? _resizeSubscription; - - /// Listens to window locale events. - DomSubscription? _localeSubscription; - - /// Contains Flutter-specific CSS rules, such as default margins and - /// paddings. - DomHTMLStyleElement? _styleElement; + /// Abstracts all the DOM manipulations required to embed a Flutter app in an user-supplied `hostElement`. + final EmbeddingStrategy _embeddingStrategy; - /// Configures the screen, such as scaling. - DomHTMLMetaElement? _viewportMeta; + // The tag name for the root view of the flutter app (glass-pane) + static const String glassPaneTagName = 'flt-glass-pane'; /// The element that contains the [sceneElement]. /// @@ -97,50 +99,6 @@ class FlutterViewEmbedder { DomElement? get sceneElement => _sceneElement; DomElement? _sceneElement; - /// This is state persistent across hot restarts that indicates what - /// to clear. Delay removal of old visible state to make the - /// transition appear smooth. - static const String _staleHotRestartStore = '__flutter_state'; - List? _staleHotRestartState; - - /// Creates a container for DOM elements that need to be cleaned up between - /// hot restarts. - /// - /// If a contains already exists, reuses the existing one. - void _setupHotRestart() { - // This persists across hot restarts to clear stale DOM. - _staleHotRestartState = getJsProperty?>(domWindow, _staleHotRestartStore); - if (_staleHotRestartState == null) { - _staleHotRestartState = []; - setJsProperty( - domWindow, _staleHotRestartStore, _staleHotRestartState); - } - } - - /// Registers DOM elements that need to be cleaned up before hot restarting. - /// - /// [_setupHotRestart] must have been called prior to calling this method. - void _registerHotRestartCleanUp() { - registerHotRestartListener(() { - _resizeSubscription?.cancel(); - _localeSubscription?.cancel(); - _staleHotRestartState!.addAll([ - _glassPaneElement, - _styleElement, - _viewportMeta, - ]); - }); - } - - void _clearOnHotRestart() { - if (_staleHotRestartState!.isNotEmpty) { - for (final DomElement? element in _staleHotRestartState!) { - element?.remove(); - } - _staleHotRestartState!.clear(); - } - } - /// Don't unnecessarily move DOM nodes around. If a DOM node is /// already in the right place, skip DOM mutation. This is both faster and /// more correct, because moving DOM nodes loses internal state, such as @@ -151,10 +109,6 @@ class FlutterViewEmbedder { _sceneElement = sceneElement; _sceneHostElement!.append(sceneElement!); } - assert(() { - _clearOnHotRestart(); - return true; - }()); } /// The element that captures input events, such as pointer events. @@ -170,8 +124,6 @@ class FlutterViewEmbedder { HostNode? get glassPaneShadow => _glassPaneShadow; HostNode? _glassPaneShadow; - final DomElement rootElement = domDocument.body!; - static const String defaultFontStyle = 'normal'; static const String defaultFontWeight = 'normal'; static const double defaultFontSize = 14; @@ -180,106 +132,42 @@ class FlutterViewEmbedder { '$defaultFontStyle $defaultFontWeight ${defaultFontSize}px $defaultFontFamily'; void reset() { - final bool isWebKit = browserEngine == BrowserEngine.webkit; - - _styleElement?.remove(); - _styleElement = createDomHTMLStyleElement(); - _resourcesHost?.remove(); - _resourcesHost = null; - domDocument.head!.append(_styleElement!); - final DomCSSStyleSheet sheet = _styleElement!.sheet! as DomCSSStyleSheet; - applyGlobalCssRulesToSheet( - sheet, - browserEngine: browserEngine, - hasAutofillOverlay: browserHasAutofillOverlay(), - ); - - final DomHTMLBodyElement bodyElement = domDocument.body!; - - bodyElement.setAttribute( - 'flt-renderer', - '${renderer.rendererTag} (${FlutterConfiguration.flutterWebAutoDetect ? 'auto-selected' : 'requested explicitly'})', + // How was the current renderer selected? + const String rendererSelection = FlutterConfiguration.flutterWebAutoDetect + ? 'auto-selected' + : 'requested explicitly'; + + // Initializes the embeddingStrategy so it can host a single-view Flutter app. + _embeddingStrategy.initialize( + hostElementAttributes: { + 'flt-renderer': '${renderer.rendererTag} ($rendererSelection)', + 'flt-build-mode': buildMode, + // TODO(mdebbar): Disable spellcheck until changes in the framework and + // engine are complete. + 'spellcheck': 'false', + }, ); - bodyElement.setAttribute('flt-build-mode', buildMode); - - setElementStyle(bodyElement, 'position', 'fixed'); - setElementStyle(bodyElement, 'top', '0'); - setElementStyle(bodyElement, 'right', '0'); - setElementStyle(bodyElement, 'bottom', '0'); - setElementStyle(bodyElement, 'left', '0'); - setElementStyle(bodyElement, 'overflow', 'hidden'); - setElementStyle(bodyElement, 'padding', '0'); - setElementStyle(bodyElement, 'margin', '0'); - - // TODO(yjbanov): fix this when KVM I/O support is added. Currently scroll - // using drag, and text selection interferes. - setElementStyle(bodyElement, 'user-select', 'none'); - setElementStyle(bodyElement, '-webkit-user-select', 'none'); - setElementStyle(bodyElement, '-ms-user-select', 'none'); - setElementStyle(bodyElement, '-moz-user-select', 'none'); - - // This is required to prevent the browser from doing any native touch - // handling. If this is not done, the browser doesn't report 'pointermove' - // events properly. - setElementStyle(bodyElement, 'touch-action', 'none'); - - // These are intentionally outrageous font parameters to make sure that the - // apps fully specify their text styles. - setElementStyle(bodyElement, 'font', defaultCssFont); - setElementStyle(bodyElement, 'color', 'red'); - - // TODO(mdebbar): Disable spellcheck until changes in the framework and - // engine are complete. - bodyElement.spellcheck = false; - - for (final DomElement viewportMeta - in domDocument.head!.querySelectorAll('meta[name="viewport"]')) { - if (assertionsEnabled) { - // Filter out the meta tag that the engine placed on the page. This is - // to avoid UI flicker during hot restart. Hot restart will clean up the - // old meta tag synchronously with the first post-restart frame. - if (!viewportMeta.hasAttribute('flt-viewport')) { - print( - 'WARNING: found an existing tag. Flutter ' - 'Web uses its own viewport configuration for better compatibility ' - 'with Flutter. This tag will be replaced.', - ); - } - } - viewportMeta.remove(); - } - // This removes a previously created meta tag. Note, however, that this does - // not remove the meta tag during hot restart. Hot restart resets all static - // variables, so this will be null upon hot restart. Instead, this tag is - // removed by _clearOnHotRestart. - _viewportMeta?.remove(); - _viewportMeta = createDomHTMLMetaElement() - ..setAttribute('flt-viewport', '') - ..name = 'viewport' - ..content = 'width=device-width, initial-scale=1.0, ' - 'maximum-scale=1.0, user-scalable=no'; - domDocument.head!.append(_viewportMeta!); - - // 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 DomElement glassPaneElement = domDocument.createElement(_glassPaneTagName); + // Create and inject the [_glassPaneElement]. + final DomElement glassPaneElement = + domDocument.createElement(glassPaneTagName); _glassPaneElement = glassPaneElement; - glassPaneElement.style - ..position = 'absolute' - ..top = '0' - ..right = '0' - ..bottom = '0' - ..left = '0'; - // This must be appended to the body, so the engine can create a host node - // properly. - bodyElement.append(glassPaneElement); + // This must be attached to the DOM now, so the engine can create a host + // node (ShadowDOM or a fallback) next. + // + // The embeddingStrategy will take care of cleaning up the glassPane on hot + // restart. + _embeddingStrategy.attachGlassPane(glassPaneElement); // Create a [HostNode] under the glass pane element, and attach everything // there, instead of directly underneath the glass panel. - final HostNode glassPaneElementHostNode = _createHostNode(glassPaneElement); + // + // TODO(dit): clean HostNode, https://github.com/flutter/flutter/issues/116204 + final HostNode glassPaneElementHostNode = HostNode.create( + glassPaneElement, + defaultCssFont, + ); _glassPaneShadow = glassPaneElementHostNode; // Don't allow the scene to receive pointer events. @@ -324,67 +212,20 @@ class FlutterViewEmbedder { } KeyboardBinding.initInstance(); - PointerBinding.initInstance(glassPaneElement, KeyboardBinding.instance!.converter); - - if (domWindow.visualViewport == null && isWebKit) { - // Older Safari versions sometimes give us bogus innerWidth/innerHeight - // values when the page loads. When it changes the values to correct ones - // it does not notify of the change via `onResize`. As a workaround, we - // set up a temporary periodic timer that polls innerWidth and triggers - // the resizeListener so that the framework can react to the change. - // - // Safari 13 has implemented visualViewport API so it doesn't need this - // timer. - // - // VisualViewport API is not enabled in Firefox as well. On the other hand - // Firefox returns correct values for innerHeight, innerWidth. - // Firefox also triggers domWindow.onResize therefore this timer does - // not need to be set up for Firefox. - final int initialInnerWidth = domWindow.innerWidth!.toInt(); - // Counts how many times screen size was checked. It is checked up to 5 - // times. - int checkCount = 0; - Timer.periodic(const Duration(milliseconds: 100), (Timer t) { - checkCount += 1; - if (initialInnerWidth != domWindow.innerWidth) { - // Window size changed. Notify. - t.cancel(); - _metricsDidChange(null); - } else if (checkCount > 5) { - // Checked enough times. Stop. - t.cancel(); - } - }); - } - - if (domWindow.visualViewport != null) { - _resizeSubscription = DomSubscription(domWindow.visualViewport!, 'resize', - allowInterop(_metricsDidChange)); - } else { - _resizeSubscription = DomSubscription(domWindow, 'resize', - allowInterop(_metricsDidChange)); - } - _localeSubscription = DomSubscription(domWindow, 'languagechange', - allowInterop(_languageDidChange)); - EnginePlatformDispatcher.instance.updateLocales(); - } + PointerBinding.initInstance( + glassPaneElement, + KeyboardBinding.instance!.converter, + ); - // Creates a [HostNode] into a `root` [DomElement]. - HostNode _createHostNode(DomElement root) { - if (getJsProperty(root, 'attachShadow') != null) { - return ShadowDomHostNode(root); - } else { - // attachShadow not available, fall back to ElementHostNode. - return ElementHostNode(root); - } + window.onResize.listen(_metricsDidChange); } /// The framework specifies semantics in physical pixels, but CSS uses /// logical pixels. To compensate, an inverse scale is injected at the root /// level. void updateSemanticsScreenProperties() { - _semanticsHostElement!.style.setProperty('transform', - 'scale(${1 / domWindow.devicePixelRatio})'); + _semanticsHostElement!.style + .setProperty('transform', 'scale(${1 / window.devicePixelRatio})'); } /// Called immediately after browser window metrics change. @@ -396,8 +237,9 @@ class FlutterViewEmbedder { /// /// Note: always check for rotations for a mobile device. Update the physical /// size if the change is caused by a rotation. - void _metricsDidChange(DomEvent? event) { + void _metricsDidChange(ui.Size? newSize) { updateSemanticsScreenProperties(); + // TODO(dit): Do not computePhysicalSize twice, https://github.com/flutter/flutter/issues/117036 if (isMobile && !window.isRotation() && textEditing.isEditing) { window.computeOnScreenKeyboardInsets(true); EnginePlatformDispatcher.instance.invokeOnMetricsChanged(); @@ -409,12 +251,6 @@ class FlutterViewEmbedder { } } - /// Called immediately after browser window language change. - void _languageDidChange(DomEvent event) { - EnginePlatformDispatcher.instance.updateLocales(); - ui.window.onLocaleChanged?.call(); - } - static const String orientationLockTypeAny = 'any'; static const String orientationLockTypeNatural = 'natural'; static const String orientationLockTypeLandscape = 'landscape'; @@ -487,17 +323,6 @@ class FlutterViewEmbedder { } } - /// The element corresponding to the only child of the root surface. - DomElement? get _rootApplicationElement { - final DomElement lastElement = rootElement.children.last; - for (final DomElement child in lastElement.children) { - if (child.tagName == 'FLT-SCENE') { - return child; - } - } - return null; - } - /// Add an element as a global resource to be referenced by CSS. /// /// This call create a global resource host element on demand and either @@ -507,15 +332,18 @@ class FlutterViewEmbedder { void addResource(DomElement element) { final bool isWebKit = browserEngine == BrowserEngine.webkit; if (_resourcesHost == null) { - _resourcesHost = createDomHTMLDivElement() + final DomElement resourcesHost = domDocument + .createElement('flt-svg-filters') ..style.visibility = 'hidden'; if (isWebKit) { - final DomNode bodyNode = domDocument.body!; - bodyNode.insertBefore(_resourcesHost!, bodyNode.firstChild); + // The resourcesHost *must* be a sibling of the glassPaneElement. + _embeddingStrategy.attachResourcesHost(resourcesHost, + nextTo: glassPaneElement); } else { - _glassPaneShadow!.node.insertBefore( - _resourcesHost!, _glassPaneShadow!.node.firstChild); + glassPaneShadow!.node + .insertBefore(resourcesHost, glassPaneShadow!.node.firstChild); } + _resourcesHost = resourcesHost; } _resourcesHost!.append(element); } @@ -528,127 +356,6 @@ class FlutterViewEmbedder { assert(element.parentNode == _resourcesHost); element.remove(); } - - String get currentHtml => _rootApplicationElement?.outerHTML ?? ''; -} - -// Applies the required global CSS to an incoming [DomCSSStyleSheet] `sheet`. -void applyGlobalCssRulesToSheet( - DomCSSStyleSheet sheet, { - required BrowserEngine browserEngine, - required bool hasAutofillOverlay, - String glassPaneTagName = FlutterViewEmbedder._glassPaneTagName, -}) { - final bool isWebKit = browserEngine == BrowserEngine.webkit; - final bool isFirefox = browserEngine == BrowserEngine.firefox; - // TODO(web): use more efficient CSS selectors; descendant selectors are slow. - // More info: https://csswizardry.com/2011/09/writing-efficient-css-selectors - - if (isFirefox) { - // For firefox set line-height, otherwise textx at same font-size will - // measure differently in ruler. - // - // - See: https://github.com/flutter/flutter/issues/44803 - sheet.insertRule( - 'flt-paragraph, flt-span {line-height: 100%;}', - sheet.cssRules.length.toInt(), - ); - } - - // This undoes browser's default painting and layout attributes of range - // input, which is used in semantics. - sheet.insertRule( - ''' - flt-semantics input[type=range] { - appearance: none; - -webkit-appearance: none; - width: 100%; - position: absolute; - border: none; - top: 0; - right: 0; - bottom: 0; - left: 0; - } - ''', - sheet.cssRules.length.toInt(), - ); - - if (isWebKit) { - sheet.insertRule( - 'flt-semantics input[type=range]::-webkit-slider-thumb {' - ' -webkit-appearance: none;' - '}', - sheet.cssRules.length.toInt()); - } - - if (isFirefox) { - sheet.insertRule( - 'input::-moz-selection {' - ' background-color: transparent;' - '}', - sheet.cssRules.length.toInt()); - sheet.insertRule( - 'textarea::-moz-selection {' - ' background-color: transparent;' - '}', - sheet.cssRules.length.toInt()); - } else { - // On iOS, the invisible semantic text field has a visible cursor and - // selection highlight. The following 2 CSS rules force everything to be - // transparent. - sheet.insertRule( - 'input::selection {' - ' background-color: transparent;' - '}', - sheet.cssRules.length.toInt()); - sheet.insertRule( - 'textarea::selection {' - ' background-color: transparent;' - '}', - sheet.cssRules.length.toInt()); - } - sheet.insertRule(''' - flt-semantics input, - flt-semantics textarea, - flt-semantics [contentEditable="true"] { - caret-color: transparent; - } - ''', sheet.cssRules.length.toInt()); - - // By default on iOS, Safari would highlight the element that's being tapped - // on using gray background. This CSS rule disables that. - if (isWebKit) { - sheet.insertRule(''' - $glassPaneTagName * { - -webkit-tap-highlight-color: transparent; - } - ''', sheet.cssRules.length.toInt()); - } - - // Hide placeholder text - sheet.insertRule( - ''' - .flt-text-editing::placeholder { - opacity: 0; - } - ''', - sheet.cssRules.length.toInt(), - ); - - // This css prevents an autofill overlay brought by the browser during - // text field autofill by delaying the transition effect. - // See: https://github.com/flutter/flutter/issues/61132. - if (browserHasAutofillOverlay()) { - sheet.insertRule(''' - .transparentTextEditing:-webkit-autofill, - .transparentTextEditing:-webkit-autofill:hover, - .transparentTextEditing:-webkit-autofill:focus, - .transparentTextEditing:-webkit-autofill:active { - -webkit-transition-delay: 99999s; - } - ''', sheet.cssRules.length.toInt()); - } } /// The embedder singleton. @@ -660,15 +367,17 @@ FlutterViewEmbedder get flutterViewEmbedder { assert(() { if (embedder == null) { throw StateError( - 'FlutterViewEmbedder not initialized. Call `ensureFlutterViewEmbedderInitialized()` ' - 'prior to calling the `flutterViewEmbedder` getter.' - ); + 'FlutterViewEmbedder not initialized. Call `ensureFlutterViewEmbedderInitialized()` ' + 'prior to calling the `flutterViewEmbedder` getter.'); } return true; }()); return embedder!; } + FlutterViewEmbedder? _flutterViewEmbedder; /// Initializes the [FlutterViewEmbedder], if it's not already initialized. -FlutterViewEmbedder ensureFlutterViewEmbedderInitialized() => _flutterViewEmbedder ??= FlutterViewEmbedder(); +FlutterViewEmbedder ensureFlutterViewEmbedderInitialized() => + _flutterViewEmbedder ??= + FlutterViewEmbedder(hostElement: configuration.hostElement); diff --git a/lib/web_ui/lib/src/engine/host_node.dart b/lib/web_ui/lib/src/engine/host_node.dart index 9421ec2e018e4..f2a6c74cb39f5 100644 --- a/lib/web_ui/lib/src/engine/host_node.dart +++ b/lib/web_ui/lib/src/engine/host_node.dart @@ -5,6 +5,7 @@ import 'browser_detection.dart'; import 'dom.dart'; import 'embedder.dart'; +import 'safe_browser_api.dart'; import 'text_editing/text_editing.dart'; /// The interface required to host a flutter app in the DOM, and its tests. @@ -13,7 +14,25 @@ import 'text_editing/text_editing.dart'; /// (preferred Flutter rendering method) and [DomDocument] (fallback). /// /// Not to be confused with [DomDocumentOrShadowRoot]. +/// +/// This also handles the stylesheet that is applied to the different types of +/// HostNodes; for ShadowDOM there's not much to do, but for ElementNodes, the +/// stylesheet is "namespaced" by the `flt-glass-pane` prefix, so it "only" +/// affects things that Flutter web owns. abstract class HostNode { + /// Returns an appropriate HostNode for the given [root]. + /// + /// If `attachShadow` is supported, this returns a [ShadowDomHostNode], else + /// this will fall-back to an [ElementHostNode]. + factory HostNode.create(DomElement root, String defaultFont) { + if (getJsProperty(root, 'attachShadow') != null) { + return ShadowDomHostNode(root, defaultFont); + } else { + // attachShadow not available, fall back to ElementHostNode. + return ElementHostNode(root, defaultFont); + } + } + /// Retrieves the [DomElement] that currently has focus. /// /// See: @@ -88,11 +107,12 @@ abstract class HostNode { class ShadowDomHostNode implements HostNode { /// Build a HostNode by attaching a [DomShadowRoot] to the `root` element. /// - /// This also calls [applyGlobalCssRulesToSheet], defined in dom_renderer. - ShadowDomHostNode(DomElement root) : - assert( + /// This also calls [applyGlobalCssRulesToSheet], with the [defaultFont] + /// to be used as the default font definition. + ShadowDomHostNode(DomElement root, String defaultFont) + : assert( root.isConnected ?? true, - 'The `root` of a ShadowDomHostNode must be connected to the Document object or a ShadowRoot.', + 'The `root` of a ShadowDomHostNode must be connected to the Document object or a ShadowRoot.' ) { _shadow = root.attachShadow({ 'mode': 'open', @@ -101,29 +121,16 @@ class ShadowDomHostNode implements HostNode { 'delegatesFocus': false, }); - final DomHTMLStyleElement shadowRootStyleElement = createDomHTMLStyleElement(); + final DomHTMLStyleElement shadowRootStyleElement = + createDomHTMLStyleElement(); + shadowRootStyleElement.id = 'flt-internals-stylesheet'; // The shadowRootStyleElement must be appended to the DOM, or its `sheet` will be null later. _shadow.appendChild(shadowRootStyleElement); - - // TODO(dit): Apply only rules for the shadow root applyGlobalCssRulesToSheet( shadowRootStyleElement.sheet! as DomCSSStyleSheet, - browserEngine: browserEngine, hasAutofillOverlay: browserHasAutofillOverlay(), + defaultCssFont: defaultFont, ); - - // Removes password reveal icon for text inputs in Edge browsers. - // Style tag needs to be injected into DOM because non-Edge - // browsers will crash trying to parse -ms-reveal CSS selectors if added via - // sheet.insertRule(). - // See: https://github.com/flutter/flutter/issues/83695 - if (isEdge) { - final DomHTMLStyleElement edgeStyleElement = createDomHTMLStyleElement(); - - edgeStyleElement.id = 'ms-reveal'; - edgeStyleElement.innerText = 'input::-ms-reveal {display: none;}'; - _shadow.appendChild(edgeStyleElement); - } } late DomShadowRoot _shadow; @@ -164,7 +171,20 @@ class ShadowDomHostNode implements HostNode { /// being constructed. class ElementHostNode implements HostNode { /// Build a HostNode by attaching a child [DomElement] to the `root` element. - ElementHostNode(DomElement root) { + ElementHostNode(DomElement root, String defaultFont) { + // Append the stylesheet here, so this class is completely symmetric to the + // ShadowDOM version. + final DomHTMLStyleElement styleElement = createDomHTMLStyleElement(); + styleElement.id = 'flt-internals-stylesheet'; + // The styleElement must be appended to the DOM, or its `sheet` will be null later. + root.appendChild(styleElement); + applyGlobalCssRulesToSheet( + styleElement.sheet! as DomCSSStyleSheet, + hasAutofillOverlay: browserHasAutofillOverlay(), + cssSelectorPrefix: FlutterViewEmbedder.glassPaneTagName, + defaultCssFont: defaultFont, + ); + _element = domDocument.createElement('flt-element-host-node'); root.appendChild(_element); } @@ -200,3 +220,144 @@ class ElementHostNode implements HostNode { @override void appendAll(Iterable nodes) => nodes.forEach(append); } + +// Applies the required global CSS to an incoming [DomCSSStyleSheet] `sheet`. +void applyGlobalCssRulesToSheet( + DomCSSStyleSheet sheet, { + required bool hasAutofillOverlay, + String cssSelectorPrefix = '', + required String defaultCssFont, +}) { + // TODO(web): use more efficient CSS selectors; descendant selectors are slow. + // More info: https://csswizardry.com/2011/09/writing-efficient-css-selectors + + // These are intentionally outrageous font parameters to make sure that the + // apps fully specify their text styles. + // + // Fixes #115216 by ensuring that our parameters only affect the flt-scene-host children. + sheet.insertRule(''' + $cssSelectorPrefix flt-scene-host { + color: red; + font: $defaultCssFont; + } + ''', sheet.cssRules.length); + + // By default on iOS, Safari would highlight the element that's being tapped + // on using gray background. This CSS rule disables that. + if (isSafari) { + sheet.insertRule(''' + $cssSelectorPrefix * { + -webkit-tap-highlight-color: transparent; + } + ''', sheet.cssRules.length); + } + + if (isFirefox) { + // For firefox set line-height, otherwise text at same font-size will + // measure differently in ruler. + // + // - See: https://github.com/flutter/flutter/issues/44803 + sheet.insertRule(''' + $cssSelectorPrefix flt-paragraph, + $cssSelectorPrefix flt-span { + line-height: 100%; + } + ''', sheet.cssRules.length); + } + + // This undoes browser's default painting and layout attributes of range + // input, which is used in semantics. + sheet.insertRule(''' + $cssSelectorPrefix flt-semantics input[type=range] { + appearance: none; + -webkit-appearance: none; + width: 100%; + position: absolute; + border: none; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + ''', sheet.cssRules.length); + + if (isSafari) { + sheet.insertRule(''' + $cssSelectorPrefix flt-semantics input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + } + ''', sheet.cssRules.length); + } + + // The invisible semantic text field may have a visible cursor and selection + // highlight. The following 2 CSS rules force everything to be transparent. + sheet.insertRule(''' + $cssSelectorPrefix input::selection { + background-color: transparent; + } + ''', sheet.cssRules.length); + sheet.insertRule(''' + $cssSelectorPrefix textarea::selection { + background-color: transparent; + } + ''', sheet.cssRules.length); + + sheet.insertRule(''' + $cssSelectorPrefix flt-semantics input, + $cssSelectorPrefix flt-semantics textarea, + $cssSelectorPrefix flt-semantics [contentEditable="true"] { + caret-color: transparent; + } + ''', sheet.cssRules.length); + + // Hide placeholder text + sheet.insertRule(''' + $cssSelectorPrefix .flt-text-editing::placeholder { + opacity: 0; + } + ''', sheet.cssRules.length); + + // This css prevents an autofill overlay brought by the browser during + // text field autofill by delaying the transition effect. + // See: https://github.com/flutter/flutter/issues/61132. + if (browserHasAutofillOverlay()) { + sheet.insertRule(''' + $cssSelectorPrefix .transparentTextEditing:-webkit-autofill, + $cssSelectorPrefix .transparentTextEditing:-webkit-autofill:hover, + $cssSelectorPrefix .transparentTextEditing:-webkit-autofill:focus, + $cssSelectorPrefix .transparentTextEditing:-webkit-autofill:active { + -webkit-transition-delay: 99999s; + } + ''', sheet.cssRules.length); + } + + // Removes password reveal icon for text inputs in Edge browsers. + // Non-Edge browsers will crash trying to parse -ms-reveal CSS selector, + // so we guard it behind an isEdge check. + // Fixes: https://github.com/flutter/flutter/issues/83695 + if (isEdge) { + // We try-catch this, because in testing, we fake Edge via the UserAgent, + // so the below will throw an exception (because only real Edge understands + // the ::-ms-reveal pseudo-selector). + try { + sheet.insertRule(''' + $cssSelectorPrefix input::-ms-reveal { + display: none; + } + ''', sheet.cssRules.length); + } on DomException catch (e) { + // Browsers that don't understand ::-ms-reveal throw a DOMException + // of type SyntaxError. + domWindow.console.warn(e); + // Add a fake rule if our code failed because we're under testing + assert(() { + sheet.insertRule(''' + $cssSelectorPrefix input.fallback-for-fakey-browser-in-ci { + display: none; + } + ''', sheet.cssRules.length); + return true; + }()); + } + } +} diff --git a/lib/web_ui/lib/src/engine/html/scene.dart b/lib/web_ui/lib/src/engine/html/scene.dart index 9beba6a4a6537..564ad97d00fce 100644 --- a/lib/web_ui/lib/src/engine/html/scene.dart +++ b/lib/web_ui/lib/src/engine/html/scene.dart @@ -6,6 +6,7 @@ import 'package:ui/ui.dart' as ui; import '../dom.dart'; import '../vector_math.dart'; +import '../window.dart'; import 'surface.dart'; class SurfaceScene implements ui.Scene { @@ -45,12 +46,10 @@ class PersistedScene extends PersistedContainerSurface { @override void recomputeTransformAndClip() { // The scene clip is the size of the entire window. - // TODO(yjbanov): in the add2app scenario where we might be hosted inside - // a custom element, this will be different. We will need to - // update this code when we add add2app support. - final double screenWidth = domWindow.innerWidth!; - final double screenHeight = domWindow.innerHeight!; - localClipBounds = ui.Rect.fromLTRB(0, 0, screenWidth, screenHeight); + final ui.Size screen = window.physicalSize / window.devicePixelRatio; + // Question: why is the above a logical size, rather than a physical size + // like everywhere else in the metrics? + localClipBounds = ui.Rect.fromLTRB(0, 0, screen.width, screen.height); projectedClip = null; } diff --git a/lib/web_ui/lib/src/engine/platform_dispatcher.dart b/lib/web_ui/lib/src/engine/platform_dispatcher.dart index e003513953583..480b367a2be3c 100644 --- a/lib/web_ui/lib/src/engine/platform_dispatcher.dart +++ b/lib/web_ui/lib/src/engine/platform_dispatcher.dart @@ -84,6 +84,7 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { _addBrightnessMediaQueryListener(); HighContrastSupport.instance.addListener(_updateHighContrast); _addFontSizeObserver(); + _addLocaleChangedListener(); registerHotRestartListener(dispose); } @@ -112,6 +113,7 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { void dispose() { _removeBrightnessMediaQueryListener(); _disconnectFontSizeObserver(); + _removeLocaleChangedListener(); HighContrastSupport.instance.removeListener(_updateHighContrast); } @@ -743,6 +745,29 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { @override List get locales => configuration.locales; + // A subscription to the 'languagechange' event of 'window'. + DomSubscription? _onLocaleChangedSubscription; + + /// Configures the [_onLocaleChangedSubscription]. + void _addLocaleChangedListener() { + if (_onLocaleChangedSubscription != null) { + return; + } + updateLocales(); // First time, for good measure. + _onLocaleChangedSubscription = + DomSubscription(domWindow, 'languagechange', allowInterop((DomEvent _) { + // Update internal config, then propagate the changes. + updateLocales(); + invokeOnLocaleChanged(); + })); + } + + /// Removes the [_onLocaleChangedSubscription]. + void _removeLocaleChangedListener() { + _onLocaleChangedSubscription?.cancel(); + _onLocaleChangedSubscription = null; + } + /// Performs the platform-native locale resolution. /// /// Each platform may return different results. diff --git a/lib/web_ui/lib/src/engine/pointer_binding.dart b/lib/web_ui/lib/src/engine/pointer_binding.dart index 1c3468597bc47..d53a5c8cefd75 100644 --- a/lib/web_ui/lib/src/engine/pointer_binding.dart +++ b/lib/web_ui/lib/src/engine/pointer_binding.dart @@ -12,13 +12,17 @@ import '../engine.dart' show registerHotRestartListener; import 'browser_detection.dart'; import 'dom.dart'; import 'platform_dispatcher.dart'; +import 'pointer_binding/event_position_helper.dart'; import 'pointer_converter.dart'; import 'safe_browser_api.dart'; import 'semantics.dart'; -/// Set this flag to true to see all the fired events in the console. +/// Set this flag to true to log all the browser events. const bool _debugLogPointerEvents = false; +/// Set this to true to log all the events sent to the Flutter framework. +const bool _debugLogFlutterEvents = false; + /// The signature of a callback that handles pointer events. typedef _PointerDataCallback = void Function(Iterable); @@ -147,13 +151,16 @@ class PointerBinding { _pointerDataConverter.clearPointerState(); } + // TODO(dit): remove old API fallbacks, https://github.com/flutter/flutter/issues/116141 _BaseAdapter _createAdapter() { if (_detector.hasPointerEvents) { return _PointerAdapter(_onPointerData, glassPaneElement, _pointerDataConverter, _keyboardConverter); } + // Fallback for Safari Mobile < 13. To be removed. if (_detector.hasTouchEvents) { return _TouchAdapter(_onPointerData, glassPaneElement, _pointerDataConverter, _keyboardConverter); } + // Fallback for Safari Desktop < 13. To be removed. if (_detector.hasMouseEvents) { return _MouseAdapter(_onPointerData, glassPaneElement, _pointerDataConverter, _keyboardConverter); } @@ -162,6 +169,11 @@ class PointerBinding { void _onPointerData(Iterable data) { final ui.PointerDataPacket packet = ui.PointerDataPacket(data: data.toList()); + if (_debugLogFlutterEvents) { + for(final ui.PointerData datum in data) { + print('fw:${datum.change} ${datum.physicalX},${datum.physicalY}'); + } + } EnginePlatformDispatcher.instance.invokeOnPointerDataPacket(packet); } } @@ -300,9 +312,10 @@ abstract class _BaseAdapter { if (_debugLogPointerEvents) { if (domInstanceOfString(event, 'PointerEvent')) { final DomPointerEvent pointerEvent = event as DomPointerEvent; + final ui.Offset offset = computeEventOffsetToTarget(event, glassPaneElement); print('${pointerEvent.type} ' - '${pointerEvent.clientX.toStringAsFixed(1)},' - '${pointerEvent.clientY.toStringAsFixed(1)}'); + '${offset.dx.toStringAsFixed(1)},' + '${offset.dy.toStringAsFixed(1)}'); } else { print(event.type); } @@ -439,6 +452,7 @@ mixin _WheelEventListenerMixin on _BaseAdapter { } final List data = []; + final ui.Offset offset = computeEventOffsetToTarget(event, glassPaneElement); _pointerDataConverter.convert( data, change: ui.PointerChange.hover, @@ -446,8 +460,8 @@ mixin _WheelEventListenerMixin on _BaseAdapter { kind: kind, signalKind: ui.PointerSignalKind.scroll, device: _mouseDeviceId, - physicalX: event.clientX * ui.window.devicePixelRatio, - physicalY: event.clientY * ui.window.devicePixelRatio, + physicalX: offset.dx * ui.window.devicePixelRatio, + physicalY: offset.dy * ui.window.devicePixelRatio, buttons: event.buttons!.toInt(), pressure: 1.0, pressureMax: 1.0, @@ -734,6 +748,7 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { _callback(pointerData); }); + // Why `domWindow` you ask? See this fiddle: https://jsfiddle.net/ditman/7towxaqp _addPointerEventListener(domWindow, 'pointermove', (DomPointerEvent event) { final int device = _getPointerId(event); final _ButtonSanitizer sanitizer = _ensureSanitizer(device); @@ -761,6 +776,7 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { } }, useCapture: false, checkModifiers: false); + // TODO(dit): This must happen in the glassPane, https://github.com/flutter/flutter/issues/116561 _addPointerEventListener(domWindow, 'pointerup', (DomPointerEvent event) { final int device = _getPointerId(event); if (_hasSanitizer(device)) { @@ -774,6 +790,8 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { } }); + // TODO(dit): Synthesize a "cancel" event when 'pointerup' happens outside of the glassPane, https://github.com/flutter/flutter/issues/116561 + // A browser fires cancel event if it concludes the pointer will no longer // be able to generate events (example: device is deactivated) _addPointerEventListener(glassPaneElement, 'pointercancel', (DomPointerEvent event) { @@ -806,6 +824,7 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { final double tilt = _computeHighestTilt(event); final Duration timeStamp = _BaseAdapter._eventTimeStampToDuration(event.timeStamp!); final num? pressure = event.pressure; + final ui.Offset offset = computeEventOffsetToTarget(event, glassPaneElement); _pointerDataConverter.convert( data, change: details.change, @@ -813,8 +832,8 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { kind: kind, signalKind: ui.PointerSignalKind.none, device: _getPointerId(event), - physicalX: event.clientX * ui.window.devicePixelRatio, - physicalY: event.clientY * ui.window.devicePixelRatio, + physicalX: offset.dx * ui.window.devicePixelRatio, + physicalY: offset.dy * ui.window.devicePixelRatio, buttons: details.buttons, pressure: pressure == null ? 0.0 : pressure.toDouble(), pressureMax: 1.0, @@ -834,6 +853,10 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { return coalescedEvents; } } + // Important: coalesced events lack the `eventTarget` property (because they're + // being handled in a deferred way). + // + // See the "Note" here: https://developer.mozilla.org/en-US/docs/Web/API/Event/currentTarget return [event]; } @@ -997,6 +1020,7 @@ class _TouchAdapter extends _BaseAdapter { timeStamp: timeStamp, signalKind: ui.PointerSignalKind.none, device: touch.identifier!.toInt(), + // Account for zoom/scroll in the TouchEvent physicalX: touch.clientX * ui.window.devicePixelRatio, physicalY: touch.clientY * ui.window.devicePixelRatio, buttons: pressed ? _kPrimaryMouseButton : 0, @@ -1080,6 +1104,7 @@ class _MouseAdapter extends _BaseAdapter with _WheelEventListenerMixin { _callback(pointerData); }); + // Why `domWindow` you ask? See this fiddle: https://jsfiddle.net/ditman/7towxaqp _addMouseEventListener(domWindow, 'mousemove', (DomMouseEvent event) { final List pointerData = []; final _SanitizedDetails? up = _sanitizer.sanitizeMissingRightClickUp(buttons: event.buttons!.toInt()); @@ -1100,6 +1125,7 @@ class _MouseAdapter extends _BaseAdapter with _WheelEventListenerMixin { } }, useCapture: false); + // TODO(dit): This must happen in the glassPane, https://github.com/flutter/flutter/issues/116561 _addMouseEventListener(domWindow, 'mouseup', (DomMouseEvent event) { final List pointerData = []; final _SanitizedDetails? sanitizedDetails = _sanitizer.sanitizeUpEvent(buttons: event.buttons?.toInt()); @@ -1124,6 +1150,7 @@ class _MouseAdapter extends _BaseAdapter with _WheelEventListenerMixin { assert(data != null); assert(event != null); assert(details != null); + final ui.Offset offset = computeEventOffsetToTarget(event, glassPaneElement); _pointerDataConverter.convert( data, change: details.change, @@ -1131,8 +1158,8 @@ class _MouseAdapter extends _BaseAdapter with _WheelEventListenerMixin { kind: ui.PointerDeviceKind.mouse, signalKind: ui.PointerSignalKind.none, device: _mouseDeviceId, - physicalX: event.clientX * ui.window.devicePixelRatio, - physicalY: event.clientY * ui.window.devicePixelRatio, + physicalX: offset.dx * ui.window.devicePixelRatio, + physicalY: offset.dy * ui.window.devicePixelRatio, buttons: details.buttons, pressure: 1.0, pressureMax: 1.0, diff --git a/lib/web_ui/lib/src/engine/pointer_binding/event_position_helper.dart b/lib/web_ui/lib/src/engine/pointer_binding/event_position_helper.dart new file mode 100644 index 0000000000000..6be2b9ccc2bcb --- /dev/null +++ b/lib/web_ui/lib/src/engine/pointer_binding/event_position_helper.dart @@ -0,0 +1,113 @@ +// 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 'package:ui/ui.dart' as ui show Offset; + +import '../dom.dart'; +import '../semantics.dart' show EngineSemanticsOwner; + +/// Returns an [ui.Offset] of the position of [event], relative to the position of [actualTarget]. +/// +/// The offset is *not* multiplied by DPR or anything else, it's the closest +/// to what the DOM would return if we had currentTarget readily available. +/// +/// This needs an `actualTarget`, because the `event.currentTarget` (which is what +/// this would really need to use) gets lost when the `event` comes from a "coalesced" +/// event. +/// +/// It also takes into account semantics being enabled to fix the case where +/// offsetX, offsetY == 0 (TalkBack events). +ui.Offset computeEventOffsetToTarget(DomMouseEvent event, DomElement actualTarget) { + // On top of a platform view + if (event.target != actualTarget) { + return _computeOffsetOnPlatformView(event, actualTarget); + } + // On a TalkBack event + if (EngineSemanticsOwner.instance.semanticsEnabled && event.offsetX == 0 && event.offsetY == 0) { + return _computeOffsetForTalkbackEvent(event, actualTarget); + } + // Return the offsetX/Y in the normal case. + // (This works with 3D translations of the parent element.) + return ui.Offset(event.offsetX, event.offsetY); +} + +/// Computes the event offset when hovering over a platformView. +/// +/// This still uses offsetX/Y, but adds the offset from the top/left corner of the +/// platform view to the glass pane (`actualTarget`). +/// +/// ×--FlutterView(actualTarget)--------------+ +/// |\ | +/// | x1,y1 | +/// | | +/// | | +/// | ×-PlatformView(target)---------+ | +/// | |\ | | +/// | | x2,y2 | | +/// | | | | +/// | | × (event) | | +/// | | \ | | +/// | | offsetX, offsetY | | +/// | | (Relative to PlatformView) | | +/// | +------------------------------+ | +/// +-----------------------------------------+ +/// +/// Offset between PlatformView and FlutterView (xP, yP) = (x2 - x1, y2 - y1) +/// +/// Event offset relative to FlutterView = (offsetX + xP, offsetY + yP) +// TODO(dit): Make this understand 3D transforms, https://github.com/flutter/flutter/issues/117091 +ui.Offset _computeOffsetOnPlatformView(DomMouseEvent event, DomElement actualTarget) { + final DomElement target = event.target! as DomElement; + final DomRect targetRect = target.getBoundingClientRect(); + final DomRect actualTargetRect = actualTarget.getBoundingClientRect(); + final double offsetTop = targetRect.y - actualTargetRect.y; + final double offsetLeft = targetRect.x - actualTargetRect.x; + return ui.Offset(event.offsetX + offsetLeft, event.offsetY + offsetTop); +} + +/// Computes the event offset when TalkBack is firing the event. +/// +/// In this case, we need to use the clientX/Y position of the event (which are +/// relative to the absolute top-left corner of the page, including scroll), then +/// deduct the offsetLeft/Top from every offsetParent of the `actualTarget`. +/// +/// ×-Page----║-------------------------------+ +/// | ║ | +/// | ×-------║--------offsetParent(s)-----+ | +/// | |\ | | +/// | | offsetLeft, offsetTop | | +/// | | | | +/// | | | | +/// | | ×-----║-------------actualTarget-+ | | +/// | | | | | | +/// ═════ × ─ (scrollLeft, scrollTop)═ ═ ═ +/// | | | | | | +/// | | | × | | | +/// | | | \ | | | +/// | | | clientX, clientY | | | +/// | | | (Relative to Page + Scroll) | | | +/// | | +-----║--------------------------+ | | +/// | +-------║----------------------------+ | +/// +---------║-------------------------------+ +/// +/// Computing the offset of the event relative to the actualTarget requires to +/// compute the clientX, clientY of the actualTarget. To do that, we iterate +/// up the offsetParent elements of actualTarget adding their offset and scroll +/// positions. Finally, we deduct that from clientX, clientY of the event. +// TODO(dit): Make this understand 3D transforms, https://github.com/flutter/flutter/issues/117091 +ui.Offset _computeOffsetForTalkbackEvent(DomMouseEvent event, DomElement actualTarget) { + assert(EngineSemanticsOwner.instance.semanticsEnabled); + // Use clientX/clientY as the position of the event (this is relative to + // the top left of the page, including scroll) + double offsetX = event.clientX; + double offsetY = event.clientY; + // Compute the scroll offset of actualTarget + DomHTMLElement parent = actualTarget as DomHTMLElement; + while(parent.offsetParent != null){ + offsetX -= parent.offsetLeft - parent.scrollLeft; + offsetY -= parent.offsetTop - parent.scrollTop; + parent = parent.offsetParent!; + } + return ui.Offset(offsetX, offsetY); +} diff --git a/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/custom_element_dimensions_provider.dart b/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/custom_element_dimensions_provider.dart new file mode 100644 index 0000000000000..ce9b6b5b7a290 --- /dev/null +++ b/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/custom_element_dimensions_provider.dart @@ -0,0 +1,88 @@ +// 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 'package:ui/src/engine/dom.dart'; +import 'package:ui/src/engine/window.dart'; +import 'package:ui/ui.dart' as ui show Size; + +import 'dimensions_provider.dart'; + +/// This class provides observable, real-time dimensions of a host element. +/// +/// All the measurements returned from this class are potentially *expensive*, +/// and should be cached as needed. Every call to every method on this class +/// WILL perform actual DOM measurements. +class CustomElementDimensionsProvider extends DimensionsProvider { + /// Creates a [CustomElementDimensionsProvider] from a [_hostElement]. + CustomElementDimensionsProvider(this._hostElement) { + // Hook up a resize observer on the hostElement (if supported!). + _hostElementResizeObserver = createDomResizeObserver(( + List entries, + DomResizeObserver _, + ) { + entries + .map((DomResizeObserverEntry entry) => + ui.Size(entry.contentRect.width, entry.contentRect.height)) + .forEach(_broadcastSize); + }); + + assert(() { + if (_hostElementResizeObserver == null) { + domWindow.console.warn('ResizeObserver API not supported. ' + 'Flutter will not resize with its hostElement.'); + } + return true; + }()); + + _hostElementResizeObserver?.observe(_hostElement); + } + + // The host element that will be used to retrieve (and observe) app size measurements. + final DomElement _hostElement; + + // Handle resize events + late DomResizeObserver? _hostElementResizeObserver; + final StreamController _onResizeStreamController = + StreamController.broadcast(); + + // Broadcasts the last seen `Size`. + void _broadcastSize(ui.Size size) { + _onResizeStreamController.add(size); + } + + @override + void close() { + _hostElementResizeObserver?.disconnect(); + // ignore:unawaited_futures + _onResizeStreamController.close(); + } + + @override + Stream get onResize => _onResizeStreamController.stream; + + @override + ui.Size computePhysicalSize() { + final double devicePixelRatio = getDevicePixelRatio(); + + return ui.Size( + _hostElement.clientWidth * devicePixelRatio, + _hostElement.clientHeight * devicePixelRatio, + ); + } + + @override + WindowPadding computeKeyboardInsets( + double physicalHeight, + bool isEditingOnMobile, + ) { + return const WindowPadding( + top: 0, + right: 0, + bottom: 0, + left: 0, + ); + } +} diff --git a/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/dimensions_provider.dart b/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/dimensions_provider.dart new file mode 100644 index 0000000000000..efabff6bb3e07 --- /dev/null +++ b/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/dimensions_provider.dart @@ -0,0 +1,65 @@ +// 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 'package:ui/src/engine/window.dart'; +import 'package:ui/ui.dart' as ui show Size; + +import '../../dom.dart'; +import 'custom_element_dimensions_provider.dart'; +import 'full_page_dimensions_provider.dart'; + +/// This class provides the dimensions of the "viewport" in which the app is rendered. +/// +/// Similarly to the `EmbeddingStrategy`, this class is specialized to handle +/// different sources of information: +/// +/// * [FullPageDimensionsProvider] - The default behavior, uses the VisualViewport +/// API to measure, and react to, the dimensions of the full browser window. +/// * [CustomElementDimensionsProvider] - Uses a custom html Element as the source +/// of dimensions, and the ResizeObserver to notify the app of changes. +/// +/// All the measurements returned from this class are potentially *expensive*, +/// and should be cached as needed. Every call to every method on this class +/// WILL perform actual DOM measurements. +abstract class DimensionsProvider { + DimensionsProvider(); + + /// Creates the appropriate DimensionsProvider depending on the incoming [hostElement]. + factory DimensionsProvider.create({DomElement? hostElement}) { + if (hostElement != null) { + return CustomElementDimensionsProvider(hostElement); + } else { + return FullPageDimensionsProvider(); + } + } + + /// Returns the DPI reported by the browser. + double getDevicePixelRatio() { + // This is overridable in tests. + return window.devicePixelRatio; + } + + /// Returns the [ui.Size] of the "viewport". + /// + /// This function is expensive. It triggers browser layout if there are + /// pending DOM writes. + ui.Size computePhysicalSize(); + + /// Returns the [WindowPadding] of the keyboard insets (if present). + WindowPadding computeKeyboardInsets( + double physicalHeight, + bool isEditingOnMobile, + ); + + /// Returns a Stream with the changes to [ui.Size] (when cheap to get). + Stream get onResize; + + /// Clears any resources grabbed by the DimensionsProvider instance. + /// + /// All internal event handlers will be disconnected, and the [onResize] Stream + /// will be closed. + void close(); +} diff --git a/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/full_page_dimensions_provider.dart b/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/full_page_dimensions_provider.dart new file mode 100644 index 0000000000000..9db769fd1707d --- /dev/null +++ b/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/full_page_dimensions_provider.dart @@ -0,0 +1,123 @@ +// 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 'package:js/js.dart'; +import 'package:ui/src/engine/browser_detection.dart'; +import 'package:ui/src/engine/dom.dart'; +import 'package:ui/src/engine/window.dart'; +import 'package:ui/ui.dart' as ui show Size; + +import 'dimensions_provider.dart'; + +/// This class provides the real-time dimensions of a "full page" viewport. +/// +/// All the measurements returned from this class are potentially *expensive*, +/// and should be cached as needed. Every call to every method on this class +/// WILL perform actual DOM measurements. +class FullPageDimensionsProvider extends DimensionsProvider { + /// Constructs a global [FullPageDimensionsProvider]. + /// + /// Doesn't need any parameters, because all the measurements come from the + /// globally available [DomVisualViewport]. + FullPageDimensionsProvider() { + // Determine what 'resize' event we'll be listening to. + // This is needed for older browsers (Firefox < 91, Safari < 13) + // TODO(dit): Clean this up, https://github.com/flutter/flutter/issues/117105 + final DomEventTarget resizeEventTarget = + domWindow.visualViewport ?? domWindow; + + // Subscribe to the 'resize' event, and convert it to a ui.Size stream. + _domResizeSubscription = DomSubscription( + resizeEventTarget, + 'resize', + allowInterop(_onVisualViewportResize), + ); + } + + late DomSubscription _domResizeSubscription; + final StreamController _onResizeStreamController = + StreamController.broadcast(); + + void _onVisualViewportResize(DomEvent event) { + // `event` doesn't contain any size information (as opposed to the custom + // element resize observer). If it did, we could broadcast the physical + // dimensions here and never have to re-measure the app, until the next + // resize event triggers. + // Would it be too costly to broadcast the computed physical size from here, + // and then never re-measure the app? + // Related: https://github.com/flutter/flutter/issues/117036 + _onResizeStreamController.add(null); + } + + @override + void close() { + _domResizeSubscription.cancel(); + // ignore:unawaited_futures + _onResizeStreamController.close(); + } + + @override + Stream get onResize => _onResizeStreamController.stream; + + @override + ui.Size computePhysicalSize() { + late double windowInnerWidth; + late double windowInnerHeight; + final DomVisualViewport? viewport = domWindow.visualViewport; + final double devicePixelRatio = getDevicePixelRatio(); + + if (viewport != null) { + if (operatingSystem == OperatingSystem.iOs) { + /// Chrome on iOS reports incorrect viewport.height when app + /// starts in portrait orientation and the phone is rotated to + /// landscape. + /// + /// We instead use documentElement clientWidth/Height to read + /// accurate physical size. VisualViewport api is only used during + /// text editing to make sure inset is correctly reported to + /// framework. + final double docWidth = domDocument.documentElement!.clientWidth; + final double docHeight = domDocument.documentElement!.clientHeight; + windowInnerWidth = docWidth * devicePixelRatio; + windowInnerHeight = docHeight * devicePixelRatio; + } else { + windowInnerWidth = viewport.width! * devicePixelRatio; + windowInnerHeight = viewport.height! * devicePixelRatio; + } + } else { + windowInnerWidth = domWindow.innerWidth! * devicePixelRatio; + windowInnerHeight = domWindow.innerHeight! * devicePixelRatio; + } + return ui.Size( + windowInnerWidth, + windowInnerHeight, + ); + } + + @override + WindowPadding computeKeyboardInsets( + double physicalHeight, + bool isEditingOnMobile, + ) { + final double devicePixelRatio = getDevicePixelRatio(); + final DomVisualViewport? viewport = domWindow.visualViewport; + late double windowInnerHeight; + + if (viewport != null) { + if (operatingSystem == OperatingSystem.iOs && !isEditingOnMobile) { + windowInnerHeight = + domDocument.documentElement!.clientHeight * devicePixelRatio; + } else { + windowInnerHeight = viewport.height! * devicePixelRatio; + } + } else { + windowInnerHeight = domWindow.innerHeight! * devicePixelRatio; + } + final double bottomPadding = physicalHeight - windowInnerHeight; + + return WindowPadding(bottom: bottomPadding, left: 0, right: 0, top: 0); + } +} diff --git a/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy.dart b/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy.dart new file mode 100644 index 0000000000000..ecf92bb3d8956 --- /dev/null +++ b/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy.dart @@ -0,0 +1,57 @@ +// 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 'package:ui/src/engine/dom.dart'; + +import 'embedding_strategy.dart'; + +/// An [EmbeddingStrategy] that renders flutter inside a target host element. +/// +/// This strategy attempts to minimize DOM modifications outside of the host +/// element, so it plays "nice" with other web frameworks. +class CustomElementEmbeddingStrategy extends EmbeddingStrategy { + /// Creates a [CustomElementEmbeddingStrategy] to embed a Flutter view into [_hostElement]. + CustomElementEmbeddingStrategy(this._hostElement) { + _hostElement.clearChildren(); + } + + /// The target element in which this strategy will embedd Flutter. + final DomElement _hostElement; + + @override + void initialize({ + Map? hostElementAttributes, + }) { + // ignore:avoid_function_literals_in_foreach_calls + hostElementAttributes?.entries.forEach((MapEntry entry) { + _setHostAttribute(entry.key, entry.value); + }); + _setHostAttribute('flt-embedding', 'custom-element'); + } + + @override + void attachGlassPane(DomElement glassPaneElement) { + glassPaneElement + ..style.width = '100%' + ..style.height = '100%' + ..style.display = 'block' + ..style.overflow = 'hidden' + ..style.position = 'relative'; + + _hostElement.appendChild(glassPaneElement); + + registerElementForCleanup(glassPaneElement); + } + + @override + void attachResourcesHost(DomElement resourceHost, {DomElement? nextTo}) { + _hostElement.insertBefore(resourceHost, nextTo); + + registerElementForCleanup(resourceHost); + } + + void _setHostAttribute(String name, String value) { + _hostElement.setAttribute(name, value); + } +} diff --git a/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/embedding_strategy.dart b/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/embedding_strategy.dart new file mode 100644 index 0000000000000..bb1c9361ae2a8 --- /dev/null +++ b/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/embedding_strategy.dart @@ -0,0 +1,58 @@ +// 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 'package:meta/meta.dart'; + +import 'package:ui/src/engine/dom.dart'; +import 'package:ui/src/engine/view_embedder/hot_restart_cache_handler.dart'; + +import 'custom_element_embedding_strategy.dart'; +import 'full_page_embedding_strategy.dart'; + +/// Controls how a Flutter app is placed, sized and measured on the page. +/// +/// The base class handles general behavior (like hot-restart cleanup), and then +/// each specialization enables different types of DOM embeddings: +/// +/// * [FullPageEmbeddingStrategy] - The default behavior, where flutter takes +/// control of the whole page. +/// * [CustomElementEmbeddingStrategy] - Flutter is rendered inside a custom host +/// element, provided by the web app programmer through the engine +/// initialization. +abstract class EmbeddingStrategy { + EmbeddingStrategy() { + // Initialize code to handle hot-restart (debug only). + assert(() { + _hotRestartCache = HotRestartCacheHandler(); + return true; + }()); + } + + factory EmbeddingStrategy.create({DomElement? hostElement}) { + if (hostElement != null) { + return CustomElementEmbeddingStrategy(hostElement); + } else { + return FullPageEmbeddingStrategy(); + } + } + + /// Keeps a list of elements to be cleaned up at hot-restart. + HotRestartCacheHandler? _hotRestartCache; + + void initialize({ + Map? hostElementAttributes, + }); + + /// Attaches the glassPane element into the hostElement. + void attachGlassPane(DomElement glassPaneElement); + + /// Attaches the resourceHost element into the hostElement. + void attachResourcesHost(DomElement resourceHost, {DomElement? nextTo}); + + /// Registers a [DomElement] to be cleaned up after hot restart. + @mustCallSuper + void registerElementForCleanup(DomElement element) { + _hotRestartCache?.registerElement(element); + } +} diff --git a/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart b/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart new file mode 100644 index 0000000000000..009b6aef4b8a0 --- /dev/null +++ b/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart @@ -0,0 +1,108 @@ +// 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 'package:ui/src/engine/dom.dart'; +import 'package:ui/src/engine/util.dart' show assertionsEnabled, setElementStyle; + +import 'embedding_strategy.dart'; + +/// An [EmbeddingStrategy] that takes over the whole web page. +/// +/// This strategy takes over the element, modifies the viewport meta-tag, +/// and ensures that the root Flutter view covers the whole screen. +class FullPageEmbeddingStrategy extends EmbeddingStrategy { + @override + void initialize({ + Map? hostElementAttributes, + }) { + // ignore:avoid_function_literals_in_foreach_calls + hostElementAttributes?.entries.forEach((MapEntry entry) { + _setHostAttribute(entry.key, entry.value); + }); + _setHostAttribute('flt-embedding', 'full-page'); + + _applyViewportMeta(); + _setHostStyles(); + } + + @override + void attachGlassPane(DomElement glassPaneElement) { + /// Tweaks style so the glassPane works well with the hostElement. + glassPaneElement.style + ..position = 'absolute' + ..top = '0' + ..right = '0' + ..bottom = '0' + ..left = '0'; + + domDocument.body!.append(glassPaneElement); + + registerElementForCleanup(glassPaneElement); + } + + @override + void attachResourcesHost(DomElement resourceHost, {DomElement? nextTo}) { + domDocument.body!.insertBefore(resourceHost, nextTo); + + registerElementForCleanup(resourceHost); + } + + void _setHostAttribute(String name, String value) { + domDocument.body!.setAttribute(name, value); + } + + // Sets the global styles for a flutter app. + void _setHostStyles() { + final DomHTMLBodyElement bodyElement = domDocument.body!; + + setElementStyle(bodyElement, 'position', 'fixed'); + setElementStyle(bodyElement, 'top', '0'); + setElementStyle(bodyElement, 'right', '0'); + setElementStyle(bodyElement, 'bottom', '0'); + setElementStyle(bodyElement, 'left', '0'); + setElementStyle(bodyElement, 'overflow', 'hidden'); + setElementStyle(bodyElement, 'padding', '0'); + setElementStyle(bodyElement, 'margin', '0'); + + setElementStyle(bodyElement, 'user-select', 'none'); + setElementStyle(bodyElement, '-webkit-user-select', 'none'); + + // This is required to prevent the browser from doing any native touch + // handling. If this is not done, the browser doesn't report 'pointermove' + // events properly. + setElementStyle(bodyElement, 'touch-action', 'none'); + } + + // Sets a meta viewport tag appropriate for Flutter Web in full screen. + void _applyViewportMeta() { + for (final DomElement viewportMeta + in domDocument.head!.querySelectorAll('meta[name="viewport"]')) { + if (assertionsEnabled) { + // Filter out the meta tag that the engine placed on the page. This is + // to avoid UI flicker during hot restart. Hot restart will clean up the + // old meta tag synchronously with the first post-restart frame. + if (!viewportMeta.hasAttribute('flt-viewport')) { + print( + 'WARNING: found an existing tag. Flutter ' + 'Web uses its own viewport configuration for better compatibility ' + 'with Flutter. This tag will be replaced.', + ); + } + } + viewportMeta.remove(); + } + + // The meta viewport is always removed by the for method above, so we don't + // need to do anything else here, other than create it again. + final DomHTMLMetaElement viewportMeta = createDomHTMLMetaElement() + ..setAttribute('flt-viewport', '') + ..name = 'viewport' + ..content = 'width=device-width, initial-scale=1.0, ' + 'maximum-scale=1.0, user-scalable=no'; + + domDocument.head!.append(viewportMeta); + + registerElementForCleanup(viewportMeta); + } +} diff --git a/lib/web_ui/lib/src/engine/view_embedder/hot_restart_cache_handler.dart b/lib/web_ui/lib/src/engine/view_embedder/hot_restart_cache_handler.dart new file mode 100644 index 0000000000000..876972141b890 --- /dev/null +++ b/lib/web_ui/lib/src/engine/view_embedder/hot_restart_cache_handler.dart @@ -0,0 +1,57 @@ +// 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 'package:meta/meta.dart'; + +import '../dom.dart'; +import '../safe_browser_api.dart'; + +/// Handles [DomElement]s that need to be removed after a hot-restart. +/// +/// Elements are stored in an [_elements] list, backed by a global JS variable, +/// named [defaultCacheName]. +/// +/// When the app hot-restarts (and a new instance of this class is created), +/// everything in [_elements] is removed from the DOM. +class HotRestartCacheHandler { + HotRestartCacheHandler() { + if (_elements.isNotEmpty) { + // We are in a post hot-restart world, clear the elements now. + _clearAllElements(); + } + } + + /// The name for the JS global variable backing this cache. + @visibleForTesting + static const String defaultCacheName = '__flutter_state'; + + /// The js-interop layer backing [_elements]. + /// + /// Elements are stored in a JS global array named [defaultCacheName]. + late List? _jsElements; + + /// The elements that need to be cleaned up after hot-restart. + List get _elements { + _jsElements = + getJsProperty?>(domWindow, defaultCacheName); + if (_jsElements == null) { + _jsElements = []; + setJsProperty(domWindow, defaultCacheName, _jsElements); + } + return _jsElements!; + } + + /// Removes every element from [_elements] and empties the list. + void _clearAllElements() { + for (final DomElement? element in _elements) { + element?.remove(); + } + _elements.clear(); + } + + /// Registers a [DomElement] to be removed after hot-restart. + void registerElement(DomElement element) { + _elements.add(element); + } +} diff --git a/lib/web_ui/lib/src/engine/window.dart b/lib/web_ui/lib/src/engine/window.dart index 7010ece2079c6..5538055f589f7 100644 --- a/lib/web_ui/lib/src/engine/window.dart +++ b/lib/web_ui/lib/src/engine/window.dart @@ -12,8 +12,7 @@ import 'package:js/js.dart'; import 'package:meta/meta.dart'; import 'package:ui/ui.dart' as ui; -import '../engine.dart' show registerHotRestartListener, renderer; -import 'browser_detection.dart'; +import '../engine.dart' show DimensionsProvider, registerHotRestartListener, renderer; import 'dom.dart'; import 'navigation/history.dart'; import 'navigation/js_url_strategy.dart'; @@ -55,6 +54,7 @@ class EngineFlutterWindow extends ui.SingletonFlutterWindow { registerHotRestartListener(() { _browserHistory?.dispose(); renderer.clearFragmentProgramCache(); + _dimensionsProvider.close(); }); } @@ -207,6 +207,16 @@ class EngineFlutterWindow extends ui.SingletonFlutterWindow { const ui.ViewConfiguration(); } + late DimensionsProvider _dimensionsProvider; + void configureDimensionsProvider(DimensionsProvider dimensionsProvider) { + _dimensionsProvider = dimensionsProvider; + } + + @override + double get devicePixelRatio => _dimensionsProvider.getDevicePixelRatio(); + + Stream get onResize => _dimensionsProvider.onResize; + @override ui.Size get physicalSize { if (_physicalSize == null) { @@ -232,38 +242,7 @@ class EngineFlutterWindow extends ui.SingletonFlutterWindow { }()); if (!override) { - double windowInnerWidth; - double windowInnerHeight; - final DomVisualViewport? viewport = domWindow.visualViewport; - - if (viewport != null) { - if (operatingSystem == OperatingSystem.iOs) { - /// Chrome on iOS reports incorrect viewport.height when app - /// starts in portrait orientation and the phone is rotated to - /// landscape. - /// - /// We instead use documentElement clientWidth/Height to read - /// accurate physical size. VisualViewport api is only used during - /// text editing to make sure inset is correctly reported to - /// framework. - final double docWidth = - domDocument.documentElement!.clientWidth; - final double docHeight = - domDocument.documentElement!.clientHeight; - windowInnerWidth = docWidth * devicePixelRatio; - windowInnerHeight = docHeight * devicePixelRatio; - } else { - windowInnerWidth = viewport.width! * devicePixelRatio; - windowInnerHeight = viewport.height! * devicePixelRatio; - } - } else { - windowInnerWidth = domWindow.innerWidth! * devicePixelRatio; - windowInnerHeight = domWindow.innerHeight! * devicePixelRatio; - } - _physicalSize = ui.Size( - windowInnerWidth, - windowInnerHeight, - ); + _physicalSize = _dimensionsProvider.computePhysicalSize(); } } @@ -273,21 +252,10 @@ class EngineFlutterWindow extends ui.SingletonFlutterWindow { } void computeOnScreenKeyboardInsets(bool isEditingOnMobile) { - double windowInnerHeight; - final DomVisualViewport? viewport = domWindow.visualViewport; - if (viewport != null) { - if (operatingSystem == OperatingSystem.iOs && !isEditingOnMobile) { - windowInnerHeight = - domDocument.documentElement!.clientHeight * devicePixelRatio; - } else { - windowInnerHeight = viewport.height! * devicePixelRatio; - } - } else { - windowInnerHeight = domWindow.innerHeight! * devicePixelRatio; - } - final double bottomPadding = _physicalSize!.height - windowInnerHeight; - _viewInsets = - WindowPadding(bottom: bottomPadding, left: 0, right: 0, top: 0); + _viewInsets = _dimensionsProvider.computeKeyboardInsets( + _physicalSize!.height, + isEditingOnMobile, + ); } /// Uses the previous physical size and current innerHeight/innerWidth @@ -305,26 +273,16 @@ class EngineFlutterWindow extends ui.SingletonFlutterWindow { /// height: 658 width: 393 /// height: 368 width: 393 bool isRotation() { - double height = 0; - double width = 0; - if (domWindow.visualViewport != null) { - height = - domWindow.visualViewport!.height! * devicePixelRatio; - width = domWindow.visualViewport!.width! * devicePixelRatio; - } else { - height = domWindow.innerHeight! * devicePixelRatio; - width = domWindow.innerWidth! * devicePixelRatio; - } - // This method compares the new dimensions with the previous ones. // Return false if the previous dimensions are not set. if (_physicalSize != null) { + final ui.Size current = _dimensionsProvider.computePhysicalSize(); // First confirm both height and width are effected. - if (_physicalSize!.height != height && _physicalSize!.width != width) { + if (_physicalSize!.height != current.height && _physicalSize!.width != current.width) { // If prior to rotation height is bigger than width it should be the // opposite after the rotation and vice versa. - if ((_physicalSize!.height > _physicalSize!.width && height < width) || - (_physicalSize!.width > _physicalSize!.height && width < height)) { + if ((_physicalSize!.height > _physicalSize!.width && current.height < current.width) || + (_physicalSize!.width > _physicalSize!.height && current.width < current.height)) { // Rotation detected return true; } diff --git a/lib/web_ui/test/engine/host_node_test.dart b/lib/web_ui/test/engine/host_node_test.dart index b41f7c381dd18..8c01b6a840dc9 100644 --- a/lib/web_ui/test/engine/host_node_test.dart +++ b/lib/web_ui/test/engine/host_node_test.dart @@ -15,7 +15,7 @@ void testMain() { domDocument.body!.append(rootNode); group('ShadowDomHostNode', () { - final HostNode hostNode = ShadowDomHostNode(rootNode); + final HostNode hostNode = ShadowDomHostNode(rootNode, '14px monospace'); test('Initializes and attaches a shadow root', () { expect(domInstanceOfString(hostNode.node, 'ShadowRoot'), isTrue); @@ -33,30 +33,90 @@ void testMain() { }); test('Attaches a stylesheet to the shadow root', () { - final DomElement firstChild = - (hostNode.node as DomShadowRoot).childNodes.toList()[0] as DomElement; + final DomElement? style = + hostNode.querySelector('#flt-internals-stylesheet'); - expect(firstChild.tagName, equalsIgnoringCase('style')); + expect(style, isNotNull); + expect(style!.tagName, equalsIgnoringCase('style')); + }); + + test('(Self-test) hasCssRule can extract rules', () { + final DomElement? style = + hostNode.querySelector('#flt-internals-stylesheet'); + + final bool hasRule = hasCssRule(style, + selector: '.flt-text-editing::placeholder', + declaration: 'opacity: 0'); + + final bool hasFakeRule = hasCssRule(style, + selector: 'input::selection', declaration: 'color: #fabada;'); + + expect(hasRule, isTrue); + expect(hasFakeRule, isFalse); + }); + + test('Attaches outrageous text styles to flt-scene-host', () { + final DomElement? style = + hostNode.querySelector('#flt-internals-stylesheet'); + + final bool hasColorRed = hasCssRule(style, + selector: 'flt-scene-host', declaration: 'color: red'); + + bool hasFont = false; + if (isSafari) { + // Safari expands the shorthand rules, so we check for all we've set (separately). + hasFont = hasCssRule(style, + selector: 'flt-scene-host', + declaration: 'font-family: monospace') && + hasCssRule(style, + selector: 'flt-scene-host', declaration: 'font-size: 14px'); + } else { + hasFont = hasCssRule(style, + selector: 'flt-scene-host', declaration: 'font: 14px monospace'); + } + + expect(hasColorRed, isTrue, + reason: 'Should make foreground color red within scene host.'); + expect(hasFont, isTrue, reason: 'Should pass default css font.'); }); test('Attaches styling to remove password reveal icons on Edge', () { - final DomElement? edgeStyleElement = hostNode.querySelector('#ms-reveal'); + final DomElement? style = + hostNode.querySelector('#flt-internals-stylesheet'); - expect(edgeStyleElement, isNotNull); - expect(edgeStyleElement!.innerText, 'input::-ms-reveal {display: none;}'); + // Check that style.sheet! contains input::-ms-reveal rule + final bool hidesRevealIcons = hasCssRule(style, + selector: 'input::-ms-reveal', declaration: 'display: none'); + + final bool codeRanInFakeyBrowser = hasCssRule(style, + selector: 'input.fallback-for-fakey-browser-in-ci', + declaration: 'display: none'); + + if (codeRanInFakeyBrowser) { + print('Please, fix https://github.com/flutter/flutter/issues/116302'); + } + + expect(hidesRevealIcons || codeRanInFakeyBrowser, isTrue, + reason: 'In Edge, stylesheet must contain "input::-ms-reveal" rule.'); }, skip: !isEdge); test('Does not attach the Edge-specific style tag on non-Edge browsers', () { - final DomElement? edgeStyleElement = hostNode.querySelector('#ms-reveal'); - expect(edgeStyleElement, isNull); + final DomElement? style = + hostNode.querySelector('#flt-internals-stylesheet'); + + // Check that style.sheet! contains input::-ms-reveal rule + final bool hidesRevealIcons = hasCssRule(style, + selector: 'input::-ms-reveal', declaration: 'display: none'); + + expect(hidesRevealIcons, isFalse); }, skip: isEdge); _runDomTests(hostNode); }); group('ElementHostNode', () { - final HostNode hostNode = ElementHostNode(rootNode); + final HostNode hostNode = ElementHostNode(rootNode, ''); test('Initializes and attaches a child element', () { expect(domInstanceOfString(hostNode.node, 'Element'), isTrue); @@ -112,3 +172,25 @@ void _runDomTests(HostNode hostNode) { }); }); } + +/// Finds out whether a given CSS Rule ([selector] { [declaration]; }) exists in a [styleSheet]. +bool hasCssRule( + DomElement? styleSheet, { + required String selector, + required String declaration, +}) { + assert(styleSheet != null); + assert((styleSheet! as DomHTMLStyleElement).sheet != null); + + // regexr.com/740ff + final RegExp ruleLike = + RegExp('[^{]*(?:$selector)[^{]*{[^}]*(?:$declaration)[^}]*}'); + + final DomCSSStyleSheet sheet = + (styleSheet! as DomHTMLStyleElement).sheet! as DomCSSStyleSheet; + + // Check that the cssText of any rule matches the ruleLike RegExp. + return sheet.cssRules + .map((DomCSSRule rule) => rule.cssText) + .any((String rule) => ruleLike.hasMatch(rule)); +} diff --git a/lib/web_ui/test/engine/pointer_binding_test.dart b/lib/web_ui/test/engine/pointer_binding_test.dart index 2f5f48c0252a6..d94b34d10e816 100644 --- a/lib/web_ui/test/engine/pointer_binding_test.dart +++ b/lib/web_ui/test/engine/pointer_binding_test.dart @@ -23,13 +23,15 @@ typedef _ContextTestBody = void Function(T); void _testEach( Iterable contexts, String description, - _ContextTestBody body, + _ContextTestBody body, { + Object? skip, + } ) { for (final T context in contexts) { if (context.isSupported) { test('${context.name} $description', () { body(context); - }); + }, skip: skip); } } } @@ -388,6 +390,8 @@ void testMain() { expect(event.buttons, equals(1)); expect(event.client.x, equals(100)); expect(event.client.y, equals(101)); + expect(event.offset.x, equals(100)); + expect(event.offset.y, equals(101)); event = expectCorrectType( context.mouseDown(clientX: 110, clientY: 111, button: 2, buttons: 2)); @@ -849,7 +853,7 @@ void testMain() { packets.clear(); // Release the pointer on the semantics placeholder. - domWindow.dispatchEvent(context.primaryUp( + glassPane.dispatchEvent(context.primaryUp( clientX: 100.0, clientY: 200.0, )); @@ -865,6 +869,7 @@ void testMain() { semanticsPlaceholder.remove(); }, + skip: isFirefox, // https://bugzilla.mozilla.org/show_bug.cgi?id=1804190 ); // BUTTONED ADAPTERS @@ -2472,7 +2477,7 @@ void testMain() { packets.clear(); // Move outside the glasspane. - domWindow.dispatchEvent(context.primaryMove( + glassPane.dispatchEvent(context.primaryMove( clientX: 900.0, clientY: 1900.0, )); @@ -2484,7 +2489,7 @@ void testMain() { packets.clear(); // Release outside the glasspane. - domWindow.dispatchEvent(context.primaryUp( + glassPane.dispatchEvent(context.primaryUp( clientX: 1000.0, clientY: 2000.0, )); @@ -3351,6 +3356,7 @@ class _MouseEventContext extends _BasicEventContext final List eventArgs = [ type, { + 'bubbles': true, 'buttons': buttons, 'button': button, 'clientX': clientX, @@ -3569,6 +3575,7 @@ class _PointerEventContext extends _BasicEventContext String? pointerType, }) { return createDomPointerEvent('pointerup', { + 'bubbles': true, 'pointerId': pointer, 'button': button, 'buttons': buttons, diff --git a/lib/web_ui/test/engine/view_embedder/dimensions_provider/custom_element_dimensions_provider_test.dart b/lib/web_ui/test/engine/view_embedder/dimensions_provider/custom_element_dimensions_provider_test.dart new file mode 100644 index 0000000000000..a891bc0634d05 --- /dev/null +++ b/lib/web_ui/test/engine/view_embedder/dimensions_provider/custom_element_dimensions_provider_test.dart @@ -0,0 +1,170 @@ +// 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. + +@TestOn('browser') + +import 'dart:async'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine/dom.dart'; +import 'package:ui/src/engine/view_embedder/dimensions_provider/custom_element_dimensions_provider.dart'; +import 'package:ui/src/engine/window.dart'; +import 'package:ui/ui.dart' as ui show Size; + +void main() { + internalBootstrapBrowserTest(() => doTests); +} + +void doTests() { + final DomElement sizeSource = createDomElement('div') + ..style.display = 'block'; + + group('computePhysicalSize', () { + late CustomElementDimensionsProvider provider; + + setUp(() { + sizeSource + ..style.width = '10px' + ..style.height = '10px'; + domDocument.body!.append(sizeSource); + provider = CustomElementDimensionsProvider(sizeSource); + }); + + tearDown(() { + provider.close(); // cleanup + sizeSource.remove(); + }); + + test('returns physical size of element (width * dpr)', () { + const double dpr = 2.5; + const double logicalWidth = 50; + const double logicalHeight = 75; + window.debugOverrideDevicePixelRatio(dpr); + + sizeSource + ..style.width = '${logicalWidth}px' + ..style.height = '${logicalHeight}px'; + + const ui.Size expected = ui.Size(logicalWidth * dpr, logicalHeight * dpr); + + final ui.Size computed = provider.computePhysicalSize(); + + expect(computed, expected); + }); + }); + + group('computeKeyboardInsets', () { + late CustomElementDimensionsProvider provider; + + setUp(() { + sizeSource + ..style.width = '10px' + ..style.height = '10px'; + domDocument.body!.append(sizeSource); + provider = CustomElementDimensionsProvider(sizeSource); + }); + + tearDown(() { + provider.close(); // cleanup + sizeSource.remove(); + }); + + test('from viewport physical size (simulated keyboard) - always zero', () { + // Simulate a 100px tall keyboard showing... + const double dpr = 2.5; + window.debugOverrideDevicePixelRatio(dpr); + const double keyboardGap = 100; + final double physicalHeight = + (domWindow.visualViewport!.height! + keyboardGap) * dpr; + + final WindowPadding computed = + provider.computeKeyboardInsets(physicalHeight, false); + + expect(computed.top, 0); + expect(computed.right, 0); + expect(computed.bottom, 0); + expect(computed.left, 0); + }); + }); + + group('onResize Stream', () { + late CustomElementDimensionsProvider provider; + + setUp(() async { + sizeSource + ..style.width = '10px' + ..style.height = '10px'; + domDocument.body!.append(sizeSource); + provider = CustomElementDimensionsProvider(sizeSource); + // Let the DOM settle before starting the test, so we don't get the first + // 10,10 Size in the test. Otherwise, the ResizeObserver may trigger + // unexpectedly after the test has started, and break our "first" result. + await Future.delayed(const Duration(milliseconds: 250)); + }); + + tearDown(() { + provider.close(); // cleanup + sizeSource.remove(); + }); + + test('funnels resize events on sizeSource', () async { + final Future event = provider.onResize.first; + final Future> events = provider.onResize.take(3).toList(); + + // The resize observer fires asynchronously, so we wait a little between + // resizes, so the observer has time to fire events separately. + await Future.delayed(const Duration(milliseconds: 100), () { + sizeSource + ..style.width = '100px' + ..style.height = '100px'; + }); + + await Future.delayed(const Duration(milliseconds: 100), () { + sizeSource + ..style.width = '200px' + ..style.height = '200px'; + }); + + await Future.delayed(const Duration(milliseconds: 100), () { + sizeSource + ..style.width = '300px' + ..style.height = '300px'; + }); + + // Let the DOM settle so the observer reports the last 300x300 mutation... + await Future.delayed(const Duration(milliseconds: 100)); + + expect(event, completion(const ui.Size(100, 100))); + expect(events, completes); + expect( + events, + completion(const [ + ui.Size(100, 100), + ui.Size(200, 200), + ui.Size(300, 300), + ])); + }); + + test('closed by onHotRestart', () async { + // Register an onDone listener for the stream + final Completer completer = Completer(); + provider.onResize.listen(null, onDone: () { + completer.complete(true); + }); + + // Should close the stream + provider.close(); + + sizeSource + ..style.width = '100px' + ..style.height = '100px'; + // Give time to the mutationObserver to fire (if needed, it won't) + await Future.delayed(const Duration(milliseconds: 100)); + + expect(provider.onResize.isEmpty, completion(isTrue)); + expect(completer.future, completion(isTrue)); + }); + }); +} diff --git a/lib/web_ui/test/engine/view_embedder/dimensions_provider/dimensions_provider_test.dart b/lib/web_ui/test/engine/view_embedder/dimensions_provider/dimensions_provider_test.dart new file mode 100644 index 0000000000000..8edfe33233d51 --- /dev/null +++ b/lib/web_ui/test/engine/view_embedder/dimensions_provider/dimensions_provider_test.dart @@ -0,0 +1,48 @@ +// 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. + +@TestOn('browser') + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine/dom.dart'; +import 'package:ui/src/engine/view_embedder/dimensions_provider/custom_element_dimensions_provider.dart'; +import 'package:ui/src/engine/view_embedder/dimensions_provider/dimensions_provider.dart'; +import 'package:ui/src/engine/view_embedder/dimensions_provider/full_page_dimensions_provider.dart'; +import 'package:ui/src/engine/window.dart'; + +void main() { + internalBootstrapBrowserTest(() => doTests); +} + +void doTests() { + group('Factory', () { + test('Creates a FullPage instance when hostElement is null', () async { + final DimensionsProvider provider = DimensionsProvider.create(); + + expect(provider, isA()); + }); + + test('Creates a CustomElement instance when hostElement is not null', + () async { + final DomElement element = createDomElement('some-random-element'); + final DimensionsProvider provider = DimensionsProvider.create( + hostElement: element, + ); + + expect(provider, isA()); + }); + }); + + group('getDevicePixelRatio', () { + test('Returns the correct pixelRatio', () async { + // Override the DPI to something known, but weird... + window.debugOverrideDevicePixelRatio(33930); + + final DimensionsProvider provider = DimensionsProvider.create(); + + expect(provider.getDevicePixelRatio(), 33930); + }); + }); +} diff --git a/lib/web_ui/test/engine/view_embedder/dimensions_provider/full_page_dimensions_provider_test.dart b/lib/web_ui/test/engine/view_embedder/dimensions_provider/full_page_dimensions_provider_test.dart new file mode 100644 index 0000000000000..aadbf6813f7c9 --- /dev/null +++ b/lib/web_ui/test/engine/view_embedder/dimensions_provider/full_page_dimensions_provider_test.dart @@ -0,0 +1,107 @@ +// 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. + +@TestOn('browser') + +import 'dart:async'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine/dom.dart'; +import 'package:ui/src/engine/view_embedder/dimensions_provider/full_page_dimensions_provider.dart'; +import 'package:ui/src/engine/window.dart'; +import 'package:ui/ui.dart' as ui show Size; + +void main() { + internalBootstrapBrowserTest(() => doTests); +} + +void doTests() { + group('computePhysicalSize', () { + late FullPageDimensionsProvider provider; + + setUp(() { + provider = FullPageDimensionsProvider(); + }); + + test('returns visualViewport physical size (width * dpr)', () { + const double dpr = 2.5; + window.debugOverrideDevicePixelRatio(dpr); + final ui.Size expected = ui.Size(domWindow.visualViewport!.width! * dpr, + domWindow.visualViewport!.height! * dpr); + + final ui.Size computed = provider.computePhysicalSize(); + + expect(computed, expected); + }); + }); + + group('computeKeyboardInsets', () { + late FullPageDimensionsProvider provider; + + setUp(() { + provider = FullPageDimensionsProvider(); + }); + + test('from viewport physical size (simulated keyboard)', () { + // Simulate a 100px tall keyboard showing... + const double dpr = 2.5; + window.debugOverrideDevicePixelRatio(dpr); + const double keyboardGap = 100; + final double physicalHeight = + (domWindow.visualViewport!.height! + keyboardGap) * dpr; + const double expectedBottom = keyboardGap * dpr; + + final WindowPadding computed = + provider.computeKeyboardInsets(physicalHeight, false); + + expect(computed.top, 0); + expect(computed.right, 0); + expect(computed.bottom, expectedBottom); + expect(computed.left, 0); + }); + }); + + group('onResize Stream', () { + // Needed to synthesize "resize" events + final DomEventTarget resizeEventTarget = + domWindow.visualViewport ?? domWindow; + + late FullPageDimensionsProvider provider; + + setUp(() { + provider = FullPageDimensionsProvider(); + }); + + test('funnels resize events on resizeEventTarget', () { + final Future event = provider.onResize.first; + + final Future> events = provider.onResize.take(3).toList(); + + resizeEventTarget.dispatchEvent(createDomEvent('Event', 'resize')); + resizeEventTarget.dispatchEvent(createDomEvent('Event', 'resize')); + resizeEventTarget.dispatchEvent(createDomEvent('Event', 'resize')); + + expect(event, completes); + expect(events, completes); + expect(events, completion(hasLength(3))); + }); + + test('closed by onHotRestart', () { + // Register an onDone listener for the stream + final Completer completer = Completer(); + provider.onResize.listen(null, onDone: () { + completer.complete(true); + }); + + // Should close the stream + provider.close(); + + resizeEventTarget.dispatchEvent(createDomEvent('Event', 'resize')); + + expect(provider.onResize.isEmpty, completion(isTrue)); + expect(completer.future, completion(isTrue)); + }); + }); +} diff --git a/lib/web_ui/test/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy_test.dart b/lib/web_ui/test/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy_test.dart new file mode 100644 index 0000000000000..75af6a0359e70 --- /dev/null +++ b/lib/web_ui/test/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy_test.dart @@ -0,0 +1,124 @@ +// 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. + +@TestOn('browser') + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine/dom.dart'; +import 'package:ui/src/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy.dart'; + +void main() { + internalBootstrapBrowserTest(() => doTests); +} + +void doTests() { + late CustomElementEmbeddingStrategy strategy; + late DomElement target; + + group('initialize', () { + setUp(() { + target = createDomElement('this-is-the-target'); + domDocument.body!.append(target); + strategy = CustomElementEmbeddingStrategy(target); + }); + + tearDown(() { + target.remove(); + }); + + test('Prepares target environment', () { + strategy.initialize( + hostElementAttributes: { + 'key-for-testing': 'value-for-testing', + }, + ); + + expect(target.getAttribute('key-for-testing'), 'value-for-testing', + reason: + 'Should add attributes as key=value into target element.'); + expect(target.getAttribute('flt-embedding'), 'custom-element', + reason: + 'Should identify itself as a specific key=value into the target element.'); + }); + }); + + group('attachGlassPane', () { + setUp(() { + target = createDomElement('this-is-the-target'); + domDocument.body!.append(target); + strategy = CustomElementEmbeddingStrategy(target); + strategy.initialize(); + }); + + tearDown(() { + target.remove(); + }); + + test('Should attach glasspane into embedder target (body)', () async { + final DomElement glassPane = createDomElement('some-tag-for-tests'); + final DomCSSStyleDeclaration style = glassPane.style; + + expect(glassPane.isConnected, isFalse); + expect(style.position, '', + reason: 'Should not have any specific position.'); + expect(style.width, '', reason: 'Should not have any size set.'); + + strategy.attachGlassPane(glassPane); + + // Assert injection into + expect(glassPane.isConnected, isTrue, + reason: 'Should inject glassPane into the document.'); + expect(glassPane.parent, target, + reason: 'Should inject glassPane into the target element'); + + final DomCSSStyleDeclaration styleAfter = glassPane.style; + + // Assert required styling to cover the viewport + expect(styleAfter.position, 'relative', + reason: 'Should be relatively positioned.'); + expect(styleAfter.display, 'block', reason: 'Should be display:block.'); + expect(styleAfter.width, '100%', + reason: 'Should take 100% of the available width'); + expect(styleAfter.height, '100%', + reason: 'Should take 100% of the available height'); + expect(styleAfter.overflow, 'hidden', + reason: 'Should hide the occasional oversized canvas elements.'); + }); + }); + + group('attachResourcesHost', () { + late DomElement glassPane; + + setUp(() { + target = createDomElement('this-is-the-target'); + glassPane = createDomElement('woah-a-glasspane'); + domDocument.body!.append(target); + strategy = CustomElementEmbeddingStrategy(target); + strategy.initialize(); + strategy.attachGlassPane(glassPane); + }); + + tearDown(() { + target.remove(); + }); + + test( + 'Should attach resources host into target (body), `nextTo` other element', + () async { + final DomElement resources = createDomElement('resources-host-element'); + + expect(resources.isConnected, isFalse); + + strategy.attachResourcesHost(resources, nextTo: glassPane); + + expect(resources.isConnected, isTrue, + reason: 'Should inject resources host somewhere in the document.'); + expect(resources.parent, target, + reason: 'Should inject the resources into the target element'); + expect(resources.nextSibling, glassPane, + reason: 'Should be injected `nextTo` the passed element.'); + }); + }); +} diff --git a/lib/web_ui/test/engine/view_embedder/embedding_strategy/embedding_strategy_test.dart b/lib/web_ui/test/engine/view_embedder/embedding_strategy/embedding_strategy_test.dart new file mode 100644 index 0000000000000..d17c1e54c48cb --- /dev/null +++ b/lib/web_ui/test/engine/view_embedder/embedding_strategy/embedding_strategy_test.dart @@ -0,0 +1,58 @@ +// 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. + +@TestOn('browser') + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine/dom.dart'; +import 'package:ui/src/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy.dart'; +import 'package:ui/src/engine/view_embedder/embedding_strategy/embedding_strategy.dart'; +import 'package:ui/src/engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart'; + +import '../hot_restart_cache_handler_test.dart' show getDomCache; + +void main() { + internalBootstrapBrowserTest(() => doTests); +} + +void doTests() { + group('Factory', () { + test('Creates a FullPage instance when hostElement is null', () async { + final EmbeddingStrategy strategy = EmbeddingStrategy.create(); + + expect(strategy, isA()); + }); + + test('Creates a CustomElement instance when hostElement is not null', + () async { + final DomElement element = createDomElement('some-random-element'); + final EmbeddingStrategy strategy = EmbeddingStrategy.create( + hostElement: element, + ); + + expect(strategy, isA()); + }); + }); + + group('registerElementForCleanup', () { + test('stores elements in a global domCache', () async { + final EmbeddingStrategy strategy = EmbeddingStrategy.create(); + + final DomElement toBeCached = createDomElement('some-element-to-cache'); + final DomElement other = createDomElement('other-element-to-cache'); + final DomElement another = createDomElement('another-element-to-cache'); + + strategy.registerElementForCleanup(toBeCached); + strategy.registerElementForCleanup(other); + strategy.registerElementForCleanup(another); + + final List cache = getDomCache()!; + + expect(cache, hasLength(3)); + expect(cache.first, toBeCached); + expect(cache.last, another); + }); + }); +} diff --git a/lib/web_ui/test/engine/view_embedder/embedding_strategy/full_page_embedding_strategy_test.dart b/lib/web_ui/test/engine/view_embedder/embedding_strategy/full_page_embedding_strategy_test.dart new file mode 100644 index 0000000000000..d05effb9b3f54 --- /dev/null +++ b/lib/web_ui/test/engine/view_embedder/embedding_strategy/full_page_embedding_strategy_test.dart @@ -0,0 +1,132 @@ +// 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. + +@TestOn('browser') + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine/dom.dart'; +import 'package:ui/src/engine/view_embedder/embedding_strategy/full_page_embedding_strategy.dart'; + +void main() { + internalBootstrapBrowserTest(() => doTests); +} + +void doTests() { + late FullPageEmbeddingStrategy strategy; + late DomElement target; + + group('initialize', () { + setUp(() { + strategy = FullPageEmbeddingStrategy(); + target = domDocument.body!; + final DomHTMLMetaElement meta = createDomHTMLMetaElement(); + meta + ..id = 'my_viewport_meta_for_testing' + ..name = 'viewport' + ..content = 'width=device-width, initial-scale=1.0, ' + 'maximum-scale=1.0, user-scalable=no'; + domDocument.head!.append(meta); + }); + + test('Prepares target environment', () { + DomElement? userMeta = + domDocument.querySelector('#my_viewport_meta_for_testing'); + + expect(userMeta, isNotNull); + + strategy.initialize( + hostElementAttributes: { + 'key-for-testing': 'value-for-testing', + }, + ); + + expect(target.getAttribute('key-for-testing'), 'value-for-testing', + reason: + 'Should add attributes as key=value into target element.'); + expect(target.getAttribute('flt-embedding'), 'full-page', + reason: + 'Should identify itself as a specific key=value into the target element.'); + + // Locate the viewport metas again... + userMeta = domDocument.querySelector('#my_viewport_meta_for_testing'); + + final DomElement? flutterMeta = + domDocument.querySelector('meta[name="viewport"]'); + + expect(userMeta, isNull, + reason: 'Should delete previously existing viewport meta tags.'); + expect(flutterMeta, isNotNull); + expect(flutterMeta!.hasAttribute('flt-viewport'), isTrue, + reason: 'Should install flutter viewport meta tag.'); + }); + }); + + group('attachGlassPane', () { + setUp(() { + strategy = FullPageEmbeddingStrategy(); + strategy.initialize(); + }); + + test('Should attach glasspane into embedder target (body)', () async { + final DomElement glassPane = createDomElement('some-tag-for-tests'); + final DomCSSStyleDeclaration style = glassPane.style; + + expect(glassPane.isConnected, isFalse); + expect(style.position, '', + reason: 'Should not have any specific position.'); + expect(style.top, '', + reason: + 'Should not have any top/right/bottom/left positioning/inset.'); + + strategy.attachGlassPane(glassPane); + + // Assert injection into + expect(glassPane.isConnected, isTrue, + reason: 'Should inject glassPane into the document.'); + expect(glassPane.parent, domDocument.body, + reason: 'Should inject glassPane into the '); + + final DomCSSStyleDeclaration styleAfter = glassPane.style; + + // Assert required styling to cover the viewport + expect(styleAfter.position, 'absolute', + reason: 'Should be absolutely positioned.'); + expect(styleAfter.top, '0px', reason: 'Should cover the whole viewport.'); + expect(styleAfter.right, '0px', + reason: 'Should cover the whole viewport.'); + expect(styleAfter.bottom, '0px', + reason: 'Should cover the whole viewport.'); + expect(styleAfter.left, '0px', + reason: 'Should cover the whole viewport.'); + }); + }); + + group('attachResourcesHost', () { + late DomElement glassPane; + setUp(() { + glassPane = createDomElement('some-tag-for-tests'); + strategy = FullPageEmbeddingStrategy(); + strategy.initialize(); + strategy.attachGlassPane(glassPane); + }); + + test( + 'Should attach resources host into target (body), `nextTo` other element', + () async { + final DomElement resources = createDomElement('resources-host-element'); + + expect(resources.isConnected, isFalse); + + strategy.attachResourcesHost(resources, nextTo: glassPane); + + expect(resources.isConnected, isTrue, + reason: 'Should inject resources host somewhere in the document.'); + expect(resources.parent, domDocument.body, + reason: 'Should inject resources host into the '); + expect(resources.nextSibling, glassPane, + reason: 'Should be injected `nextTo` the passed element.'); + }); + }); +} diff --git a/lib/web_ui/test/engine/view_embedder/hot_restart_cache_handler_test.dart b/lib/web_ui/test/engine/view_embedder/hot_restart_cache_handler_test.dart new file mode 100644 index 0000000000000..6ebc7134087a7 --- /dev/null +++ b/lib/web_ui/test/engine/view_embedder/hot_restart_cache_handler_test.dart @@ -0,0 +1,82 @@ +// 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. + +@TestOn('browser') + +import 'package:js/js_util.dart'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine/dom.dart'; +import 'package:ui/src/engine/view_embedder/hot_restart_cache_handler.dart'; + +void main() { + internalBootstrapBrowserTest(() => doTests); +} + +void doTests() { + group('Constructor', () { + test('Creates a cache in the JS environment', () async { + final HotRestartCacheHandler cache = HotRestartCacheHandler(); + + expect(cache, isNotNull); + + final List? domCache = getDomCache(); + + expect(domCache, isNotNull); + expect(domCache, isEmpty); + }); + }); + + group('registerElement', () { + HotRestartCacheHandler? cache; + List? domCache; + + setUp(() { + cache = HotRestartCacheHandler(); + domCache = getDomCache(); + }); + + test('Registers an element in the DOM cache', () async { + final DomElement element = createDomElement('for-test'); + cache!.registerElement(element); + + expect(domCache, hasLength(1)); + expect(domCache!.last, element); + }); + + test('Registers elements in the DOM cache', () async { + final DomElement element = createDomElement('for-test'); + domDocument.body!.append(element); + + cache!.registerElement(element); + + expect(domCache, hasLength(1)); + expect(domCache!.last, element); + }); + + test('Clears registered elements from the DOM and the cache upon restart', + () async { + final DomElement element = createDomElement('for-test'); + final DomElement element2 = createDomElement('for-test-two'); + domDocument.body!.append(element); + domDocument.body!.append(element2); + + cache!.registerElement(element); + + expect(element.isConnected, isTrue); + expect(element2.isConnected, isTrue); + + // Simulate a hot restart... + cache = HotRestartCacheHandler(); + + expect(domCache, hasLength(0)); + expect(element.isConnected, isFalse); // Removed + expect(element2.isConnected, isTrue); + }); + }); +} + +List? getDomCache() => getProperty?>( + domWindow, HotRestartCacheHandler.defaultCacheName);