[web] Only create one <style> for SelectableRegion (#161682)
We used to create and insert a new `<style>` element for each `SelectableRegion` widget. That's unnecessary. All we need is one `<style>` element that contains the style sheets that we want to apply. Most of this PR is re-working the tests to be able to check that the issue is actually fixed. Fixes https://github.com/flutter/flutter/issues/161519
This commit is contained in:
parent
093485d91c
commit
996badc9cf
@ -46,6 +46,12 @@ class PlatformSelectableRegionContextMenu extends StatelessWidget {
|
||||
@visibleForTesting
|
||||
static RegisterViewFactory? debugOverrideRegisterViewFactory;
|
||||
|
||||
/// Resets the view factory registration to its initial state.
|
||||
@visibleForTesting
|
||||
static void debugResetRegistry() {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => throw UnimplementedError();
|
||||
}
|
||||
|
@ -75,6 +75,12 @@ class PlatformSelectableRegionContextMenu extends StatelessWidget {
|
||||
@visibleForTesting
|
||||
static RegisterViewFactory? debugOverrideRegisterViewFactory;
|
||||
|
||||
/// Resets the view factory registration to its initial state.
|
||||
@visibleForTesting
|
||||
static void debugResetRegistry() {
|
||||
_registeredViewType = null;
|
||||
}
|
||||
|
||||
// Registers the view factories for the interceptor widgets.
|
||||
static void _register() {
|
||||
assert(_registeredViewType == null);
|
||||
@ -104,21 +110,21 @@ class PlatformSelectableRegionContextMenu extends StatelessWidget {
|
||||
}
|
||||
|
||||
static String _registerWebSelectionCallback(_WebSelectionCallBack callback) {
|
||||
_registerViewFactory(_viewType, (int viewId) {
|
||||
// Create css style for _kClassName.
|
||||
final web.HTMLStyleElement styleElement =
|
||||
web.document.createElement('style') as web.HTMLStyleElement;
|
||||
web.document.head!.append(styleElement as JSAny);
|
||||
final web.CSSStyleSheet sheet = styleElement.sheet!;
|
||||
sheet.insertRule(_kClassRule, 0);
|
||||
sheet.insertRule(_kClassSelectionRule, 1);
|
||||
|
||||
_registerViewFactory(_viewType, (int viewId, {Object? params}) {
|
||||
final web.HTMLElement htmlElement = web.document.createElement('div') as web.HTMLElement;
|
||||
htmlElement
|
||||
..style.width = '100%'
|
||||
..style.height = '100%'
|
||||
..classList.add(_kClassName);
|
||||
|
||||
// Create css style for _kClassName.
|
||||
final web.HTMLStyleElement styleElement =
|
||||
web.document.createElement('style') as web.HTMLStyleElement;
|
||||
web.document.head!.append(styleElement as JSAny);
|
||||
final web.CSSStyleSheet sheet = styleElement.sheet!;
|
||||
sheet.insertRule(_kClassRule, 0);
|
||||
sheet.insertRule(_kClassSelectionRule, 1);
|
||||
|
||||
htmlElement.addEventListener(
|
||||
'mousedown',
|
||||
(web.Event event) {
|
||||
|
@ -8,7 +8,6 @@ library;
|
||||
import 'dart:async';
|
||||
import 'dart:ui_web' as ui_web;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/src/widgets/_html_element_view_web.dart'
|
||||
@ -17,6 +16,8 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
import 'web_platform_view_registry_utils.dart';
|
||||
|
||||
final Object _mockHtmlElement = Object();
|
||||
Object _mockViewFactory(int id, {Object? params}) {
|
||||
return _mockHtmlElement;
|
||||
@ -419,101 +420,3 @@ void main() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
typedef FakeViewFactory = ({String viewType, bool isVisible, Function viewFactory});
|
||||
|
||||
typedef FakePlatformView = ({int id, String viewType, Object? params, Object htmlElement});
|
||||
|
||||
class FakePlatformViewRegistry implements ui_web.PlatformViewRegistry {
|
||||
FakePlatformViewRegistry() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform_views,
|
||||
_onMethodCall,
|
||||
);
|
||||
}
|
||||
|
||||
Set<FakePlatformView> get views => Set<FakePlatformView>.unmodifiable(_views);
|
||||
final Set<FakePlatformView> _views = <FakePlatformView>{};
|
||||
|
||||
final Set<FakeViewFactory> _registeredViewTypes = <FakeViewFactory>{};
|
||||
|
||||
@override
|
||||
bool registerViewFactory(String viewType, Function viewFactory, {bool isVisible = true}) {
|
||||
if (_findRegisteredViewFactory(viewType) != null) {
|
||||
return false;
|
||||
}
|
||||
_registeredViewTypes.add((viewType: viewType, isVisible: isVisible, viewFactory: viewFactory));
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Object getViewById(int viewId) {
|
||||
return _findViewById(viewId)!.htmlElement;
|
||||
}
|
||||
|
||||
FakeViewFactory? _findRegisteredViewFactory(String viewType) {
|
||||
return _registeredViewTypes.singleWhereOrNull(
|
||||
(FakeViewFactory registered) => registered.viewType == viewType,
|
||||
);
|
||||
}
|
||||
|
||||
FakePlatformView? _findViewById(int viewId) {
|
||||
return _views.singleWhereOrNull((FakePlatformView view) => view.id == viewId);
|
||||
}
|
||||
|
||||
Future<dynamic> _onMethodCall(MethodCall call) {
|
||||
return switch (call.method) {
|
||||
'create' => _create(call),
|
||||
'dispose' => _dispose(call),
|
||||
_ => Future<dynamic>.sync(() => null),
|
||||
};
|
||||
}
|
||||
|
||||
Future<dynamic> _create(MethodCall call) async {
|
||||
final Map<dynamic, dynamic> args = call.arguments as Map<dynamic, dynamic>;
|
||||
final int id = args['id'] as int;
|
||||
final String viewType = args['viewType'] as String;
|
||||
final Object? params = args['params'];
|
||||
|
||||
if (_findViewById(id) != null) {
|
||||
throw PlatformException(
|
||||
code: 'error',
|
||||
message: 'Trying to create an already created platform view, view id: $id',
|
||||
);
|
||||
}
|
||||
|
||||
final FakeViewFactory? registered = _findRegisteredViewFactory(viewType);
|
||||
if (registered == null) {
|
||||
throw PlatformException(
|
||||
code: 'error',
|
||||
message: 'Trying to create a platform view of unregistered type: $viewType',
|
||||
);
|
||||
}
|
||||
|
||||
final ui_web.ParameterizedPlatformViewFactory viewFactory =
|
||||
registered.viewFactory as ui_web.ParameterizedPlatformViewFactory;
|
||||
|
||||
_views.add((
|
||||
id: id,
|
||||
viewType: viewType,
|
||||
params: params,
|
||||
htmlElement: viewFactory(id, params: params),
|
||||
));
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<dynamic> _dispose(MethodCall call) async {
|
||||
final int id = call.arguments as int;
|
||||
|
||||
final FakePlatformView? view = _findViewById(id);
|
||||
if (view == null) {
|
||||
throw PlatformException(
|
||||
code: 'error',
|
||||
message: 'Trying to dispose a platform view with unknown id: $id',
|
||||
);
|
||||
}
|
||||
|
||||
_views.remove(view);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -5,14 +5,15 @@
|
||||
@TestOn('browser') // This file contains web-only library.
|
||||
library;
|
||||
|
||||
import 'dart:js_interop';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
import 'web_platform_view_registry_utils.dart';
|
||||
|
||||
extension on web.HTMLCollection {
|
||||
Iterable<web.Element?> get iterable =>
|
||||
Iterable<web.Element?>.generate(length, (int index) => item(index));
|
||||
@ -24,47 +25,75 @@ extension on web.CSSRuleList {
|
||||
}
|
||||
|
||||
void main() {
|
||||
web.HTMLElement? element;
|
||||
PlatformSelectableRegionContextMenu.debugOverrideRegisterViewFactory = (
|
||||
String viewType,
|
||||
Object Function(int viewId) fn, {
|
||||
bool isVisible = true,
|
||||
}) {
|
||||
element = fn(0) as web.HTMLElement;
|
||||
// The element needs to be attached to the document body to receive mouse
|
||||
// events.
|
||||
web.document.body!.append(element! as JSAny);
|
||||
};
|
||||
// This force register the dom element.
|
||||
PlatformSelectableRegionContextMenu(child: const Placeholder());
|
||||
PlatformSelectableRegionContextMenu.debugOverrideRegisterViewFactory = null;
|
||||
late FakePlatformViewRegistry fakePlatformViewRegistry;
|
||||
|
||||
setUp(() {
|
||||
removeAllStyleElements();
|
||||
fakePlatformViewRegistry = FakePlatformViewRegistry();
|
||||
PlatformSelectableRegionContextMenu.debugOverrideRegisterViewFactory =
|
||||
fakePlatformViewRegistry.registerViewFactory;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PlatformSelectableRegionContextMenu.debugOverrideRegisterViewFactory = null;
|
||||
PlatformSelectableRegionContextMenu.debugResetRegistry();
|
||||
});
|
||||
|
||||
testWidgets('DOM element is set up correctly', (WidgetTester tester) async {
|
||||
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SelectableRegion(
|
||||
selectionControls: EmptyTextSelectionControls(),
|
||||
child: const Placeholder(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final web.HTMLElement element =
|
||||
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
|
||||
|
||||
test('DOM element is set up correctly', () async {
|
||||
expect(element, isNotNull);
|
||||
expect(element!.style.width, '100%');
|
||||
expect(element!.style.height, '100%');
|
||||
expect(element!.classList.length, 1);
|
||||
final String className = element!.className;
|
||||
expect(element.style.width, '100%');
|
||||
expect(element.style.height, '100%');
|
||||
expect(element.classList.length, 1);
|
||||
|
||||
expect(web.document.head!.children.iterable, isNotEmpty);
|
||||
bool foundStyle = false;
|
||||
for (final web.Element? element in web.document.head!.children.iterable) {
|
||||
expect(element, isNotNull);
|
||||
if (element!.tagName != 'STYLE') {
|
||||
continue;
|
||||
}
|
||||
final web.CSSRuleList? rules = (element as web.HTMLStyleElement).sheet?.rules;
|
||||
if (rules != null) {
|
||||
foundStyle = rules.iterable.any((web.CSSRule? rule) => rule!.cssText.contains(className));
|
||||
}
|
||||
if (foundStyle) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(foundStyle, isTrue);
|
||||
final int numberOfStyleElements = getNumberOfStyleElements();
|
||||
expect(numberOfStyleElements, 1);
|
||||
});
|
||||
|
||||
testWidgets('only one <style> is inserted into the DOM', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ListView(
|
||||
children: <Widget>[
|
||||
SelectableRegion(
|
||||
selectionControls: EmptyTextSelectionControls(),
|
||||
child: const Placeholder(),
|
||||
),
|
||||
SelectableRegion(
|
||||
selectionControls: EmptyTextSelectionControls(),
|
||||
child: const Placeholder(),
|
||||
),
|
||||
SelectableRegion(
|
||||
selectionControls: EmptyTextSelectionControls(),
|
||||
child: const Placeholder(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final int numberOfStyleElements = getNumberOfStyleElements();
|
||||
expect(numberOfStyleElements, 1);
|
||||
});
|
||||
|
||||
testWidgets('right click can trigger select word', (WidgetTester tester) async {
|
||||
final int currentViewId = platformViewsRegistry.getNextPlatformViewId();
|
||||
|
||||
final FocusNode focusNode = FocusNode();
|
||||
addTearDown(focusNode.dispose);
|
||||
final UniqueKey spy = UniqueKey();
|
||||
@ -77,13 +106,16 @@ void main() {
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final web.HTMLElement element =
|
||||
fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement;
|
||||
expect(element, isNotNull);
|
||||
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
// Dispatch right click.
|
||||
element!.dispatchEvent(
|
||||
element.dispatchEvent(
|
||||
web.MouseEvent('mousedown', web.MouseEventInit(button: 2, clientX: 200, clientY: 300)),
|
||||
);
|
||||
final RenderSelectionSpy renderSelectionSpy = tester.renderObject<RenderSelectionSpy>(
|
||||
@ -104,6 +136,36 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
void removeAllStyleElements() {
|
||||
final List<web.Element?> styles = web.document.head!.children.iterable.toList();
|
||||
for (final web.Element? element in styles) {
|
||||
if (element!.tagName == 'STYLE') {
|
||||
element.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int getNumberOfStyleElements() {
|
||||
expect(web.document.head!.children.iterable, isNotEmpty);
|
||||
|
||||
int count = 0;
|
||||
for (final web.Element? element in web.document.head!.children.iterable) {
|
||||
expect(element, isNotNull);
|
||||
if (element!.tagName != 'STYLE') {
|
||||
continue;
|
||||
}
|
||||
final web.CSSRuleList? rules = (element as web.HTMLStyleElement).sheet?.rules;
|
||||
if (rules != null) {
|
||||
if (rules.iterable.any(
|
||||
(web.CSSRule? rule) => rule!.cssText.contains('web-selectable-region-context-menu'),
|
||||
)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
class SelectionSpy extends LeafRenderObjectWidget {
|
||||
const SelectionSpy({super.key});
|
||||
|
||||
|
@ -0,0 +1,107 @@
|
||||
// Copyright 2014 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:ui_web' as ui_web;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
typedef FakeViewFactory = ({String viewType, bool isVisible, Function viewFactory});
|
||||
|
||||
typedef FakePlatformView = ({int id, String viewType, Object? params, Object htmlElement});
|
||||
|
||||
class FakePlatformViewRegistry implements ui_web.PlatformViewRegistry {
|
||||
FakePlatformViewRegistry() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform_views,
|
||||
_onMethodCall,
|
||||
);
|
||||
}
|
||||
|
||||
Set<FakePlatformView> get views => Set<FakePlatformView>.unmodifiable(_views);
|
||||
final Set<FakePlatformView> _views = <FakePlatformView>{};
|
||||
|
||||
final Set<FakeViewFactory> _registeredViewTypes = <FakeViewFactory>{};
|
||||
|
||||
@override
|
||||
bool registerViewFactory(String viewType, Function viewFactory, {bool isVisible = true}) {
|
||||
if (_findRegisteredViewFactory(viewType) != null) {
|
||||
return false;
|
||||
}
|
||||
_registeredViewTypes.add((viewType: viewType, isVisible: isVisible, viewFactory: viewFactory));
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Object getViewById(int viewId) {
|
||||
return _findViewById(viewId)!.htmlElement;
|
||||
}
|
||||
|
||||
FakeViewFactory? _findRegisteredViewFactory(String viewType) {
|
||||
return _registeredViewTypes.singleWhereOrNull(
|
||||
(FakeViewFactory registered) => registered.viewType == viewType,
|
||||
);
|
||||
}
|
||||
|
||||
FakePlatformView? _findViewById(int viewId) {
|
||||
return _views.singleWhereOrNull((FakePlatformView view) => view.id == viewId);
|
||||
}
|
||||
|
||||
Future<dynamic> _onMethodCall(MethodCall call) {
|
||||
return switch (call.method) {
|
||||
'create' => _create(call),
|
||||
'dispose' => _dispose(call),
|
||||
_ => Future<dynamic>.sync(() => null),
|
||||
};
|
||||
}
|
||||
|
||||
Future<dynamic> _create(MethodCall call) async {
|
||||
final Map<dynamic, dynamic> args = call.arguments as Map<dynamic, dynamic>;
|
||||
final int id = args['id'] as int;
|
||||
final String viewType = args['viewType'] as String;
|
||||
final Object? params = args['params'];
|
||||
|
||||
if (_findViewById(id) != null) {
|
||||
throw PlatformException(
|
||||
code: 'error',
|
||||
message: 'Trying to create an already created platform view, view id: $id',
|
||||
);
|
||||
}
|
||||
|
||||
final FakeViewFactory? registered = _findRegisteredViewFactory(viewType);
|
||||
if (registered == null) {
|
||||
throw PlatformException(
|
||||
code: 'error',
|
||||
message: 'Trying to create a platform view of unregistered type: $viewType',
|
||||
);
|
||||
}
|
||||
|
||||
final ui_web.ParameterizedPlatformViewFactory viewFactory =
|
||||
registered.viewFactory as ui_web.ParameterizedPlatformViewFactory;
|
||||
|
||||
_views.add((
|
||||
id: id,
|
||||
viewType: viewType,
|
||||
params: params,
|
||||
htmlElement: viewFactory(id, params: params),
|
||||
));
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<dynamic> _dispose(MethodCall call) async {
|
||||
final int id = call.arguments as int;
|
||||
|
||||
final FakePlatformView? view = _findViewById(id);
|
||||
if (view == null) {
|
||||
throw PlatformException(
|
||||
code: 'error',
|
||||
message: 'Trying to dispose a platform view with unknown id: $id',
|
||||
);
|
||||
}
|
||||
|
||||
_views.remove(view);
|
||||
return null;
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user