mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Implements browser context menu in selectable region (#108909)
This commit is contained in:
parent
ddc08cf537
commit
2f4e9536bf
@ -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();
|
||||
}
|
||||
@ -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: <Widget>[
|
||||
const Positioned.fill(
|
||||
child: HtmlElementView(
|
||||
viewType: _viewType,
|
||||
),
|
||||
),
|
||||
child,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
@ -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';
|
||||
|
||||
@ -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<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);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user