mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-11-03 16:32:22 +00:00
fix(web): preserve peer card clicks with semantics enabled (#16709)
* fix(web): preserve peer card clicks with semantics enabled Give the More menu semantic tap and double-click actions, and expose multi-selection through separate checkboxes. Exclude redundant outer desktop web tap semantics while preserving card-body double-click connections. Cover mouse single/double clicks and semantic activation under the parent card gesture detector. Signed-off-by: fufesou <linlong1266@gmail.com> * fix(web): reuse accessible More control in portrait cards Signed-off-by: fufesou <linlong1266@gmail.com> * fix(web): keep peer More taps immediate and cancellable Signed-off-by: fufesou <linlong1266@gmail.com> * fix(web): keep keyboard wrapper out of accessibility focus Signed-off-by: fufesou <linlong1266@gmail.com> --------- Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
1 parent
3cb027a372
commit
eba0a6e552
3 files changed
+128
-7
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
||||
@@ -62,6 +63,8 @@ class _PeerCardState extends State<_PeerCard>
|
||||
final PeerTabModel peerTabModel = Provider.of(context);
|
||||
final peer = super.widget.peer;
|
||||
return GestureDetector(
|
||||
// Web card actions have their own semantics; preserve pointer double-clicks.
|
||||
excludeFromSemantics: isWebDesktop,
|
||||
onDoubleTap: peerTabModel.multiSelectionMode
|
||||
? null
|
||||
: () => widget.connect(context, peer.id),
|
||||
@@ -423,7 +426,7 @@ class _PeerCardState extends State<_PeerCard>
|
||||
final PeerTabModel peerTabModel = Provider.of(context);
|
||||
final selected = peerTabModel.isPeerSelected(peer.id);
|
||||
if (peerTabModel.multiSelectionMode) {
|
||||
return Padding(
|
||||
final checkbox = Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: selected
|
||||
? Icon(
|
||||
@@ -432,7 +435,16 @@ class _PeerCardState extends State<_PeerCard>
|
||||
)
|
||||
: Icon(Icons.check_box_outline_blank),
|
||||
);
|
||||
return isWebDesktop
|
||||
? Semantics(
|
||||
container: true,
|
||||
label: formatID(peer.id),
|
||||
checked: selected,
|
||||
onTap: () => peerTabModel.select(peer),
|
||||
child: checkbox)
|
||||
: checkbox;
|
||||
} else {
|
||||
if (isWebDesktop) return _actionMoreWeb(peer);
|
||||
return InkWell(
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(12), child: Icon(Icons.more_vert)),
|
||||
@@ -457,19 +469,27 @@ class _PeerCardState extends State<_PeerCard>
|
||||
color: MyTheme.accent,
|
||||
)
|
||||
: Icon(Icons.check_box_outline_blank);
|
||||
final checkbox = isWebDesktop
|
||||
? Semantics(
|
||||
container: true,
|
||||
label: formatID(peer.id),
|
||||
checked: selected,
|
||||
onTap: () => peerTabModel.select(peer),
|
||||
child: icon)
|
||||
: icon;
|
||||
bool last = peerTabModel.isShiftDown && peer.id == peerTabModel.lastId;
|
||||
double right = isTile ? 4 : 0;
|
||||
if (last) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: MyTheme.accent, width: 1)),
|
||||
child: icon,
|
||||
child: checkbox,
|
||||
).marginOnly(right: right);
|
||||
} else {
|
||||
return icon.marginOnly(right: right);
|
||||
return checkbox.marginOnly(right: right);
|
||||
}
|
||||
} else {
|
||||
return _actionMore(peer);
|
||||
return isWebDesktop ? _actionMoreWeb(peer) : _actionMore(peer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,6 +502,20 @@ class _PeerCardState extends State<_PeerCard>
|
||||
onPointerUp: (_) => _showPeerMenu(peer.id),
|
||||
child: build_more(context));
|
||||
|
||||
Widget _actionMoreWeb(Peer peer) => Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
label: translate('More'),
|
||||
child: Builder(
|
||||
builder: (context) => build_more(context, onTap: () {
|
||||
final box = context.findRenderObject() as RenderBox;
|
||||
final position =
|
||||
box.localToGlobal(box.size.center(Offset.zero));
|
||||
_menuPos = RelativeRect.fromLTRB(
|
||||
position.dx, position.dy, position.dx, position.dy);
|
||||
_showPeerMenu(peer.id);
|
||||
})));
|
||||
|
||||
bool _shouldBuildPasswordIcon(Peer peer) {
|
||||
if (gFFI.peerTabModel.currentTab != PeerTabIndex.ab.index) return false;
|
||||
if (gFFI.abModel.current.isPersonal()) return false;
|
||||
@@ -1474,11 +1508,12 @@ Widget getOnline(double rightPadding, bool online) {
|
||||
radius: 3, backgroundColor: online ? Colors.green : kColorWarn)));
|
||||
}
|
||||
|
||||
Widget build_more(BuildContext context, {bool invert = false}) {
|
||||
Widget build_more(BuildContext context,
|
||||
{bool invert = false, VoidCallback? onTap}) {
|
||||
final RxBool hover = false.obs;
|
||||
return InkWell(
|
||||
final button = InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: () {},
|
||||
onTap: onTap ?? () {},
|
||||
onHover: (value) => hover.value = value,
|
||||
child: Obx(() => CircleAvatar(
|
||||
radius: 14,
|
||||
@@ -1498,6 +1533,19 @@ Widget build_more(BuildContext context, {bool invert = false}) {
|
||||
.titleLarge
|
||||
?.color
|
||||
?.withOpacity(0.5)))));
|
||||
if (onTap == null) return button;
|
||||
// Serial taps preserve cancellation without waiting for the card's double tap.
|
||||
return RawGestureDetector(
|
||||
excludeFromSemantics: true,
|
||||
gestures: {
|
||||
SerialTapGestureRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<SerialTapGestureRecognizer>(
|
||||
() => SerialTapGestureRecognizer(
|
||||
allowedButtonsFilter: (buttons) =>
|
||||
buttons == kPrimaryButton),
|
||||
(recognizer) => recognizer.onSerialTapUp = (_) => onTap()),
|
||||
},
|
||||
child: button);
|
||||
}
|
||||
|
||||
class TagPainter extends CustomPainter {
|
||||
|
||||
@@ -577,6 +577,8 @@ _registerEventHandler() {
|
||||
|
||||
Widget keyListenerBuilder(BuildContext context, Widget? child) {
|
||||
return RawKeyboardListener(
|
||||
// Keep this keyboard-only wrapper from taking focus away from web inputs.
|
||||
includeSemantics: !isWeb,
|
||||
// `skipTraversal: isWeb` is to fix "Bad state: RenderBox was not laid out: minified:aeL#c19e4"
|
||||
focusNode: FocusNode(skipTraversal: isWeb),
|
||||
child: child ?? Container(),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'dart:ui' show PointerDeviceKind, SemanticsAction;
|
||||
|
||||
import 'package:flutter/gestures.dart' show kSecondaryMouseButton;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common/widgets/peer_card.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
const doubleClickInterval = Duration(milliseconds: 75);
|
||||
const dragOffset = Offset(150, 0);
|
||||
// Keep pointer cases semantics-off to cover parent/child gesture competition.
|
||||
// VM taps bypass browser click-to-semantics conversion; verify that in a browser.
|
||||
for (final action in ['physical clicks', 'semantic tap', 'keyboard']) {
|
||||
testWidgets('More $action opens once without connecting the card',
|
||||
(tester) async {
|
||||
var opened = 0;
|
||||
var connected = 0;
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Material(
|
||||
child: GestureDetector(
|
||||
excludeFromSemantics: true,
|
||||
onTap: () {},
|
||||
onDoubleTap: () => connected++,
|
||||
onLongPress: () {},
|
||||
child: Builder(builder: (context) {
|
||||
return Center(
|
||||
child: build_more(context, onTap: () {
|
||||
opened++;
|
||||
showMenu<void>(
|
||||
context: context,
|
||||
position: RelativeRect.fill,
|
||||
items: const [PopupMenuItem(child: Text('Connect'))],
|
||||
);
|
||||
}));
|
||||
}),
|
||||
),
|
||||
),
|
||||
));
|
||||
final button = find.byIcon(Icons.more_vert);
|
||||
final position = tester.getCenter(button);
|
||||
if (action == 'semantic tap') {
|
||||
final node = tester.getSemantics(button);
|
||||
node.owner!.performAction(node.id, SemanticsAction.tap);
|
||||
} else if (action == 'keyboard') {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
} else {
|
||||
await tester.tapAt(position,
|
||||
kind: PointerDeviceKind.mouse, buttons: kSecondaryMouseButton);
|
||||
await tester.pumpAndSettle();
|
||||
expect(opened, 0, reason: 'A secondary click must not open More');
|
||||
await tester.dragFrom(position, dragOffset,
|
||||
kind: PointerDeviceKind.mouse);
|
||||
await tester.pumpAndSettle();
|
||||
expect(opened, 0, reason: 'Dragging out must cancel the More tap');
|
||||
await tester.tapAt(position, kind: PointerDeviceKind.mouse);
|
||||
}
|
||||
expect(opened, 1,
|
||||
reason: 'More must not wait for a possible double click');
|
||||
await tester.pump();
|
||||
if (action == 'physical clicks') {
|
||||
await tester.pump(doubleClickInterval);
|
||||
await tester.tapAt(position, kind: PointerDeviceKind.mouse);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
expect({'menu': opened, 'connection': connected},
|
||||
{'menu': 1, 'connection': 0});
|
||||
}, semanticsEnabled: action != 'physical clicks');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user