From 6ea7e483933c3f7615d85da7c26fdda0814088f5 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Wed, 7 Apr 2021 15:34:13 -0700 Subject: [PATCH 01/37] Wrap all the contents of the glass-pane in a shadow root. --- lib/web_ui/lib/src/engine/dom_renderer.dart | 60 ++++++++++++++------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/lib/web_ui/lib/src/engine/dom_renderer.dart b/lib/web_ui/lib/src/engine/dom_renderer.dart index cb7825938d282..3f3a14b7e86bb 100644 --- a/lib/web_ui/lib/src/engine/dom_renderer.dart +++ b/lib/web_ui/lib/src/engine/dom_renderer.dart @@ -154,6 +154,9 @@ class DomRenderer { html.Element? get glassPaneElement => _glassPaneElement; html.Element? _glassPaneElement; + // The ShadowRoot of the [glassPaneElement]. + html.ShadowRoot? _glassPaneElementShadowRoot; + final html.Element rootElement = html.document.body!; void addElementClass(html.Element element, String className) { @@ -252,11 +255,8 @@ class DomRenderer { static const String defaultCssFont = '$defaultFontStyle $defaultFontWeight ${defaultFontSize}px $defaultFontFamily'; - void reset() { - _styleElement?.remove(); - _styleElement = html.StyleElement(); - html.document.head!.append(_styleElement!); - final html.CssStyleSheet sheet = _styleElement!.sheet as html.CssStyleSheet; + // Applies the required global CSS to an incoming [html.CssStyleSheet] `sheet`. + void _applyCssRulesToSheet(html.CssStyleSheet sheet) { final bool isWebKit = browserEngine == BrowserEngine.webkit; final bool isFirefox = browserEngine == BrowserEngine.firefox; // TODO(butterfly): use more efficient CSS selectors; descendant selectors @@ -360,6 +360,16 @@ flt-glass-pane * { } ''', sheet.cssRules.length); } + } + + void reset() { + final bool isWebKit = browserEngine == BrowserEngine.webkit; + + _styleElement?.remove(); + _styleElement = html.StyleElement(); + html.document.head!.append(_styleElement!); + final html.CssStyleSheet sheet = _styleElement!.sheet as html.CssStyleSheet; + _applyCssRulesToSheet(sheet); final html.BodyElement bodyElement = html.document.body!; @@ -440,9 +450,28 @@ flt-glass-pane * { ..right = '0' ..bottom = '0' ..left = '0'; + + // Create a Shadow Root under the glass panel, and attach everything there, + // instead of directly underneath the glass panel. + final html.ShadowRoot glassPaneElementShadowRoot = glassPaneElement.attachShadow({ + 'mode': 'open', + 'delegatesFocus': 'true', + }); + + _glassPaneElementShadowRoot = glassPaneElementShadowRoot; + bodyElement.append(glassPaneElement); - _sceneHostElement = createElement('flt-scene-host'); + final html.StyleElement shadowRootStyleElement = html.StyleElement(); + glassPaneElementShadowRoot.append(shadowRootStyleElement); + + final html.CssStyleSheet shadowRootStyleSheet = shadowRootStyleElement.sheet as html.CssStyleSheet; + _applyCssRulesToSheet(shadowRootStyleSheet); // TODO: Apply only rules for the shadow root + + // Don't allow the scene to receive pointer events. + _sceneHostElement = createElement('flt-scene-host') + ..style + .pointerEvents = 'none'; final html.Element semanticsHostElement = createElement('flt-semantics-host'); @@ -451,23 +480,16 @@ flt-glass-pane * { ..transformOrigin = '0 0 0'; _semanticsHostElement = semanticsHostElement; updateSemanticsScreenProperties(); - glassPaneElement.append(semanticsHostElement); - - // Don't allow the scene to receive pointer events. - _sceneHostElement!.style.pointerEvents = 'none'; - - glassPaneElement.append(_sceneHostElement!); - final html.Element _accesibilityPlaceholder = EngineSemanticsOwner + final html.Element _accessibilityPlaceholder = EngineSemanticsOwner .instance.semanticsHelper .prepareAccessibilityPlaceholder(); - // Insert the semantics placeholder after the scene host. For all widgets - // in the scene, except for platform widgets, the scene host will pass the - // pointer events through to the semantics tree. However, for platform - // views, the pointer events will not pass through, and will be handled - // by the platform view. - glassPaneElement.insertBefore(_accesibilityPlaceholder, _sceneHostElement); + glassPaneElementShadowRoot.nodes.addAll([ + semanticsHostElement, + _accessibilityPlaceholder, + _sceneHostElement!, + ]); // When debugging semantics, make the scene semi-transparent so that the // semantics tree is visible. From 61ed8617d8a125bb5f6f00d29bed6ac2d829c5d3 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Wed, 7 Apr 2021 17:27:03 -0700 Subject: [PATCH 02/37] Make analyzer happy --- lib/web_ui/lib/src/engine/dom_renderer.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/web_ui/lib/src/engine/dom_renderer.dart b/lib/web_ui/lib/src/engine/dom_renderer.dart index 3f3a14b7e86bb..ce2f881fd74f9 100644 --- a/lib/web_ui/lib/src/engine/dom_renderer.dart +++ b/lib/web_ui/lib/src/engine/dom_renderer.dart @@ -155,7 +155,7 @@ class DomRenderer { html.Element? _glassPaneElement; // The ShadowRoot of the [glassPaneElement]. - html.ShadowRoot? _glassPaneElementShadowRoot; + // html.ShadowRoot? _glassPaneElementShadowRoot; // unused externally, for now... final html.Element rootElement = html.document.body!; @@ -458,11 +458,12 @@ flt-glass-pane * { 'delegatesFocus': 'true', }); - _glassPaneElementShadowRoot = glassPaneElementShadowRoot; + // _glassPaneElementShadowRoot = glassPaneElementShadowRoot; bodyElement.append(glassPaneElement); final html.StyleElement shadowRootStyleElement = html.StyleElement(); + // The shadowRootStyleElement must be appended to the DOM, or its `sheet` will be null later... glassPaneElementShadowRoot.append(shadowRootStyleElement); final html.CssStyleSheet shadowRootStyleSheet = shadowRootStyleElement.sheet as html.CssStyleSheet; From 5e1720db9d281a29f069db7dfb8ab71675ee9d14 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Fri, 9 Apr 2021 17:14:54 -0700 Subject: [PATCH 03/37] Tweak semantics_test so it is shadow-DOM aware. --- .../test/engine/semantics/semantics_test.dart | 48 +++++++++++-------- .../engine/semantics/semantics_tester.dart | 25 +++++++++- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart index 96c04c12984a6..ec6a16e7c5fff 100644 --- a/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart = 2.6 +// @dart = 2.9 @TestOn('chrome || safari || firefox') import 'dart:async'; @@ -88,9 +88,12 @@ void _testEngineSemanticsOwner() { expect(semantics().semanticsEnabled, false); // Synthesize a click on the placeholder. + final root = getRootDocument(); final html.Element placeholder = - html.document.querySelectorAll('flt-semantics-placeholder').single; + root.querySelector('flt-semantics-placeholder'); + expect(placeholder.isConnected, true); + final html.Rectangle rect = placeholder.getBoundingClientRect(); placeholder.dispatchEvent(html.MouseEvent( 'click', @@ -353,10 +356,10 @@ void _testContainer() { '''); - final html.Element parentElement = - html.document.querySelector('flt-semantics'); + final root = getRootDocument(); + final html.Element parentElement = root.querySelector('flt-semantics'); final html.Element container = - html.document.querySelector('flt-semantics-container'); + root.querySelector('flt-semantics-container'); if (isMacOrIOS) { expect(parentElement.style.top, '0px'); @@ -401,10 +404,10 @@ void _testContainer() { '''); - final html.Element parentElement = - html.document.querySelector('flt-semantics'); + final root = getRootDocument(); + final html.Element parentElement = root.querySelector('flt-semantics'); final html.Element container = - html.document.querySelector('flt-semantics-container'); + root.querySelector('flt-semantics-container'); expect(parentElement.style.transform, 'matrix(1, 0, 0, 1, 10, 10)'); expect(parentElement.style.transformOrigin, '0px 0px 0px'); @@ -446,10 +449,12 @@ void _testContainer() { '''); } - final html.Element parentElement = - html.document.querySelector('flt-semantics'); + + final root = getRootDocument(); + final html.Element parentElement = root.querySelector('flt-semantics'); final html.Element container = - html.document.querySelector('flt-semantics-container'); + root.querySelector('flt-semantics-container'); + if (isMacOrIOS) { expect(parentElement.style.top, '0px'); expect(parentElement.style.left, '0px'); @@ -804,8 +809,8 @@ void _testIncrementables() { '''); - final html.InputElement input = - html.document.querySelectorAll('input').single; + final root = getRootDocument(); + final html.InputElement input = root.querySelector('input'); input.value = '2'; input.dispatchEvent(html.Event('change')); @@ -839,8 +844,8 @@ void _testIncrementables() { '''); - final html.InputElement input = - html.document.querySelectorAll('input').single; + final root = getRootDocument(); + final html.InputElement input = root.querySelector('input'); input.value = '0'; input.dispatchEvent(html.Event('change')); @@ -933,20 +938,21 @@ void _testTextField() { semantics().updateSemantics(builder.build()); - final html.Element textField = html.document - .querySelectorAll('input[data-semantics-role="text-field"]') - .single; + final root = getRootDocument(); + + final html.Element textField = + root.querySelector('input[data-semantics-role="text-field"]'); - expect(html.document.activeElement, isNot(textField)); + expect(root.activeElement, isNot(textField)); textField.focus(); - expect(html.document.activeElement, textField); + expect(root.activeElement, textField); expect(await logger.idLog.first, 0); expect(await logger.actionLog.first, ui.SemanticsAction.tap); semantics().semanticsEnabled = false; - }, // TODO(nurhan): https://github.com/flutter/flutter/issues/46638 + }, // TODO(nurhan): https://github.com/flutter/flutter/issues/46638 // TODO(nurhan): https://github.com/flutter/flutter/issues/50590 // TODO(nurhan): https://github.com/flutter/flutter/issues/50754 skip: (browserEngine != BrowserEngine.blink)); diff --git a/lib/web_ui/test/engine/semantics/semantics_tester.dart b/lib/web_ui/test/engine/semantics/semantics_tester.dart index 2b0b6fdb06d7d..0a50563c93312 100644 --- a/lib/web_ui/test/engine/semantics/semantics_tester.dart +++ b/lib/web_ui/test/engine/semantics/semantics_tester.dart @@ -12,6 +12,27 @@ import 'package:ui/ui.dart' as ui; import '../../matchers.dart'; +/// Gets the root document or shadow root where the Flutter app is being rendered. +/// +/// This function returns the correct root for the flutter app under testing, +/// instead of hardcoding html.document across the test. +/// +/// The root of a normal flutter app used to be html.document, but now that the +/// whole app is wrapped in a Shadow DOM, that's not the case anymore. +/// +/// A [html.ShadowRoot] quacks very similarly to a [html.Document], but unfortunately +/// they don't share any class/implement any interface that let us use them interchangeably. +/// To do so, just: +/// +/// final root = getRootDocument(); +/// +/// So getRootDocument can be changed to return ShadowRoot or Document without +/// the need to modify your code. +/// +html.ShadowRoot getRootDocument() { + return html.document.querySelector('flt-glass-pane')!.shadowRoot!; +} + /// CSS style applied to the root of the semantics tree. // TODO(yjbanov): this should be handled internally by [expectSemanticsTree]. // No need for every test to inject it. @@ -336,14 +357,14 @@ class SemanticsTester { /// Verifies the HTML structure of the current semantics tree. void expectSemanticsTree(String semanticsHtml) { expect( - canonicalizeHtml(html.document.querySelector('flt-semantics')!.outerHtml!), + canonicalizeHtml(getRootDocument().querySelector('flt-semantics')!.outerHtml!), canonicalizeHtml(semanticsHtml), ); } /// Finds the first HTML element in the semantics tree used for scrolling. html.Element? findScrollable() { - return html.document.querySelectorAll('flt-semantics').cast().firstWhere( + return getRootDocument().querySelectorAll('flt-semantics').cast().firstWhere( (html.Element? element) => element!.style.overflow == 'hidden' || element.style.overflowY == 'scroll' || From 7b76403f89f4bb300a77158bc5a37eb77be7a809 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Mon, 12 Apr 2021 17:55:35 -0700 Subject: [PATCH 04/37] Render all text_editing primitives inside the new shadow root. --- .../lib/src/engine/text_editing/text_editing.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart index c07cc09bbd0c1..f6868aad243d8 100644 --- a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart +++ b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart @@ -26,6 +26,12 @@ const String transparentTextEditingClass = 'transparentTextEditing'; void _emptyCallback(dynamic _) {} +/// The root node that hosts all DOM required for text editing. +/// +/// This is something similar to [html.Document]. Currently, it's a [html.ShadowRoot]. +@visibleForTesting +html.ShadowRoot get textEditingRoot => domRenderer.glassPaneElement!.shadowRoot!; + /// These style attributes are constant throughout the life time of an input /// element. /// @@ -232,7 +238,7 @@ class EngineAutofillForm { void placeForm(html.HtmlElement mainTextEditingElement) { formElement.append(mainTextEditingElement); - domRenderer.glassPaneElement!.append(formElement); + textEditingRoot.append(formElement); } void storeForm() { @@ -832,7 +838,7 @@ abstract class DefaultTextEditingStrategy implements TextEditingStrategy { // DOM later, when the first location information arrived. // Otherwise, on Blink based Desktop browsers, the autofill menu appears // on top left of the screen. - domRenderer.glassPaneElement!.append(activeDomElement); + textEditingRoot.append(activeDomElement); _appendedToForm = false; } @@ -1207,7 +1213,7 @@ class AndroidTextEditingStrategy extends GloballyPositionedTextEditingStrategy { if (hasAutofillGroup) { placeForm(); } else { - domRenderer.glassPaneElement!.append(activeDomElement); + textEditingRoot.append(activeDomElement); } inputConfig.textCapitalization.setAutocapitalizeAttribute(activeDomElement); } From 0d117199680177d3268cc75cbd31cd81376687c1 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Mon, 12 Apr 2021 17:56:37 -0700 Subject: [PATCH 05/37] Tweak text_editing_test so it is shadow-DOM aware. --- lib/web_ui/test/text_editing_test.dart | 154 +++++++++++++------------ 1 file changed, 80 insertions(+), 74 deletions(-) diff --git a/lib/web_ui/test/text_editing_test.dart b/lib/web_ui/test/text_editing_test.dart index 394604ef1ffac..18ea00897c377 100644 --- a/lib/web_ui/test/text_editing_test.dart +++ b/lib/web_ui/test/text_editing_test.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart = 2.6 +// @dart = 2.9 import 'dart:async'; import 'dart:html'; import 'dart:js_util' as js_util; @@ -79,32 +79,38 @@ void testMain() { // The focus initially is on the body. expect(document.activeElement, document.body); + // The line below, initializes the whole flutter app structure. Up until now, + // the DOM is completely empty. editingStrategy.enable( singlelineConfig, onChange: trackEditingState, onAction: trackInputAction, ); + expect( - document.getElementsByTagName('input'), + textEditingRoot.querySelectorAll('input'), hasLength(1), ); - final InputElement input = document.getElementsByTagName('input')[0]; + final InputElement input = textEditingRoot.querySelector('input'); // Now the editing element should have focus. - expect(document.activeElement, input); + + expect(document.activeElement, domRenderer.glassPaneElement); + expect(textEditingRoot.activeElement, input); + expect(editingStrategy.domElement, input); expect(input.getAttribute('type'), null); - // Input is appended to the glass pane. - expect(domRenderer.glassPaneElement.contains(editingStrategy.domElement), - isTrue); + // Input is appended to the right point of the DOM. + expect(textEditingRoot.contains(editingStrategy.domElement), isTrue); editingStrategy.disable(); expect( - document.getElementsByTagName('input'), + textEditingRoot.querySelectorAll('input'), hasLength(0), ); // The focus is back to the body. expect(document.activeElement, document.body); + expect(textEditingRoot.activeElement, null); }); test('Respects read-only config', () { @@ -116,8 +122,8 @@ void testMain() { onChange: trackEditingState, onAction: trackInputAction, ); - expect(document.getElementsByTagName('input'), hasLength(1)); - final InputElement input = document.getElementsByTagName('input')[0]; + expect(textEditingRoot.querySelectorAll('input'), hasLength(1)); + final InputElement input = textEditingRoot.querySelector('input'); expect(editingStrategy.domElement, input); expect(input.getAttribute('readonly'), 'readonly'); @@ -133,8 +139,8 @@ void testMain() { onChange: trackEditingState, onAction: trackInputAction, ); - expect(document.getElementsByTagName('input'), hasLength(1)); - final InputElement input = document.getElementsByTagName('input')[0]; + expect(textEditingRoot.querySelectorAll('input'), hasLength(1)); + final InputElement input = textEditingRoot.querySelector('input'); expect(editingStrategy.domElement, input); expect(input.getAttribute('type'), 'password'); @@ -150,8 +156,8 @@ void testMain() { onChange: trackEditingState, onAction: trackInputAction, ); - expect(document.getElementsByTagName('input'), hasLength(1)); - final InputElement input = document.getElementsByTagName('input')[0]; + expect(textEditingRoot.querySelectorAll('input'), hasLength(1)); + final InputElement input = textEditingRoot.querySelector('input'); expect(editingStrategy.domElement, input); expect(input.getAttribute('autocorrect'), 'off'); @@ -167,8 +173,8 @@ void testMain() { onChange: trackEditingState, onAction: trackInputAction, ); - expect(document.getElementsByTagName('input'), hasLength(1)); - final InputElement input = document.getElementsByTagName('input')[0]; + expect(textEditingRoot.querySelectorAll('input'), hasLength(1)); + final InputElement input = textEditingRoot.querySelector('input'); expect(editingStrategy.domElement, input); expect(input.getAttribute('autocorrect'), 'on'); @@ -224,18 +230,18 @@ void testMain() { onChange: trackEditingState, onAction: trackInputAction, ); - expect(document.getElementsByTagName('textarea'), hasLength(1)); + expect(textEditingRoot.querySelectorAll('textarea'), hasLength(1)); final TextAreaElement textarea = - document.getElementsByTagName('textarea')[0]; + textEditingRoot.querySelector('textarea'); // Now the textarea should have focus. - expect(document.activeElement, textarea); + expect(textEditingRoot.activeElement, textarea); expect(editingStrategy.domElement, textarea); textarea.value = 'foo\nbar'; textarea.dispatchEvent(Event.eventType('Event', 'input')); textarea.setSelectionRange(4, 6); - textarea.dispatchEvent(Event.eventType('Event', 'selectionchange')); + document.dispatchEvent(Event.eventType('Event', 'selectionchange')); // Can read textarea state correctly (and preserves new lines). expect( lastEditingState, @@ -249,9 +255,9 @@ void testMain() { editingStrategy.disable(); // The textarea should be cleaned up. - expect(document.getElementsByTagName('textarea'), hasLength(0)); + expect(textEditingRoot.querySelectorAll('textarea'), hasLength(0)); // The focus is back to the body. - expect(document.activeElement, document.body); + expect(textEditingRoot.activeElement, null); // There should be no input action. expect(lastInputAction, isNull); @@ -268,13 +274,13 @@ void testMain() { onChange: trackEditingState, onAction: trackInputAction, ); - expect(document.getElementsByTagName('input'), hasLength(1)); - expect(document.getElementsByTagName('textarea'), hasLength(0)); + expect(textEditingRoot.querySelectorAll('input'), hasLength(1)); + expect(textEditingRoot.querySelectorAll('textarea'), hasLength(0)); // Disable and check that all DOM elements were removed. editingStrategy.disable(); - expect(document.getElementsByTagName('input'), hasLength(0)); - expect(document.getElementsByTagName('textarea'), hasLength(0)); + expect(textEditingRoot.querySelectorAll('input'), hasLength(0)); + expect(textEditingRoot.querySelectorAll('textarea'), hasLength(0)); // Use multi-line config and expect an `