From 01cc1c2ecde3c41b4fddbc38a9d31224d504416a Mon Sep 17 00:00:00 2001 From: Brenno de Winter Date: Wed, 3 Jun 2026 21:56:51 +0200 Subject: [PATCH] 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 --- lib/widgets/app_shell.dart | 9 +++ lib/widgets/panels/slide_list_panel.dart | 84 +++++++++++++++++++++++- test/deck_provider_test.dart | 28 ++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 42e6a8b..c986d37 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -299,6 +299,12 @@ class _AppShellState extends ConsumerState 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 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, diff --git a/lib/widgets/panels/slide_list_panel.dart b/lib/widgets/panels/slide_list_panel.dart index 81016c9..de865ec 100644 --- a/lib/widgets/panels/slide_list_panel.dart +++ b/lib/widgets/panels/slide_list_panel.dart @@ -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 { } 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 { ); } + /// De geselecteerde slides, op volgorde van positie in het deck. + List _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 _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( + 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 { 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', diff --git a/test/deck_provider_test.dart b/test/deck_provider_test.dart index 8e6f8d1..917b270 100644 --- a/test/deck_provider_test.dart +++ b/test/deck_provider_test.dart @@ -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