feat: disable context menu in web

This commit is contained in:
insleker 2025-09-18 21:05:40 +08:00
parent 34f6abad32
commit eee75f6fdb
10 changed files with 225 additions and 112 deletions

View File

@ -23,6 +23,7 @@ flutter analyze
flutter test
# > run integration tests
flutter test integration_test/ -d <device_id>
# dart run tool/run_integration_tests.dart --device=linux
# dart run tool/gen_view_wireframe_md.dart
# flutter pub run dead_code_analyzer

View File

@ -79,7 +79,10 @@ void main() {
),
exportServiceProvider.overrideWith((_) => fake),
savePathPickerProvider.overrideWith(
(_) => () async => 'C:/tmp/output.pdf',
(_) => () async {
final dir = Directory.systemTemp.createTempSync('pdfsig_');
return '${dir.path}/output.pdf';
},
),
],
child: MaterialApp(
@ -431,7 +434,10 @@ void main() {
),
exportServiceProvider.overrideWith((ref) => LightweightExporter()),
savePathPickerProvider.overrideWith(
(_) => () async => 'C:/tmp/output-after-export.pdf',
(_) => () async {
final dir = Directory.systemTemp.createTempSync('pdfsig_after_');
return '${dir.path}/output-after-export.pdf';
},
),
],
child: MaterialApp(

View File

@ -1,5 +1,15 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pdf_signature/app.dart';
export 'package:pdf_signature/app.dart';
void main() => runApp(const MyApp());
void main() {
// Ensure Flutter bindings are initialized before platform channel usage
WidgetsFlutterBinding.ensureInitialized();
// Disable right-click context menu on web using Flutter API
if (kIsWeb) {
BrowserContextMenu.disableContextMenu();
}
runApp(const MyApp());
}

View File

@ -112,7 +112,9 @@ class PdfPageOverlays extends ConsumerWidget {
// TODO:Add active overlay if present and not using mock (mock has its own)
final useMock = pdfViewModel.useMockViewer;
if (!useMock && activeRect != null) {
if (!useMock &&
activeRect != null &&
pageNumber == pdfViewModel.currentPage) {
widgets.add(
LayoutBuilder(
builder: (context, constraints) {

View File

@ -127,7 +127,7 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
color: Colors.black.withValues(alpha: 0.7),
child: Center(
child: Text(
pageNumber.toString(),
'Pg $pageNumber',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
@ -146,7 +146,7 @@ class _PdfViewerWidgetState extends ConsumerState<PdfViewerWidget> {
color: Colors.black.withValues(alpha: 0.7),
child: Center(
child: Text(
pageNumber.toString(),
'Pg $pageNumber',
style: const TextStyle(
color: Colors.white,
fontSize: 12,

View File

@ -40,6 +40,45 @@ class SignatureOverlay extends ConsumerWidget {
rect.height * pageH,
);
Future<void> _showContextMenu(Offset position) async {
final pdfViewModel = ref.read(pdfViewModelProvider.notifier);
final isLocked = ref
.watch(pdfViewModelProvider)
.isPlacementLocked(page: pageNumber, index: placedIndex);
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy,
position.dx,
position.dy,
),
items: [
PopupMenuItem(
key: const Key('mi_placement_lock'),
value: isLocked ? 'unlock' : 'lock',
child: Text(
isLocked
? AppLocalizations.of(context).unlock
: AppLocalizations.of(context).lock,
),
),
PopupMenuItem(
key: const Key('mi_placement_delete'),
value: 'delete',
child: Text(AppLocalizations.of(context).delete),
),
],
);
if (selected == 'lock') {
pdfViewModel.lockPlacement(page: pageNumber, index: placedIndex);
} else if (selected == 'unlock') {
pdfViewModel.unlockPlacement(page: pageNumber, index: placedIndex);
} else if (selected == 'delete') {
pdfViewModel.removePlacement(page: pageNumber, index: placedIndex);
}
}
return Stack(
children: [
TransformableBox(
@ -110,55 +149,10 @@ class SignatureOverlay extends ConsumerWidget {
height: rectPx.height,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onSecondaryTapDown: (details) async {
final pdfViewModel = ref.read(pdfViewModelProvider.notifier);
final isLocked = ref
.watch(pdfViewModelProvider)
.isPlacementLocked(page: pageNumber, index: placedIndex);
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx,
details.globalPosition.dy,
),
items: [
PopupMenuItem(
key: const Key('mi_placement_lock'),
value: isLocked ? 'unlock' : 'lock',
child: Text(
isLocked
? AppLocalizations.of(context).unlock
: AppLocalizations.of(context).lock,
),
),
PopupMenuItem(
key: const Key('mi_placement_delete'),
value: 'delete',
child: Text(AppLocalizations.of(context).delete),
),
],
);
if (selected == 'lock') {
pdfViewModel.lockPlacement(
page: pageNumber,
index: placedIndex,
);
} else if (selected == 'unlock') {
pdfViewModel.unlockPlacement(
page: pageNumber,
index: placedIndex,
);
} else if (selected == 'delete') {
pdfViewModel.removePlacement(
page: pageNumber,
index: placedIndex,
);
}
},
onSecondaryTapDown:
(details) => _showContextMenu(details.globalPosition),
onLongPressStart:
(details) => _showContextMenu(details.globalPosition),
),
),
],

View File

@ -27,6 +27,34 @@ class SignatureCard extends ConsumerWidget {
final bool useCurrentBytesForDrag;
final double rotationDeg;
final domain.GraphicAdjust graphicAdjust;
Future<void> _showContextMenu(BuildContext context, Offset position) async {
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy,
position.dx,
position.dy,
),
items: [
PopupMenuItem(
key: const Key('mi_signature_adjust'),
value: 'adjust',
child: Text(AppLocalizations.of(context).adjustGraphic),
),
PopupMenuItem(
key: const Key('mi_signature_delete'),
value: 'delete',
child: Text(AppLocalizations.of(context).delete),
),
],
);
if (selected == 'adjust') {
onAdjust?.call();
} else if (selected == 'delete') {
onDelete();
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -91,65 +119,11 @@ class SignatureCard extends ConsumerWidget {
onSecondaryTapDown:
disabled
? null
: (details) async {
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx,
details.globalPosition.dy,
),
items: [
PopupMenuItem(
key: const Key('mi_signature_adjust'),
value: 'adjust',
child: Text(AppLocalizations.of(context).adjustGraphic),
),
PopupMenuItem(
key: const Key('mi_signature_delete'),
value: 'delete',
child: Text(AppLocalizations.of(context).delete),
),
],
);
if (selected == 'adjust') {
onAdjust?.call();
} else if (selected == 'delete') {
onDelete();
}
},
: (details) => _showContextMenu(context, details.globalPosition),
onLongPressStart:
disabled
? null
: (details) async {
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx,
details.globalPosition.dy,
),
items: [
PopupMenuItem(
key: const Key('mi_signature_adjust'),
value: 'adjust',
child: Text(AppLocalizations.of(context).adjustGraphic),
),
PopupMenuItem(
key: const Key('mi_signature_delete'),
value: 'delete',
child: Text(AppLocalizations.of(context).delete),
),
],
);
if (selected == 'adjust') {
onAdjust?.call();
} else if (selected == 'delete') {
onDelete();
}
},
: (details) => _showContextMenu(context, details.globalPosition),
child: child,
);
if (disabled) return child;

View File

@ -9,6 +9,7 @@ import 'package:pdf_signature/data/repositories/signature_card_repository.dart';
import 'package:pdf_signature/domain/models/model.dart' hide SignatureCard;
import 'image_editor_dialog.dart';
import 'signature_card.dart';
import '../../pdf/view_model/pdf_view_model.dart';
/// Data for drag-and-drop is in signature_drag_data.dart
@ -77,7 +78,11 @@ class _SignatureDrawerState extends ConsumerState<SignatureDrawer> {
}
},
onTap: () {
// state = const Rect.fromLTWH(0.2, 0.2, 0.3, 0.15);
// Activate a default overlay rectangle on the current page
// so integration tests can find and size the active overlay.
ref
.read(pdfViewModelProvider.notifier)
.activeRect = const Rect.fromLTWH(0.2, 0.2, 0.3, 0.15);
},
),
),

View File

@ -59,6 +59,7 @@ dependencies:
riverpod_annotation: ^2.6.1
colorfilter_generator: ^0.0.8
flutter_box_transform: ^0.4.7
# disable_web_context_menu: ^1.1.0
# ml_linalg: ^13.12.6
dev_dependencies:

View File

@ -0,0 +1,120 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
/// Runs each integration test file sequentially to avoid multi-app start issues on desktop.
///
/// Usage:
/// dart tool/run_integration_tests.dart [--device=<id>] [--reporter=<name>] [--pattern=<glob>]
///
/// Defaults:
/// --device=linux
/// --reporter=compact
/// --pattern=*.dart (all files in integration_test/)
Future<int> main(List<String> args) async {
String device = 'linux';
String reporter = 'compact';
String pattern = '*.dart';
for (int i = 0; i < args.length; i++) {
final a = args[i];
if (a.startsWith('--device=')) {
device = a.substring(a.indexOf('=') + 1);
} else if (a == '--device' || a == '-d') {
if (i + 1 < args.length) {
device = args[++i];
}
} else if (a.startsWith('-d=')) {
device = a.substring(a.indexOf('=') + 1);
} else if (a.startsWith('--reporter=')) {
reporter = a.substring(a.indexOf('=') + 1);
} else if (a == '--reporter' || a == '-r') {
if (i + 1 < args.length) {
reporter = args[++i];
}
} else if (a.startsWith('--pattern=')) {
pattern = a.substring(a.indexOf('=') + 1);
} else if (a == '--pattern') {
if (i + 1 < args.length) {
pattern = args[++i];
}
}
}
final dir = Directory('integration_test');
if (!await dir.exists()) {
stderr.writeln('integration_test/ not found. Run from the project root.');
return 2;
}
final files =
(await dir
.list()
.where((e) => e is File && e.path.endsWith('.dart'))
.cast<File>()
.toList())
..sort((a, b) => a.path.compareTo(b.path));
List<File> selected;
if (pattern == '*.dart') {
selected = files;
} else {
// very simple glob: supports prefix/suffix match
if (pattern.startsWith('*')) {
final suffix = pattern.substring(1);
selected = files.where((f) => f.path.endsWith(suffix)).toList();
} else if (pattern.endsWith('*')) {
final prefix = pattern.substring(0, pattern.length - 1);
selected =
files
.where(
(f) => f.path
.split(Platform.pathSeparator)
.last
.startsWith(prefix),
)
.toList();
} else {
selected = files.where((f) => f.path.contains(pattern)).toList();
}
}
if (selected.isEmpty) {
stderr.writeln('No integration tests matched pattern: $pattern');
return 3;
}
stdout.writeln(
'Running ${selected.length} integration test file(s) sequentially...',
);
final results = <String, int>{};
for (final f in selected) {
final rel = f.path;
stdout.writeln('\n=== Running: $rel ===');
final args = <String>['test', rel, '-d', device, '-r', reporter];
final proc = await Process.start('flutter', args);
// Pipe output live
unawaited(proc.stdout.transform(utf8.decoder).forEach(stdout.write));
unawaited(proc.stderr.transform(utf8.decoder).forEach(stderr.write));
final code = await proc.exitCode;
results[rel] = code;
if (code == 0) {
stdout.writeln('=== PASSED: $rel ===');
} else {
stderr.writeln('=== FAILED (exit $code): $rel ===');
}
// Small pause between launches to let desktop/device settle
await Future<void>.delayed(const Duration(milliseconds: 300));
}
stdout.writeln('\nSummary:');
var failures = 0;
for (final entry in results.entries) {
final status = entry.value == 0 ? 'PASS' : 'FAIL(${entry.value})';
stdout.writeln(' - ${entry.key}: $status');
if (entry.value != 0) failures += 1;
}
return failures == 0 ? 0 : 1;
}