Add Ctrl/Cmd+O open shortcut and bulk copy-slides-to-another-deck

- Bind Ctrl/Cmd+O app-wide to the open-presentation dialog (was unbound).
- Add a "copy to another deck" bulk action to the slide list: with multiple
  slides selected, pick a target open tab; the slides are appended there as
  fresh copies, leaving the source deck untouched. Multi-select, bulk delete
  and bulk skip/show already existed.

Test: cross-deck copy keeps the source intact and assigns fresh ids.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brenno de Winter 2026-06-03 21:56:51 +02:00
parent e63679978b
commit 01cc1c2ecd
3 changed files with 119 additions and 2 deletions

View file

@ -299,6 +299,12 @@ class _AppShellState extends ConsumerState<AppShell> with WindowListener {
);
}
/// Open een presentatie via de zoek-/kies-dialoog. App-breed zodat Ctrl/Cmd+O
/// altijd werkt, ongeacht waar de focus zit.
void _openActive() {
_openWithSearch(context, ref, ref.read(settingsProvider).homeDirectory);
}
bool _dragging = false;
static const _imageExtensions = {
@ -370,6 +376,9 @@ class _AppShellState extends ConsumerState<AppShell> with WindowListener {
const SingleActivator(LogicalKeyboardKey.keyS, control: true):
_saveActive,
const SingleActivator(LogicalKeyboardKey.keyS, meta: true): _saveActive,
const SingleActivator(LogicalKeyboardKey.keyO, control: true):
_openActive,
const SingleActivator(LogicalKeyboardKey.keyO, meta: true): _openActive,
},
child: FocusScope(
autofocus: true,

View file

@ -7,6 +7,7 @@ import '../../models/slide.dart';
import '../../state/deck_provider.dart';
import '../../state/editor_provider.dart';
import '../../state/settings_provider.dart';
import '../../state/tabs_provider.dart';
import '../../services/image_service.dart';
import '../../services/slide_rasterizer.dart';
import '../../state/slide_clipboard_provider.dart';
@ -174,8 +175,7 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
} catch (_) {}
if (!mounted) return;
final ok =
bytes != null &&
await ImageService().copyImageBytesToClipboard(bytes);
bytes != null && await ImageService().copyImageBytesToClipboard(bytes);
if (!mounted) return;
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
@ -187,6 +187,78 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
);
}
/// De geselecteerde slides, op volgorde van positie in het deck.
List<Slide> _selectedSlides(Deck deck) {
final indices = ref.read(editorProvider).selection.toList()..sort();
return [
for (final i in indices)
if (i >= 0 && i < deck.slides.length) deck.slides[i],
];
}
/// Kopieer de geselecteerde slides (bulk) naar een ander open deck. Toont een
/// keuzelijst van de overige open tabbladen; de slides worden achteraan dat
/// deck toegevoegd (met nieuwe id's, zodat het kopieën zijn).
Future<void> _copySelectionToOtherDeck() async {
final deck = ref.read(deckProvider).deck;
if (deck == null) return;
final slides = _selectedSlides(deck);
if (slides.isEmpty) return;
final tabs = ref.read(tabsProvider);
final currentId = tabs.current?.id;
final targets = tabs.tabs
.where((t) => t.id != currentId && t.isOpen)
.toList();
final messenger = ScaffoldMessenger.of(context);
if (targets.isEmpty) {
messenger.showSnackBar(
const SnackBar(
content: Text('Geen ander deck open. Open eerst een ander tabblad.'),
),
);
return;
}
final target = await showDialog<TabInfo>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(
slides.length == 1
? '1 slide kopiëren naar…'
: '${slides.length} slides kopiëren naar…',
),
children: [
for (final t in targets)
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, t),
child: Row(
children: [
const Icon(Icons.slideshow_outlined, size: 16),
const SizedBox(width: 8),
Expanded(child: Text(t.label)),
],
),
),
],
),
);
if (target == null || !mounted) return;
final at = target.deckNotifier.insertSlides(slides);
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(
at >= 0
? '${slides.length} slide(s) gekopieerd naar “${target.label}”.'
: 'Kopiëren mislukt.',
),
),
);
}
/// Verwijder alle geselecteerde slides (bulk). Houdt minstens één over.
void _deleteSelection() {
final deck = ref.read(deckProvider).deck;
@ -498,6 +570,7 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
const SizedBox(height: 6),
_BulkActionBar(
count: editor.selection.length,
onCopyToDeck: _copySelectionToOtherDeck,
onDelete: _deleteSelection,
onSkip: () => notifier.setSkippedForSlides(
editor.selection,
@ -780,6 +853,7 @@ class _SkipBanner extends StatelessWidget {
class _BulkActionBar extends StatelessWidget {
final int count;
final VoidCallback onCopyToDeck;
final VoidCallback onDelete;
final VoidCallback onSkip;
final VoidCallback onShow;
@ -787,6 +861,7 @@ class _BulkActionBar extends StatelessWidget {
const _BulkActionBar({
required this.count,
required this.onCopyToDeck,
required this.onDelete,
required this.onSkip,
required this.onShow,
@ -814,6 +889,11 @@ class _BulkActionBar extends StatelessWidget {
),
),
),
_BulkIcon(
icon: Icons.drive_file_move_outline,
tooltip: 'Kopiëren naar ander deck',
onTap: onCopyToDeck,
),
_BulkIcon(
icon: Icons.visibility_off_outlined,
tooltip: 'Overslaan bij presenteren/exporteren',

View file

@ -61,6 +61,34 @@ void main() {
expect(n.state.deck!.slides[1].id, isNot(source.id));
});
test('copying a selection into another deck leaves the source intact', () {
// Source deck with three slides; "select" indices 0 and 2.
final source = _notifier()..newDeck('Bron');
source.addSlide(SlideType.bullets); // 1
source.addSlide(SlideType.image); // 2
final picked = [source.state.deck!.slides[0], source.state.deck!.slides[2]];
final sourceIdsBefore = source.state.deck!.slides.map((s) => s.id).toList();
// Target deck receives the copies (appended at the end).
final target = _notifier()..newDeck('Doel');
final at = target.insertSlides(picked);
expect(at, 1); // appended after the single title slide
expect(target.state.deck!.slides, hasLength(3));
expect(target.state.deck!.slides[1].type, SlideType.title);
expect(target.state.deck!.slides[2].type, SlideType.image);
// Copies must have fresh ids, not the source ids.
expect(
target.state.deck!.slides[1].id,
isNot(anyOf(picked.map((s) => s.id))),
);
// The source deck is untouched.
expect(
source.state.deck!.slides.map((s) => s.id).toList(),
sourceIdsBefore,
);
});
test('reorderSlides moves a slide to a new position', () {
final n = _notifier()..newDeck('D');
n.addSlide(SlideType.bullets); // 1