diff --git a/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_io.dart b/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_io.dart new file mode 100644 index 00000000000..5c66a372bb1 --- /dev/null +++ b/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_io.dart @@ -0,0 +1,41 @@ +// 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. + +// The widget in this file is an empty mock for non-web platforms. See +// `_platform_selectable_region_context_menu_web.dart` for the web +// implementation. + +import 'framework.dart'; +import 'selection_container.dart'; + +/// A widget that provides native selection context menu for its child subtree. +/// +/// This widget currently only supports Flutter web. Using this widget in non-web +/// platforms will throw [UnimplementedError]s. +/// +/// In web platform, this widget registers a singleton platform view, i.e. a +/// HTML DOM element. The created platform view will be shared between all +/// [PlatformSelectableRegionContextMenu]s. +/// +/// Only one [SelectionContainerDelegate] can attach to the +/// [PlatformSelectableRegionContextMenu] at a time. Use [attach] method to make +/// a [SelectionContainerDelegate] to be the active client. +class PlatformSelectableRegionContextMenu extends StatelessWidget { + /// Creates a [PlatformSelectableRegionContextMenu] + // ignore: prefer_const_constructors_in_immutables + PlatformSelectableRegionContextMenu({ + // ignore: avoid_unused_constructor_parameters + required Widget child, + super.key, + }); + + /// Attaches the `client` to be able to open platform-appropriate context menus. + static void attach(SelectionContainerDelegate client) => throw UnimplementedError(); + + /// Detaches the `client` from the platform-appropriate selection context menus. + static void detach(SelectionContainerDelegate client) => throw UnimplementedError(); + + @override + Widget build(BuildContext context) => throw UnimplementedError(); +} diff --git a/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_web.dart b/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_web.dart new file mode 100644 index 00000000000..be1eeda0257 --- /dev/null +++ b/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_web.dart @@ -0,0 +1,143 @@ +// 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:html' as html; +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; + +import 'basic.dart'; +import 'framework.dart'; +import 'platform_view.dart'; +import 'selection_container.dart'; + +const String _viewType = 'Browser__WebContextMenuViewType__'; +const String _kClassName = 'web-electable-region-context-menu'; +// These css rules hides the dom element with the class name. +const String _kClassSelectionRule = '.$_kClassName::selection { background: transparent; }'; +const String _kClassRule = ''' +.$_kClassName { + color: transparent; + user-select: text; + -webkit-user-select: text; /* Safari */ + -moz-user-select: text; /* Firefox */ + -ms-user-select: text; /* IE10+ */ +} +'''; +const int _kRightClickButton = 2; + +typedef _WebSelectionCallBack = void Function(html.Element, html.MouseEvent); + +/// Function signature for `ui.platformViewRegistry.registerViewFactory`. +@visibleForTesting +typedef RegisterViewFactory = void Function(String, Object Function(int viewId), {bool isVisible}); + +/// See `_platform_selectable_region_context_menu_io.dart` for full +/// documentation. +class PlatformSelectableRegionContextMenu extends StatelessWidget { + /// See `_platform_selectable_region_context_menu_io.dart`. + PlatformSelectableRegionContextMenu({ + required this.child, + super.key, + }) { + if (_registeredViewType == null) { + _register(); + } + } + + /// See `_platform_selectable_region_context_menu_io.dart`. + final Widget child; + + /// See `_platform_selectable_region_context_menu_io.dart`. + // ignore: use_setters_to_change_properties + static void attach(SelectionContainerDelegate client) { + _activeClient = client; + } + + /// See `_platform_selectable_region_context_menu_io.dart`. + static void detach(SelectionContainerDelegate client) { + if (_activeClient != client) { + _activeClient = null; + } + } + + static SelectionContainerDelegate? _activeClient; + + // Keeps track if this widget has already registered its view factories or not. + static String? _registeredViewType; + + /// See `_platform_selectable_region_context_menu_io.dart`. + @visibleForTesting + // ignore: undefined_prefixed_name, invalid_assignment, avoid_dynamic_calls + static RegisterViewFactory registerViewFactory = ui.platformViewRegistry.registerViewFactory; + + // Registers the view factories for the interceptor widgets. + static void _register() { + assert(_registeredViewType == null); + _registeredViewType = _registerWebSelectionCallback((html.Element element, html.MouseEvent event) { + final SelectionContainerDelegate? client = _activeClient; + if (client != null) { + // Converts the html right click event to flutter coordinate. + final Offset localOffset = Offset(event.offset.x.toDouble(), event.offset.y.toDouble()); + final Matrix4 transform = client.getTransformTo(null); + final Offset globalOffset = MatrixUtils.transformPoint(transform, localOffset); + client.dispatchSelectionEvent(SelectWordSelectionEvent(globalPosition: globalOffset)); + // The innerText must contain the text in order to be selected by + // the browser. + element.innerText = client.getSelectedContent()?.plainText ?? ''; + + // Programmatically select the dom element in browser. + final html.Range range = html.document.createRange(); + range.selectNode(element); + final html.Selection? selection = html.window.getSelection(); + if (selection != null) { + selection.removeAllRanges(); + selection.addRange(range); + } + } + }); + } + + static String _registerWebSelectionCallback(_WebSelectionCallBack callback) { + registerViewFactory(_viewType, (int viewId) { + final html.Element htmlElement = html.DivElement(); + htmlElement + ..style.width = '100%' + ..style.height = '100%' + ..classes.add(_kClassName); + + // Create css style for _kClassName. + final html.StyleElement styleElement = html.StyleElement(); + html.document.head!.append(styleElement); + final html.CssStyleSheet sheet = styleElement.sheet! as html.CssStyleSheet; + sheet.insertRule(_kClassRule, 0); + sheet.insertRule(_kClassSelectionRule, 1); + + htmlElement.onMouseDown.listen((html.MouseEvent event) { + if (event.button != _kRightClickButton) { + return; + } + callback(htmlElement, event); + }); + return htmlElement; + }, isVisible: false); + return _viewType; + } + + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + const Positioned.fill( + child: HtmlElementView( + viewType: _viewType, + ), + ), + child, + ], + ); + } +} diff --git a/packages/flutter/lib/src/widgets/platform_selectable_region_context_menu.dart b/packages/flutter/lib/src/widgets/platform_selectable_region_context_menu.dart new file mode 100644 index 00000000000..295f0a33c87 --- /dev/null +++ b/packages/flutter/lib/src/widgets/platform_selectable_region_context_menu.dart @@ -0,0 +1,5 @@ +// 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. + +export '_platform_selectable_region_context_menu_io.dart' if(dart.library.html) '_platform_selectable_region_context_menu_web.dart'; diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart index 5554a9f8dfb..d8623e03654 100644 --- a/packages/flutter/lib/src/widgets/selectable_region.dart +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -20,6 +20,7 @@ import 'framework.dart'; import 'gesture_detector.dart'; import 'media_query.dart'; import 'overlay.dart'; +import 'platform_selectable_region_context_menu.dart'; import 'selection_container.dart'; import 'text_editing_intents.dart'; import 'text_selection.dart'; @@ -288,8 +289,14 @@ class _SelectableRegionState extends State with TextSelectionD void _handleFocusChanged() { if (!widget.focusNode.hasFocus) { + if (kIsWeb) { + PlatformSelectableRegionContextMenu.detach(_selectionDelegate); + } _clearSelection(); } + if (kIsWeb) { + PlatformSelectableRegionContextMenu.attach(_selectionDelegate); + } } void _updateSelectionStatus() { @@ -867,6 +874,16 @@ class _SelectableRegionState extends State with TextSelectionD @override Widget build(BuildContext context) { assert(debugCheckHasOverlay(context)); + Widget result = SelectionContainer( + registrar: this, + delegate: _selectionDelegate, + child: widget.child, + ); + if (kIsWeb) { + result = PlatformSelectableRegionContextMenu( + child: result, + ); + } return CompositedTransformTarget( link: _toolbarLayerLink, child: RawGestureDetector( @@ -878,11 +895,7 @@ class _SelectableRegionState extends State with TextSelectionD child: Focus( includeSemantics: false, focusNode: widget.focusNode, - child: SelectionContainer( - registrar: this, - delegate: _selectionDelegate, - child: widget.child, - ), + child: result, ), ), ), diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index b071b66a215..af5295394c8 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -87,6 +87,7 @@ export 'src/widgets/pages.dart'; export 'src/widgets/performance_overlay.dart'; export 'src/widgets/placeholder.dart'; export 'src/widgets/platform_menu_bar.dart'; +export 'src/widgets/platform_selectable_region_context_menu.dart'; export 'src/widgets/platform_view.dart'; export 'src/widgets/preferred_size.dart'; export 'src/widgets/primary_scroll_controller.dart'; diff --git a/packages/flutter/test/widgets/selectable_region_context_menu_test.dart b/packages/flutter/test/widgets/selectable_region_context_menu_test.dart new file mode 100644 index 00000000000..1be9360de30 --- /dev/null +++ b/packages/flutter/test/widgets/selectable_region_context_menu_test.dart @@ -0,0 +1,172 @@ +// 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. + +// ignore_for_file: undefined_class, undefined_getter, undefined_setter + +@TestOn('browser') // This file contains web-only library. + +import 'dart:html' as html; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + html.Element? element; + final RegisterViewFactory originalFactory = PlatformSelectableRegionContextMenu.registerViewFactory; + PlatformSelectableRegionContextMenu.registerViewFactory = (String viewType, Object Function(int viewId) fn, {bool isVisible = true}) { + element = fn(0) as html.Element; + // The element needs to be attached to the document body to receive mouse + // events. + html.document.body!.append(element!); + }; + // This force register the dom element. + PlatformSelectableRegionContextMenu(child: const Placeholder()); + PlatformSelectableRegionContextMenu.registerViewFactory = originalFactory; + + test('DOM element is set up correctly', () async { + expect(element, isNotNull); + expect(element!.style.width, '100%'); + expect(element!.style.height, '100%'); + expect(element!.classes.length, 1); + final String className = element!.classes.first; + + expect(html.document.head!.children, isNotEmpty); + bool foundStyle = false; + for (final html.Element element in html.document.head!.children) { + if (element is! html.StyleElement) { + continue; + } + final html.CssStyleSheet sheet = element.sheet! as html.CssStyleSheet; + foundStyle = sheet.rules!.any((html.CssRule rule) => rule.cssText!.contains(className)); + } + expect(foundStyle, isTrue); + }); + + testWidgets('right click can trigger select word', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + final UniqueKey spy = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + focusNode: focusNode, + selectionControls: materialTextSelectionControls, + child: SelectionSpy(key: spy), + ), + ) + ); + expect(element, isNotNull); + + focusNode.requestFocus(); + await tester.pump(); + + // Dispatch right click. + element!.dispatchEvent( + html.MouseEvent( + 'mousedown', + button: 2, + clientX: 200, + clientY: 300, + ), + ); + final RenderSelectionSpy renderSelectionSpy = tester.renderObject(find.byKey(spy)); + expect(renderSelectionSpy.events, isNotEmpty); + + SelectWordSelectionEvent? selectWordEvent; + for (final SelectionEvent event in renderSelectionSpy.events) { + if (event is SelectWordSelectionEvent) { + selectWordEvent = event; + break; + } + } + expect(selectWordEvent, isNotNull); + expect((selectWordEvent!.globalPosition.dx - 200).abs() < precisionErrorTolerance, isTrue); + expect((selectWordEvent.globalPosition.dy - 300).abs() < precisionErrorTolerance, isTrue); + }); +} + +class SelectionSpy extends LeafRenderObjectWidget { + const SelectionSpy({ + super.key, + }); + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderSelectionSpy( + SelectionContainer.maybeOf(context), + ); + } + + @override + void updateRenderObject(BuildContext context, covariant RenderObject renderObject) { } +} + +class RenderSelectionSpy extends RenderProxyBox + with Selectable, SelectionRegistrant { + RenderSelectionSpy( + SelectionRegistrar? registrar, + ) { + this.registrar = registrar; + } + + final Set listeners = {}; + List events = []; + + @override + Size get size => _size; + Size _size = Size.zero; + + @override + Size computeDryLayout(BoxConstraints constraints) { + _size = Size(constraints.maxWidth, constraints.maxHeight); + return _size; + } + + @override + void addListener(VoidCallback listener) => listeners.add(listener); + + @override + void removeListener(VoidCallback listener) => listeners.remove(listener); + + @override + SelectionResult dispatchSelectionEvent(SelectionEvent event) { + events.add(event); + return SelectionResult.end; + } + + @override + SelectedContent? getSelectedContent() { + return const SelectedContent(plainText: 'content'); + } + + @override + SelectionGeometry get value => _value; + SelectionGeometry _value = SelectionGeometry( + hasContent: true, + status: SelectionStatus.uncollapsed, + startSelectionPoint: const SelectionPoint( + localPosition: Offset.zero, + lineHeight: 0.0, + handleType: TextSelectionHandleType.left, + ), + endSelectionPoint: const SelectionPoint( + localPosition: Offset.zero, + lineHeight: 0.0, + handleType: TextSelectionHandleType.left, + ), + ); + set value(SelectionGeometry other) { + if (other == _value) { + return; + } + _value = other; + for (final VoidCallback callback in listeners) { + callback(); + } + } + + @override + void pushHandleLayers(LayerLink? startHandle, LayerLink? endHandle) { } +} diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart index 9c753bb549b..1e404412edc 100644 --- a/packages/flutter/test/widgets/selectable_region_test.dart +++ b/packages/flutter/test/widgets/selectable_region_test.dart @@ -1237,6 +1237,16 @@ class RenderSelectionSpy extends RenderProxyBox final Set listeners = {}; List events = []; + @override + Size get size => _size; + Size _size = Size.zero; + + @override + Size computeDryLayout(BoxConstraints constraints) { + _size = Size(constraints.maxWidth, constraints.maxHeight); + return _size; + } + @override void addListener(VoidCallback listener) => listeners.add(listener);