Unverified Commit 2f4e9536 authored by chunhtai's avatar chunhtai Committed by GitHub

Implements browser context menu in selectable region (#108909)

parent ddc08cf5
// 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();
}
// 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: <Widget>[
const Positioned.fill(
child: HtmlElementView(
viewType: _viewType,
),
),
child,
],
);
}
}
// 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';
......@@ -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<SelectableRegion> 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<SelectableRegion> 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<SelectableRegion> with TextSelectionD
child: Focus(
includeSemantics: false,
focusNode: widget.focusNode,
child: SelectionContainer(
registrar: this,
delegate: _selectionDelegate,
child: widget.child,
),
child: result,
),
),
),
......
......@@ -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';
......
// 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<RenderSelectionSpy>(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<VoidCallback> listeners = <VoidCallback>{};
List<SelectionEvent> events = <SelectionEvent>[];
@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) { }
}
......@@ -1237,6 +1237,16 @@ class RenderSelectionSpy extends RenderProxyBox
final Set<VoidCallback> listeners = <VoidCallback>{};
List<SelectionEvent> events = <SelectionEvent>[];
@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);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment