2026-06-02 23:28:39 +02:00
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
|
import 'package:path/path.dart' as p;
|
|
|
|
|
|
import '../../services/caption_service.dart';
|
|
|
|
|
|
import '../../services/description_service.dart';
|
|
|
|
|
|
import '../../services/image_service.dart';
|
|
|
|
|
|
import '../../state/tabs_provider.dart';
|
2026-06-04 02:30:03 +02:00
|
|
|
|
import '../../l10n/app_localizations.dart';
|
2026-06-02 23:28:39 +02:00
|
|
|
|
import '../dialogs/image_carousel_picker.dart';
|
|
|
|
|
|
|
|
|
|
|
|
/// Shared layout helpers for slide editors.
|
|
|
|
|
|
|
|
|
|
|
|
class EditorField extends StatelessWidget {
|
|
|
|
|
|
final String label;
|
|
|
|
|
|
final TextEditingController controller;
|
|
|
|
|
|
final String hint;
|
|
|
|
|
|
final int maxLines;
|
|
|
|
|
|
|
|
|
|
|
|
const EditorField({
|
|
|
|
|
|
super.key,
|
|
|
|
|
|
required this.label,
|
|
|
|
|
|
required this.controller,
|
|
|
|
|
|
this.hint = '',
|
|
|
|
|
|
this.maxLines = 1,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-06-04 02:30:03 +02:00
|
|
|
|
final l10n = context.l10n;
|
2026-06-02 23:28:39 +02:00
|
|
|
|
return Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Text(
|
2026-06-04 02:30:03 +02:00
|
|
|
|
l10n.d(label),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
|
color: Color(0xFF64748B),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 5),
|
|
|
|
|
|
TextField(
|
|
|
|
|
|
controller: controller,
|
|
|
|
|
|
maxLines: maxLines,
|
|
|
|
|
|
minLines: 1,
|
2026-06-04 02:30:03 +02:00
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
|
hintText: hint.isEmpty ? '' : l10n.d(hint),
|
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class EditorFieldList extends StatelessWidget {
|
|
|
|
|
|
final List<Widget> children;
|
|
|
|
|
|
const EditorFieldList({super.key, required this.children});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
return ListView.separated(
|
|
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
|
|
itemCount: children.length,
|
|
|
|
|
|
separatorBuilder: (context, index) => const SizedBox(height: 16),
|
|
|
|
|
|
itemBuilder: (_, i) => children[i],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Zoom-bediening voor afbeeldingen in slides.
|
|
|
|
|
|
/// De afbeelding vult ALTIJD de slide; de zoom bepaalt welk deel zichtbaar is.
|
|
|
|
|
|
/// 0 of 100 = normaal (cover, Marp-standaard)
|
|
|
|
|
|
/// 150 = ingezoomd: ziet alleen het midden van de foto
|
|
|
|
|
|
/// 300 = flink ingezoomd: ziet 1/3 van de foto
|
|
|
|
|
|
class ImageZoomControl extends StatelessWidget {
|
|
|
|
|
|
final int value; // 0 = auto/normaal, anders minValue–maxValue
|
|
|
|
|
|
final ValueChanged<int> onChanged;
|
|
|
|
|
|
final int step;
|
|
|
|
|
|
final int minValue;
|
|
|
|
|
|
final int maxValue;
|
|
|
|
|
|
|
|
|
|
|
|
const ImageZoomControl({
|
|
|
|
|
|
super.key,
|
|
|
|
|
|
required this.value,
|
|
|
|
|
|
required this.onChanged,
|
|
|
|
|
|
this.step = 10,
|
|
|
|
|
|
this.minValue = 20,
|
|
|
|
|
|
this.maxValue = 300,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Effectieve sliderwaarde: 0 behandelen als 100
|
|
|
|
|
|
int get _effective => value == 0 ? 100 : value.clamp(minValue, maxValue);
|
|
|
|
|
|
|
2026-06-04 02:30:03 +02:00
|
|
|
|
String _label(BuildContext context) {
|
|
|
|
|
|
final l10n = context.l10n;
|
2026-06-02 23:28:39 +02:00
|
|
|
|
final v = _effective;
|
|
|
|
|
|
if (maxValue <= 100) return '$v%'; // paneelbreedte-modus
|
2026-06-04 02:30:03 +02:00
|
|
|
|
if (v == 100) return l10n.d('Volledig zichtbaar (100%)');
|
2026-06-02 23:28:39 +02:00
|
|
|
|
if (v > 100) {
|
2026-06-04 02:30:03 +02:00
|
|
|
|
return '${l10n.d('Ingezoomd')} $v% — ${((1 / (v / 100)) * 100).round()}% ${l10n.d('van de foto zichtbaar')}';
|
2026-06-02 23:28:39 +02:00
|
|
|
|
}
|
2026-06-04 02:30:03 +02:00
|
|
|
|
return '${l10n.d('Uitgezoomd')} $v%';
|
2026-06-02 23:28:39 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-06-04 02:30:03 +02:00
|
|
|
|
final l10n = context.l10n;
|
2026-06-02 23:28:39 +02:00
|
|
|
|
final zoomed = _effective != 100;
|
|
|
|
|
|
|
|
|
|
|
|
return Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Row(
|
|
|
|
|
|
children: [
|
2026-06-04 02:30:03 +02:00
|
|
|
|
Tooltip(
|
|
|
|
|
|
message: l10n.d('Uitzoomen (meer van de foto zichtbaar)'),
|
|
|
|
|
|
child: const Icon(
|
|
|
|
|
|
Icons.zoom_out,
|
|
|
|
|
|
size: 16,
|
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
|
|
|
|
color: Color(0xFF64748B),
|
2026-06-04 02:30:03 +02:00
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
),
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: Slider(
|
|
|
|
|
|
value: _effective.toDouble(),
|
|
|
|
|
|
min: minValue.toDouble(),
|
|
|
|
|
|
max: maxValue.toDouble(),
|
|
|
|
|
|
divisions: (maxValue - minValue) ~/ step,
|
2026-06-04 02:30:03 +02:00
|
|
|
|
label: _label(context),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
onChanged: (v) {
|
|
|
|
|
|
final snapped = ((v.round() / step).round() * step).clamp(
|
|
|
|
|
|
minValue,
|
|
|
|
|
|
maxValue,
|
|
|
|
|
|
);
|
|
|
|
|
|
onChanged(snapped);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-04 02:30:03 +02:00
|
|
|
|
Tooltip(
|
|
|
|
|
|
message: l10n.d('Inzoomen (minder van de foto zichtbaar)'),
|
|
|
|
|
|
child: const Icon(
|
|
|
|
|
|
Icons.zoom_in,
|
|
|
|
|
|
size: 16,
|
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
|
|
|
|
color: Color(0xFF64748B),
|
2026-06-04 02:30:03 +02:00
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
|
SizedBox(
|
|
|
|
|
|
width: 52,
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
'$_effective%',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
|
color: zoomed
|
|
|
|
|
|
? const Color(0xFF2563EB)
|
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
|
|
|
|
: const Color(0xFF64748B),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
fontWeight: zoomed ? FontWeight.w600 : FontWeight.normal,
|
|
|
|
|
|
),
|
|
|
|
|
|
textAlign: TextAlign.right,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
|
Tooltip(
|
2026-06-04 02:30:03 +02:00
|
|
|
|
message: l10n.d('Terugzetten (volledige afbeelding zichtbaar)'),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
child: IconButton(
|
|
|
|
|
|
icon: const Icon(Icons.refresh, size: 16),
|
|
|
|
|
|
onPressed: zoomed ? () => onChanged(100) : null,
|
|
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
|
|
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
|
|
|
|
color: const Color(0xFF64748B),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(left: 8, bottom: 4),
|
|
|
|
|
|
child: Text(
|
2026-06-04 02:30:03 +02:00
|
|
|
|
_label(context),
|
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
|
|
|
|
style: const TextStyle(fontSize: 10, color: Color(0xFF64748B)),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Callback met pad én caption.
|
|
|
|
|
|
typedef ImagePickedCallback = void Function(String path, String caption);
|
|
|
|
|
|
|
|
|
|
|
|
/// Afbeeldingskiezer-balk met carousel-knop + optionele caption.
|
|
|
|
|
|
class ImagePickerBar extends ConsumerWidget {
|
|
|
|
|
|
final String imagePath;
|
|
|
|
|
|
final String imageCaption;
|
|
|
|
|
|
final String? captionBasePath;
|
|
|
|
|
|
final List<String> searchPaths;
|
|
|
|
|
|
final ImagePickedCallback onPicked;
|
|
|
|
|
|
final VoidCallback? onBrowse;
|
|
|
|
|
|
final VoidCallback? onPaste;
|
|
|
|
|
|
final VoidCallback? onClear;
|
|
|
|
|
|
final ValueChanged<String>? onCaptionChanged;
|
|
|
|
|
|
final String label;
|
|
|
|
|
|
|
|
|
|
|
|
const ImagePickerBar({
|
|
|
|
|
|
super.key,
|
|
|
|
|
|
required this.imagePath,
|
|
|
|
|
|
required this.searchPaths,
|
|
|
|
|
|
required this.onPicked,
|
|
|
|
|
|
this.imageCaption = '',
|
|
|
|
|
|
this.captionBasePath,
|
|
|
|
|
|
this.onBrowse,
|
|
|
|
|
|
this.onPaste,
|
|
|
|
|
|
this.onClear,
|
|
|
|
|
|
this.onCaptionChanged,
|
|
|
|
|
|
this.label = 'Geen afbeelding gekozen',
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _openCarousel(
|
|
|
|
|
|
BuildContext context,
|
|
|
|
|
|
WidgetRef ref,
|
|
|
|
|
|
CaptionService captions,
|
|
|
|
|
|
) async {
|
|
|
|
|
|
final result = await ImageCarouselPicker.show(
|
|
|
|
|
|
context,
|
|
|
|
|
|
searchPaths: searchPaths,
|
|
|
|
|
|
initialPath: imagePath.isNotEmpty ? _resolveImagePath(imagePath) : null,
|
|
|
|
|
|
captionService: captions,
|
|
|
|
|
|
descriptionService: ref.read(descriptionServiceProvider),
|
|
|
|
|
|
usageOf: (absolutePath) => _imageUsages(ref, absolutePath),
|
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
|
|
|
|
onReplaceUsages: (from, to) => _replaceImageUsages(ref, from, to),
|
|
|
|
|
|
openDeckFiles: [
|
|
|
|
|
|
for (final tab in ref.read(tabsProvider).tabs)
|
|
|
|
|
|
?tab.deckNotifier.currentState.filePath,
|
|
|
|
|
|
],
|
2026-06-02 23:28:39 +02:00
|
|
|
|
);
|
|
|
|
|
|
if (result != null) onPicked(result.path, result.caption);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
|
|
|
|
/// Wijs in alle open decks elke slideverwijzing naar [fromAbsolute] om naar
|
|
|
|
|
|
/// [toAbsolute]. Gebruikt door de afbeeldingenbibliotheek wanneer een md5-
|
|
|
|
|
|
/// duplicaat wordt opgeruimd, zodat slides het behouden bestand blijven tonen.
|
|
|
|
|
|
Future<void> _replaceImageUsages(
|
|
|
|
|
|
WidgetRef ref,
|
|
|
|
|
|
String fromAbsolute,
|
|
|
|
|
|
String toAbsolute,
|
|
|
|
|
|
) async {
|
|
|
|
|
|
final target = p.normalize(fromAbsolute);
|
|
|
|
|
|
for (final tab in ref.read(tabsProvider).tabs) {
|
|
|
|
|
|
final notifier = tab.deckNotifier;
|
|
|
|
|
|
final deck = notifier.currentState.deck;
|
|
|
|
|
|
if (deck == null) continue;
|
|
|
|
|
|
final projectPath = deck.projectPath ?? '';
|
|
|
|
|
|
|
|
|
|
|
|
String resolve(String candidate) => p.normalize(
|
|
|
|
|
|
p.isAbsolute(candidate) ? candidate : p.join(projectPath, candidate),
|
|
|
|
|
|
);
|
|
|
|
|
|
// Blijf relatief opslaan als de slide dat al deed en het nieuwe pad
|
|
|
|
|
|
// binnen het project ligt; anders absoluut.
|
|
|
|
|
|
String replacement(String candidate) {
|
|
|
|
|
|
if (p.isAbsolute(candidate) || projectPath.isEmpty) return toAbsolute;
|
|
|
|
|
|
return p.isWithin(projectPath, toAbsolute)
|
|
|
|
|
|
? p.relative(toAbsolute, from: projectPath)
|
|
|
|
|
|
: toAbsolute;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (var i = 0; i < deck.slides.length; i++) {
|
|
|
|
|
|
final slide = deck.slides[i];
|
|
|
|
|
|
var updated = slide;
|
|
|
|
|
|
if (slide.imagePath.isNotEmpty && resolve(slide.imagePath) == target) {
|
|
|
|
|
|
updated = updated.copyWith(imagePath: replacement(slide.imagePath));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (slide.imagePath2.isNotEmpty &&
|
|
|
|
|
|
resolve(slide.imagePath2) == target) {
|
|
|
|
|
|
updated = updated.copyWith(
|
|
|
|
|
|
imagePath2: replacement(slide.imagePath2),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!identical(updated, slide)) notifier.updateSlide(i, updated);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 23:28:39 +02:00
|
|
|
|
/// Find every open-deck slide that references [absolutePath], so we can warn
|
|
|
|
|
|
/// before deleting an image that is still in use.
|
|
|
|
|
|
List<String> _imageUsages(WidgetRef ref, String absolutePath) {
|
|
|
|
|
|
final target = p.normalize(absolutePath);
|
|
|
|
|
|
final usages = <String>[];
|
|
|
|
|
|
for (final tab in ref.read(tabsProvider).tabs) {
|
|
|
|
|
|
final deck = tab.deckNotifier.currentState.deck;
|
|
|
|
|
|
if (deck == null) continue;
|
|
|
|
|
|
for (var i = 0; i < deck.slides.length; i++) {
|
|
|
|
|
|
final slide = deck.slides[i];
|
|
|
|
|
|
for (final candidate in [slide.imagePath, slide.imagePath2]) {
|
|
|
|
|
|
if (candidate.isEmpty) continue;
|
|
|
|
|
|
final resolved = p.normalize(
|
|
|
|
|
|
p.isAbsolute(candidate)
|
|
|
|
|
|
? candidate
|
|
|
|
|
|
: p.join(deck.projectPath ?? '', candidate),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (resolved == target) {
|
|
|
|
|
|
usages.add('${tab.label} · slide ${i + 1}');
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return usages;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
String _resolveImagePath(String path) {
|
|
|
|
|
|
if (p.isAbsolute(path) || captionBasePath == null) return path;
|
|
|
|
|
|
return p.join(captionBasePath!, path);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
2026-06-04 02:30:03 +02:00
|
|
|
|
final l10n = context.l10n;
|
2026-06-02 23:28:39 +02:00
|
|
|
|
final captions = ref.read(captionServiceProvider);
|
|
|
|
|
|
return Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
// Pad-display
|
|
|
|
|
|
Container(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
border: Border.all(color: const Color(0xFFCBD5E1)),
|
|
|
|
|
|
borderRadius: BorderRadius.circular(6),
|
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Text(
|
2026-06-04 02:30:03 +02:00
|
|
|
|
imagePath.isEmpty ? l10n.d(label) : imagePath,
|
2026-06-02 23:28:39 +02:00
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
|
color: imagePath.isEmpty
|
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
|
|
|
|
? const Color(0xFF64748B)
|
2026-06-02 23:28:39 +02:00
|
|
|
|
: const Color(0xFF334155),
|
|
|
|
|
|
),
|
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
|
// Knoppen
|
|
|
|
|
|
Wrap(
|
|
|
|
|
|
spacing: 6,
|
|
|
|
|
|
runSpacing: 6,
|
|
|
|
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
ElevatedButton.icon(
|
|
|
|
|
|
onPressed: () => _openCarousel(context, ref, captions),
|
|
|
|
|
|
icon: const Icon(Icons.photo_library_outlined, size: 16),
|
2026-06-04 02:30:03 +02:00
|
|
|
|
label: Text(l10n.d('Uit bibliotheek…')),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
),
|
|
|
|
|
|
if (onBrowse != null)
|
|
|
|
|
|
OutlinedButton.icon(
|
|
|
|
|
|
onPressed: onBrowse,
|
|
|
|
|
|
icon: const Icon(Icons.folder_open_outlined, size: 16),
|
2026-06-04 02:30:03 +02:00
|
|
|
|
label: Text(l10n.d('Van computer…')),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
),
|
|
|
|
|
|
if (onPaste != null)
|
|
|
|
|
|
Tooltip(
|
2026-06-04 02:30:03 +02:00
|
|
|
|
message: l10n.d('Afbeelding plakken uit klembord'),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
child: IconButton(
|
|
|
|
|
|
onPressed: onPaste,
|
|
|
|
|
|
icon: const Icon(Icons.content_paste, size: 18),
|
|
|
|
|
|
color: const Color(0xFF64748B),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
if (imagePath.isNotEmpty)
|
|
|
|
|
|
Tooltip(
|
2026-06-04 02:30:03 +02:00
|
|
|
|
message: l10n.d('Kopieer afbeelding naar klembord'),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
child: IconButton(
|
|
|
|
|
|
onPressed: () async {
|
|
|
|
|
|
final ok = await ImageService().copyImageToClipboard(
|
|
|
|
|
|
_resolveImagePath(imagePath),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (context.mounted) {
|
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
SnackBar(
|
|
|
|
|
|
content: Text(
|
|
|
|
|
|
ok
|
2026-06-04 02:30:03 +02:00
|
|
|
|
? l10n.d('Afbeelding gekopieerd naar klembord.')
|
|
|
|
|
|
: l10n.d('Kopiëren naar klembord mislukt.'),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
icon: const Icon(Icons.content_copy_outlined, size: 18),
|
|
|
|
|
|
color: const Color(0xFF64748B),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
if (onClear != null && imagePath.isNotEmpty)
|
|
|
|
|
|
Tooltip(
|
2026-06-04 02:30:03 +02:00
|
|
|
|
message: l10n.d('Verwijder afbeelding'),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
child: IconButton(
|
|
|
|
|
|
onPressed: onClear,
|
|
|
|
|
|
icon: const Icon(Icons.clear, size: 18),
|
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
|
|
|
|
color: const Color(0xFF64748B),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
// Caption-veld
|
|
|
|
|
|
if (imagePath.isNotEmpty && onCaptionChanged != null) ...[
|
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
|
_CaptionField(
|
|
|
|
|
|
caption: imageCaption,
|
|
|
|
|
|
imagePath: imagePath,
|
|
|
|
|
|
captionBasePath: captionBasePath,
|
|
|
|
|
|
captionService: captions,
|
|
|
|
|
|
onChanged: onCaptionChanged!,
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Captionveld met auto-save naar sidecar.
|
|
|
|
|
|
class _CaptionField extends StatefulWidget {
|
|
|
|
|
|
final String caption;
|
|
|
|
|
|
final String imagePath;
|
|
|
|
|
|
final String? captionBasePath;
|
|
|
|
|
|
final CaptionService captionService;
|
|
|
|
|
|
final ValueChanged<String> onChanged;
|
|
|
|
|
|
|
|
|
|
|
|
const _CaptionField({
|
|
|
|
|
|
required this.caption,
|
|
|
|
|
|
required this.imagePath,
|
|
|
|
|
|
this.captionBasePath,
|
|
|
|
|
|
required this.captionService,
|
|
|
|
|
|
required this.onChanged,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
State<_CaptionField> createState() => _CaptionFieldState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class _CaptionFieldState extends State<_CaptionField> {
|
|
|
|
|
|
late final TextEditingController _ctrl;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void initState() {
|
|
|
|
|
|
super.initState();
|
|
|
|
|
|
_ctrl = TextEditingController(text: widget.caption);
|
|
|
|
|
|
_ctrl.addListener(_onChanged);
|
|
|
|
|
|
_loadStoredCaption();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void didUpdateWidget(_CaptionField old) {
|
|
|
|
|
|
super.didUpdateWidget(old);
|
|
|
|
|
|
if (old.imagePath != widget.imagePath) {
|
|
|
|
|
|
_ctrl.removeListener(_onChanged);
|
|
|
|
|
|
_ctrl.text = widget.caption;
|
|
|
|
|
|
_ctrl.addListener(_onChanged);
|
|
|
|
|
|
_loadStoredCaption();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _onChanged() {
|
|
|
|
|
|
widget.onChanged(_ctrl.text);
|
|
|
|
|
|
widget.captionService.saveCaption(
|
|
|
|
|
|
widget.imagePath,
|
|
|
|
|
|
_ctrl.text,
|
|
|
|
|
|
basePath: widget.captionBasePath,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _loadStoredCaption() async {
|
|
|
|
|
|
if (widget.caption.isNotEmpty) return;
|
|
|
|
|
|
final stored = await widget.captionService.getCaption(
|
|
|
|
|
|
widget.imagePath,
|
|
|
|
|
|
basePath: widget.captionBasePath,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!mounted || stored == null || stored == _ctrl.text) return;
|
|
|
|
|
|
_ctrl.removeListener(_onChanged);
|
|
|
|
|
|
_ctrl.text = stored;
|
|
|
|
|
|
_ctrl.addListener(_onChanged);
|
|
|
|
|
|
widget.onChanged(stored);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void dispose() {
|
|
|
|
|
|
_ctrl.dispose();
|
|
|
|
|
|
super.dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-06-04 02:30:03 +02:00
|
|
|
|
final l10n = context.l10n;
|
2026-06-02 23:28:39 +02:00
|
|
|
|
return TextField(
|
|
|
|
|
|
controller: _ctrl,
|
|
|
|
|
|
decoration: InputDecoration(
|
2026-06-04 02:30:03 +02:00
|
|
|
|
hintText: l10n.d('Caption / bronvermelding (bijv. © Naam Fotograaf)'),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
hintStyle: const TextStyle(fontSize: 12, color: Color(0xFFB0BEC5)),
|
|
|
|
|
|
prefixIcon: const Icon(
|
|
|
|
|
|
Icons.copyright_outlined,
|
|
|
|
|
|
size: 16,
|
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
|
|
|
|
color: Color(0xFF64748B),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
),
|
|
|
|
|
|
isDense: true,
|
|
|
|
|
|
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
|
|
|
|
|
|
filled: true,
|
|
|
|
|
|
fillColor: const Color(0xFFF8FAFC),
|
|
|
|
|
|
border: OutlineInputBorder(
|
|
|
|
|
|
borderRadius: BorderRadius.circular(6),
|
|
|
|
|
|
borderSide: const BorderSide(color: Color(0xFFCBD5E1)),
|
|
|
|
|
|
),
|
|
|
|
|
|
enabledBorder: OutlineInputBorder(
|
|
|
|
|
|
borderRadius: BorderRadius.circular(6),
|
|
|
|
|
|
borderSide: const BorderSide(color: Color(0xFFCBD5E1)),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
style: const TextStyle(fontSize: 12),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class SectionLabel extends StatelessWidget {
|
|
|
|
|
|
final String text;
|
|
|
|
|
|
const SectionLabel(this.text, {super.key});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-06-04 02:30:03 +02:00
|
|
|
|
final l10n = context.l10n;
|
2026-06-02 23:28:39 +02:00
|
|
|
|
return Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
|
|
|
|
child: Text(
|
2026-06-04 02:30:03 +02:00
|
|
|
|
l10n.d(text),
|
2026-06-02 23:28:39 +02:00
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
|
color: Color(0xFF64748B),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|