2026-06-02 23:28:39 +02:00
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:io';
|
|
|
|
|
import 'dart:typed_data';
|
|
|
|
|
import 'package:archive/archive.dart';
|
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
|
|
|
import 'package:path/path.dart' as p;
|
|
|
|
|
import 'package:flutter/services.dart' show rootBundle;
|
|
|
|
|
import '../models/deck.dart';
|
2026-06-04 02:30:03 +02:00
|
|
|
import '../l10n/app_localizations.dart';
|
2026-06-02 23:28:39 +02:00
|
|
|
import '../models/settings.dart';
|
|
|
|
|
import '../models/slide.dart';
|
Add annotation layer (laser, pen, highlighter) over slides
Draw on slides while presenting, kept as a layer fully separate from the
Marp content so the deck stays pure, portable Marp.
Presenter tools (D pen, T highlighter, E eraser, X laser, C clear, Esc
puts the tool away) with a small floating colour/tool bar shown only when
a tool is active. Strokes use coordinates normalized to the 16:9 slide so
they render identically on the laptop and the beamer; in dual-screen mode
the ink and the laser are mirrored live to the audience window.
Persistence (decoupled from the .md):
- In memory the layer is keyed by Slide.id (stable within a session).
- On disk it lives in a sidecar <name>.ink.json next to the deck and as a
separate entry inside the .ocideck package; the markdown is untouched.
- Because slide ids are regenerated on load, the sidecar anchors strokes
by order + a content fingerprint, re-attaching them after reordering and
dropping them when a slide's content changed.
- Deck.annotations carries the layer in memory but is never serialized to
markdown; deckProvider.setAnnotations keeps it out of undo/redo.
flutter analyze is clean, all tests pass (incl. new stroke/codec tests),
and the macOS debug build compiles. Drawing and live beamer sync still
need verification on real hardware.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:14:51 +02:00
|
|
|
import 'annotation_codec.dart';
|
2026-06-02 23:28:39 +02:00
|
|
|
import 'caption_service.dart';
|
|
|
|
|
import 'image_service.dart';
|
|
|
|
|
import 'markdown_service.dart';
|
|
|
|
|
|
|
|
|
|
/// A presentation found on disk while scanning a directory.
|
|
|
|
|
class ScannedPresentation {
|
|
|
|
|
final String path;
|
|
|
|
|
final String fileName;
|
|
|
|
|
final Deck deck;
|
|
|
|
|
|
|
|
|
|
/// The raw markdown source, kept for maximal full-text search.
|
|
|
|
|
final String content;
|
|
|
|
|
|
|
|
|
|
const ScannedPresentation({
|
|
|
|
|
required this.path,
|
|
|
|
|
required this.fileName,
|
|
|
|
|
required this.deck,
|
|
|
|
|
this.content = '',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _LogoProjectAsset {
|
|
|
|
|
final ThemeProfile profile;
|
|
|
|
|
final String? cssUrl;
|
|
|
|
|
|
|
|
|
|
const _LogoProjectAsset(this.profile, this.cssUrl);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class FileService {
|
|
|
|
|
final MarkdownService _md;
|
|
|
|
|
final ImageService _img;
|
|
|
|
|
final ThemeProfile Function() _themeProfile;
|
2026-06-04 02:30:03 +02:00
|
|
|
final String Function() _languageCode;
|
2026-06-02 23:28:39 +02:00
|
|
|
final CaptionService _captions = CaptionService();
|
|
|
|
|
|
2026-06-04 02:30:03 +02:00
|
|
|
FileService(
|
|
|
|
|
this._md,
|
|
|
|
|
this._img,
|
|
|
|
|
this._themeProfile, {
|
|
|
|
|
String Function()? languageCode,
|
|
|
|
|
}) : _languageCode = languageCode ?? (() => 'nl');
|
2026-06-02 23:28:39 +02:00
|
|
|
|
|
|
|
|
ThemeProfile get currentThemeProfile => _themeProfile();
|
|
|
|
|
|
2026-06-04 02:30:03 +02:00
|
|
|
String _d(String text) => AppLocalizations.sourceFor(_languageCode(), text);
|
|
|
|
|
|
2026-06-02 23:28:39 +02:00
|
|
|
static const _ignoredDirs = {
|
|
|
|
|
'images',
|
|
|
|
|
'logos',
|
|
|
|
|
'themes',
|
|
|
|
|
'node_modules',
|
|
|
|
|
'build',
|
|
|
|
|
'.git',
|
|
|
|
|
'.dart_tool',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// Recursively scan [directory] for Marp markdown presentations and parse
|
|
|
|
|
/// them into decks. [excludePath] (typically the currently open file) is
|
|
|
|
|
/// skipped. Directories such as images/ and themes/ are ignored, and the
|
|
|
|
|
/// walk is bounded by [maxDepth] to keep large home folders responsive.
|
|
|
|
|
Future<List<ScannedPresentation>> scanPresentations(
|
|
|
|
|
String directory, {
|
|
|
|
|
String? excludePath,
|
|
|
|
|
int maxDepth = 4,
|
|
|
|
|
}) async {
|
|
|
|
|
final root = Directory(directory);
|
|
|
|
|
if (!await root.exists()) return [];
|
|
|
|
|
|
|
|
|
|
final results = <ScannedPresentation>[];
|
|
|
|
|
Future<void> walk(Directory dir, int depth) async {
|
|
|
|
|
List<FileSystemEntity> entries;
|
|
|
|
|
try {
|
|
|
|
|
entries = await dir.list(followLinks: false).toList();
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
for (final entity in entries) {
|
|
|
|
|
if (entity is File) {
|
|
|
|
|
if (!entity.path.toLowerCase().endsWith('.md')) continue;
|
|
|
|
|
if (excludePath != null && p.equals(entity.path, excludePath)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
String content;
|
|
|
|
|
try {
|
|
|
|
|
content = await entity.readAsString();
|
|
|
|
|
} catch (_) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
final deck = await openDeck(entity.path, content: content);
|
|
|
|
|
if (deck != null && deck.slides.isNotEmpty) {
|
|
|
|
|
results.add(
|
|
|
|
|
ScannedPresentation(
|
|
|
|
|
path: entity.path,
|
|
|
|
|
fileName: p.basename(entity.path),
|
|
|
|
|
deck: deck,
|
|
|
|
|
content: content,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} else if (entity is Directory && depth < maxDepth) {
|
|
|
|
|
final name = p.basename(entity.path);
|
|
|
|
|
if (_ignoredDirs.contains(name) || name.startsWith('.')) continue;
|
|
|
|
|
await walk(entity, depth + 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await walk(root, 0);
|
|
|
|
|
results.sort(
|
|
|
|
|
(a, b) =>
|
|
|
|
|
a.deck.title.toLowerCase().compareTo(b.deck.title.toLowerCase()),
|
|
|
|
|
);
|
|
|
|
|
return results;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<String?> pickMarkdownFile({String? initialDirectory}) async {
|
|
|
|
|
final result = await FilePicker.pickFiles(
|
2026-06-04 02:30:03 +02:00
|
|
|
dialogTitle: _d('Presentatie openen'),
|
2026-06-02 23:28:39 +02:00
|
|
|
type: FileType.custom,
|
|
|
|
|
allowedExtensions: ['md'],
|
|
|
|
|
initialDirectory: initialDirectory,
|
|
|
|
|
);
|
|
|
|
|
return result?.files.single.path;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<Deck?> openDeck(String filePath, {String? content}) async {
|
|
|
|
|
String raw;
|
|
|
|
|
if (content != null) {
|
|
|
|
|
raw = content;
|
|
|
|
|
} else {
|
|
|
|
|
final file = File(filePath);
|
|
|
|
|
if (!await file.exists()) return null;
|
|
|
|
|
raw = await file.readAsString();
|
|
|
|
|
}
|
|
|
|
|
final deck = _md.parseDeck(raw, filePath: filePath);
|
|
|
|
|
if (deck == null) return null;
|
Add annotation layer (laser, pen, highlighter) over slides
Draw on slides while presenting, kept as a layer fully separate from the
Marp content so the deck stays pure, portable Marp.
Presenter tools (D pen, T highlighter, E eraser, X laser, C clear, Esc
puts the tool away) with a small floating colour/tool bar shown only when
a tool is active. Strokes use coordinates normalized to the 16:9 slide so
they render identically on the laptop and the beamer; in dual-screen mode
the ink and the laser are mirrored live to the audience window.
Persistence (decoupled from the .md):
- In memory the layer is keyed by Slide.id (stable within a session).
- On disk it lives in a sidecar <name>.ink.json next to the deck and as a
separate entry inside the .ocideck package; the markdown is untouched.
- Because slide ids are regenerated on load, the sidecar anchors strokes
by order + a content fingerprint, re-attaching them after reordering and
dropping them when a slide's content changed.
- Deck.annotations carries the layer in memory but is never serialized to
markdown; deckProvider.setAnnotations keeps it out of undo/redo.
flutter analyze is clean, all tests pass (incl. new stroke/codec tests),
and the macOS debug build compiles. Drawing and live beamer sync still
need verification on real hardware.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:14:51 +02:00
|
|
|
final hydrated = await _hydrateImageCaptions(deck);
|
|
|
|
|
// Re-attach the separate annotation layer from its sidecar, if present.
|
|
|
|
|
if (content == null) {
|
|
|
|
|
final sidecar = File(_sidecarPath(filePath));
|
|
|
|
|
if (await sidecar.exists()) {
|
|
|
|
|
try {
|
|
|
|
|
final map = AnnotationCodec.decode(
|
|
|
|
|
await sidecar.readAsString(),
|
|
|
|
|
hydrated.slides,
|
|
|
|
|
);
|
|
|
|
|
if (map.isNotEmpty) return hydrated.copyWith(annotations: map);
|
|
|
|
|
} catch (_) {
|
|
|
|
|
// A broken sidecar must never block opening the deck.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return hydrated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Path of the annotation sidecar next to a deck `<name>.md` → `<name>.ink.json`.
|
|
|
|
|
String _sidecarPath(String mdPath) => p.setExtension(mdPath, '.ink.json');
|
|
|
|
|
|
|
|
|
|
/// Write the annotation sidecar next to [filePath], or remove it when empty.
|
|
|
|
|
Future<void> _writeSidecar(Deck deck, String filePath) async {
|
|
|
|
|
final sidecar = File(_sidecarPath(filePath));
|
|
|
|
|
final json = AnnotationCodec.encode(deck.slides, deck.annotations);
|
|
|
|
|
if (json == null) {
|
|
|
|
|
if (await sidecar.exists()) await sidecar.delete();
|
|
|
|
|
} else {
|
|
|
|
|
await sidecar.writeAsString(json, flush: true);
|
|
|
|
|
}
|
2026-06-02 23:28:39 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<String?> saveDeckAs(Deck deck, {String? initialDirectory}) async {
|
|
|
|
|
final safeName = deck.title
|
|
|
|
|
.replaceAll(RegExp(r'[^\w\s-]'), '')
|
|
|
|
|
.replaceAll(' ', '_');
|
|
|
|
|
final result = await FilePicker.saveFile(
|
2026-06-04 02:30:03 +02:00
|
|
|
dialogTitle: _d('Opslaan als'),
|
2026-06-02 23:28:39 +02:00
|
|
|
fileName: '$safeName.md',
|
|
|
|
|
initialDirectory: initialDirectory,
|
|
|
|
|
);
|
|
|
|
|
if (result == null) return null;
|
|
|
|
|
final path = result.endsWith('.md') ? result : '$result.md';
|
|
|
|
|
await _writeProject(deck, path);
|
|
|
|
|
return path;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<Deck> saveDeck(Deck deck, String filePath) async {
|
|
|
|
|
return _writeProject(deck, filePath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Draagbaar pakket (uitwisselen / op een ander systeem draaien) ──────────
|
|
|
|
|
|
|
|
|
|
static const packageExtension = 'ocideck';
|
|
|
|
|
|
|
|
|
|
String _safeName(String title) {
|
|
|
|
|
final cleaned = title
|
|
|
|
|
.replaceAll(RegExp(r'[^\w\s-]'), '')
|
|
|
|
|
.replaceAll(RegExp(r'\s+'), '_')
|
|
|
|
|
.trim();
|
|
|
|
|
return cleaned.isEmpty ? 'presentatie' : cleaned;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Schrijf een zelfstandig pakket (zip): de markdown + álle gebruikte assets
|
|
|
|
|
/// (afbeeldingen, media, logo) en de thema-CSS, met onderling relatieve
|
|
|
|
|
/// paden. Werkt ongeacht of het deck al is opgeslagen.
|
|
|
|
|
Future<void> exportPackage(Deck deck, String destPath) async {
|
|
|
|
|
final archive = Archive();
|
|
|
|
|
final added = <String>{};
|
|
|
|
|
|
|
|
|
|
/// Resolve [path] (relatief t.o.v. projectPath of absoluut), voeg het
|
|
|
|
|
/// bestand toe onder `<subdir>/<bestandsnaam>` en geef dat pad terug.
|
|
|
|
|
String? addAsset(String path, String subdir) {
|
|
|
|
|
if (path.trim().isEmpty) return null;
|
|
|
|
|
final abs = p.isAbsolute(path)
|
|
|
|
|
? path
|
|
|
|
|
: (deck.projectPath != null ? p.join(deck.projectPath!, path) : path);
|
|
|
|
|
final file = File(abs);
|
|
|
|
|
if (!file.existsSync()) return null;
|
|
|
|
|
final rel = p.posix.join(subdir, p.basename(abs));
|
|
|
|
|
if (!added.contains(rel)) {
|
|
|
|
|
final bytes = file.readAsBytesSync();
|
|
|
|
|
archive.add(ArchiveFile(rel, bytes.length, bytes));
|
|
|
|
|
added.add(rel);
|
|
|
|
|
}
|
|
|
|
|
return rel;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final slides = [
|
|
|
|
|
for (final s in deck.slides)
|
|
|
|
|
s.copyWith(
|
|
|
|
|
imagePath: addAsset(s.imagePath, 'images') ?? s.imagePath,
|
|
|
|
|
imagePath2: addAsset(s.imagePath2, 'images') ?? s.imagePath2,
|
|
|
|
|
videoPath: addAsset(s.videoPath, 'media') ?? s.videoPath,
|
|
|
|
|
audioPath: addAsset(s.audioPath, 'media') ?? s.audioPath,
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
final logoRel = addAsset(deck.themeProfile.logoPath ?? '', 'logos');
|
|
|
|
|
final profile = logoRel != null
|
|
|
|
|
? deck.themeProfile.copyWith(logoPath: logoRel)
|
|
|
|
|
: deck.themeProfile;
|
|
|
|
|
|
|
|
|
|
final packDeck = deck.copyWith(slides: slides, themeProfile: profile);
|
|
|
|
|
|
|
|
|
|
// Markdown.
|
|
|
|
|
final markdown = _md.generateDeck(packDeck);
|
|
|
|
|
final mdBytes = utf8.encode(markdown);
|
|
|
|
|
archive.add(
|
|
|
|
|
ArchiveFile('${_safeName(deck.title)}.md', mdBytes.length, mdBytes),
|
|
|
|
|
);
|
|
|
|
|
|
Add annotation layer (laser, pen, highlighter) over slides
Draw on slides while presenting, kept as a layer fully separate from the
Marp content so the deck stays pure, portable Marp.
Presenter tools (D pen, T highlighter, E eraser, X laser, C clear, Esc
puts the tool away) with a small floating colour/tool bar shown only when
a tool is active. Strokes use coordinates normalized to the 16:9 slide so
they render identically on the laptop and the beamer; in dual-screen mode
the ink and the laser are mirrored live to the audience window.
Persistence (decoupled from the .md):
- In memory the layer is keyed by Slide.id (stable within a session).
- On disk it lives in a sidecar <name>.ink.json next to the deck and as a
separate entry inside the .ocideck package; the markdown is untouched.
- Because slide ids are regenerated on load, the sidecar anchors strokes
by order + a content fingerprint, re-attaching them after reordering and
dropping them when a slide's content changed.
- Deck.annotations carries the layer in memory but is never serialized to
markdown; deckProvider.setAnnotations keeps it out of undo/redo.
flutter analyze is clean, all tests pass (incl. new stroke/codec tests),
and the macOS debug build compiles. Drawing and live beamer sync still
need verification on real hardware.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:14:51 +02:00
|
|
|
// Annotation layer travels as a separate sidecar (same base name as the
|
|
|
|
|
// markdown), so the .md inside the package stays pure Marp.
|
|
|
|
|
final ink = AnnotationCodec.encode(packDeck.slides, packDeck.annotations);
|
|
|
|
|
if (ink != null) {
|
|
|
|
|
final inkBytes = utf8.encode(ink);
|
|
|
|
|
archive.add(
|
|
|
|
|
ArchiveFile(
|
|
|
|
|
'${_safeName(deck.title)}.ink.json',
|
|
|
|
|
inkBytes.length,
|
|
|
|
|
inkBytes,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 23:28:39 +02:00
|
|
|
// Thema-CSS (zodat het pakket ook in Marp/CLI bruikbaar is).
|
|
|
|
|
final css = await _packageThemeCss(packDeck.theme, profile, logoRel);
|
|
|
|
|
if (css != null) {
|
|
|
|
|
final cssBytes = utf8.encode(css);
|
|
|
|
|
final themeName = packDeck.theme.trim().isEmpty
|
|
|
|
|
? 'ocideck'
|
|
|
|
|
: packDeck.theme;
|
|
|
|
|
archive.add(
|
|
|
|
|
ArchiveFile('themes/$themeName.css', cssBytes.length, cssBytes),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final bytes = ZipEncoder().encodeBytes(archive);
|
|
|
|
|
await File(destPath).writeAsBytes(bytes, flush: true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<String?> _packageThemeCss(
|
|
|
|
|
String themeName,
|
|
|
|
|
ThemeProfile profile,
|
|
|
|
|
String? logoRel,
|
|
|
|
|
) async {
|
|
|
|
|
final safe = themeName.trim().isEmpty ? 'ocideck' : themeName;
|
|
|
|
|
try {
|
|
|
|
|
final base = (await rootBundle.loadString(
|
|
|
|
|
'assets/themes/ocideck.css',
|
|
|
|
|
)).replaceFirst('@theme ocideck', '@theme $safe');
|
|
|
|
|
return _buildThemeCss(
|
|
|
|
|
base,
|
|
|
|
|
profile,
|
|
|
|
|
logoRel == null ? null : '../$logoRel',
|
|
|
|
|
);
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Pak een pakket uit in een nieuwe submap onder [destParentDir]. Geeft het
|
|
|
|
|
/// pad naar het uitgepakte markdown-bestand terug (om in een tab te openen).
|
|
|
|
|
Future<String?> importPackageBytes(
|
|
|
|
|
List<int> zipBytes,
|
|
|
|
|
String destParentDir,
|
|
|
|
|
) async {
|
|
|
|
|
final Archive archive;
|
|
|
|
|
try {
|
|
|
|
|
archive = ZipDecoder().decodeBytes(zipBytes);
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Kies de markdown met het ondiepste pad (de hoofd-md van het pakket).
|
|
|
|
|
ArchiveFile? mdEntry;
|
|
|
|
|
for (final f in archive.files) {
|
|
|
|
|
if (!f.isFile || !f.name.toLowerCase().endsWith('.md')) continue;
|
|
|
|
|
if (mdEntry == null ||
|
|
|
|
|
'/'.allMatches(f.name).length < '/'.allMatches(mdEntry.name).length) {
|
|
|
|
|
mdEntry = f;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (mdEntry == null) return null;
|
|
|
|
|
|
|
|
|
|
final folderName = p.basenameWithoutExtension(mdEntry.name);
|
|
|
|
|
final destDir = _uniqueDir(destParentDir, folderName);
|
|
|
|
|
await destDir.create(recursive: true);
|
|
|
|
|
|
|
|
|
|
for (final f in archive.files) {
|
|
|
|
|
if (!f.isFile) continue;
|
|
|
|
|
final out = File(p.join(destDir.path, f.name));
|
|
|
|
|
await out.parent.create(recursive: true);
|
|
|
|
|
await out.writeAsBytes(f.content as List<int>, flush: true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return p.join(destDir.path, mdEntry.name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Directory _uniqueDir(String parent, String name) {
|
|
|
|
|
var dir = Directory(p.join(parent, name));
|
|
|
|
|
var i = 2;
|
|
|
|
|
while (dir.existsSync()) {
|
|
|
|
|
dir = Directory(p.join(parent, '$name ($i)'));
|
|
|
|
|
i++;
|
|
|
|
|
}
|
|
|
|
|
return dir;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Download een presentatie vanaf [url]. Een zip-pakket wordt uitgepakt;
|
|
|
|
|
/// platte markdown wordt als losse `.md` opgeslagen. Geeft het pad naar het
|
|
|
|
|
/// markdown-bestand terug.
|
|
|
|
|
Future<String?> importFromUrl(String url, String destParentDir) async {
|
|
|
|
|
final uri = Uri.tryParse(url.trim());
|
|
|
|
|
if (uri == null || !uri.hasScheme) return null;
|
|
|
|
|
|
|
|
|
|
final List<int> bytes;
|
|
|
|
|
try {
|
|
|
|
|
final client = HttpClient();
|
|
|
|
|
try {
|
|
|
|
|
final request = await client.getUrl(uri);
|
|
|
|
|
final response = await request.close();
|
|
|
|
|
if (response.statusCode != 200) return null;
|
|
|
|
|
final builder = BytesBuilder(copy: false);
|
|
|
|
|
await for (final chunk in response) {
|
|
|
|
|
builder.add(chunk);
|
|
|
|
|
}
|
|
|
|
|
bytes = builder.takeBytes();
|
|
|
|
|
} finally {
|
|
|
|
|
client.close(force: true);
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Zip-magie 'PK\x03\x04' → pakket; anders als markdown behandelen.
|
|
|
|
|
final isZip =
|
|
|
|
|
bytes.length >= 4 &&
|
|
|
|
|
bytes[0] == 0x50 &&
|
|
|
|
|
bytes[1] == 0x4B &&
|
|
|
|
|
bytes[2] == 0x03 &&
|
|
|
|
|
bytes[3] == 0x04;
|
|
|
|
|
if (isZip) {
|
|
|
|
|
return importPackageBytes(bytes, destParentDir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Platte markdown.
|
|
|
|
|
final String markdown;
|
|
|
|
|
try {
|
|
|
|
|
markdown = utf8.decode(bytes);
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
if (!markdown.contains('marp') && !markdown.contains('---')) return null;
|
|
|
|
|
|
|
|
|
|
var base = p.basenameWithoutExtension(uri.path);
|
|
|
|
|
if (base.isEmpty) base = 'presentatie';
|
|
|
|
|
final destDir = _uniqueDir(destParentDir, base);
|
|
|
|
|
await destDir.create(recursive: true);
|
|
|
|
|
final mdPath = p.join(destDir.path, '$base.md');
|
|
|
|
|
await File(mdPath).writeAsString(markdown);
|
|
|
|
|
return mdPath;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<String?> pickPackageFile({String? initialDirectory}) async {
|
|
|
|
|
final result = await FilePicker.pickFiles(
|
2026-06-04 02:30:03 +02:00
|
|
|
dialogTitle: _d('Pakket importeren'),
|
2026-06-02 23:28:39 +02:00
|
|
|
type: FileType.custom,
|
|
|
|
|
allowedExtensions: [packageExtension, 'zip'],
|
|
|
|
|
initialDirectory: initialDirectory,
|
|
|
|
|
);
|
|
|
|
|
return result?.files.single.path;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<String?> pickPackageDestination(Deck deck) async {
|
|
|
|
|
return FilePicker.saveFile(
|
2026-06-04 02:30:03 +02:00
|
|
|
dialogTitle: _d('Pakket exporteren'),
|
2026-06-02 23:28:39 +02:00
|
|
|
fileName: '${_safeName(deck.title)}.$packageExtension',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<Deck> _writeProject(Deck deck, String filePath) async {
|
|
|
|
|
final dir = p.dirname(filePath);
|
|
|
|
|
|
|
|
|
|
final imagesDir = Directory(p.join(dir, 'images'));
|
|
|
|
|
final logosDir = Directory(p.join(dir, 'logos'));
|
|
|
|
|
final themesDir = Directory(p.join(dir, 'themes'));
|
|
|
|
|
await imagesDir.create(recursive: true);
|
|
|
|
|
await logosDir.create(recursive: true);
|
|
|
|
|
await themesDir.create(recursive: true);
|
|
|
|
|
|
|
|
|
|
final imageSlides = await _img.copyImagesToProject(deck.slides, dir);
|
|
|
|
|
final mediaSlides = await _img.copyMediaToProject(imageSlides, dir);
|
|
|
|
|
var updatedDeck = deck.copyWith(slides: mediaSlides, projectPath: dir);
|
|
|
|
|
final logoAsset = await _copyLogoToProject(updatedDeck.themeProfile, dir);
|
|
|
|
|
updatedDeck = updatedDeck.copyWith(themeProfile: logoAsset.profile);
|
|
|
|
|
await _writeImageCaptions(updatedDeck);
|
|
|
|
|
|
|
|
|
|
await _writeTheme(
|
|
|
|
|
themesDir.path,
|
|
|
|
|
updatedDeck.theme,
|
|
|
|
|
updatedDeck.themeProfile,
|
|
|
|
|
logoAsset.cssUrl,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final markdown = _md.generateDeck(updatedDeck);
|
|
|
|
|
await File(filePath).writeAsString(markdown);
|
Add annotation layer (laser, pen, highlighter) over slides
Draw on slides while presenting, kept as a layer fully separate from the
Marp content so the deck stays pure, portable Marp.
Presenter tools (D pen, T highlighter, E eraser, X laser, C clear, Esc
puts the tool away) with a small floating colour/tool bar shown only when
a tool is active. Strokes use coordinates normalized to the 16:9 slide so
they render identically on the laptop and the beamer; in dual-screen mode
the ink and the laser are mirrored live to the audience window.
Persistence (decoupled from the .md):
- In memory the layer is keyed by Slide.id (stable within a session).
- On disk it lives in a sidecar <name>.ink.json next to the deck and as a
separate entry inside the .ocideck package; the markdown is untouched.
- Because slide ids are regenerated on load, the sidecar anchors strokes
by order + a content fingerprint, re-attaching them after reordering and
dropping them when a slide's content changed.
- Deck.annotations carries the layer in memory but is never serialized to
markdown; deckProvider.setAnnotations keeps it out of undo/redo.
flutter analyze is clean, all tests pass (incl. new stroke/codec tests),
and the macOS debug build compiles. Drawing and live beamer sync still
need verification on real hardware.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:14:51 +02:00
|
|
|
// Annotations live in a separate sidecar so the Marp .md stays pure.
|
|
|
|
|
await _writeSidecar(updatedDeck, filePath);
|
2026-06-02 23:28:39 +02:00
|
|
|
return updatedDeck;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<Deck> _hydrateImageCaptions(Deck deck) async {
|
|
|
|
|
final slides = <Slide>[];
|
|
|
|
|
for (final slide in deck.slides) {
|
|
|
|
|
var next = slide;
|
|
|
|
|
if (slide.imagePath.isNotEmpty) {
|
|
|
|
|
final caption = await _captions.getCaption(
|
|
|
|
|
slide.imagePath,
|
|
|
|
|
basePath: deck.projectPath,
|
|
|
|
|
);
|
|
|
|
|
if (caption != null) next = next.copyWith(imageCaption: caption);
|
|
|
|
|
}
|
|
|
|
|
if (slide.imagePath2.isNotEmpty) {
|
|
|
|
|
final caption = await _captions.getCaption(
|
|
|
|
|
slide.imagePath2,
|
|
|
|
|
basePath: deck.projectPath,
|
|
|
|
|
);
|
|
|
|
|
if (caption != null) next = next.copyWith(imageCaption2: caption);
|
|
|
|
|
}
|
|
|
|
|
slides.add(next);
|
|
|
|
|
}
|
|
|
|
|
return deck.copyWith(slides: slides);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _writeImageCaptions(Deck deck) async {
|
|
|
|
|
for (final slide in deck.slides) {
|
|
|
|
|
if (slide.imagePath.isNotEmpty && slide.imageCaption.trim().isNotEmpty) {
|
|
|
|
|
await _captions.saveCaption(
|
|
|
|
|
slide.imagePath,
|
|
|
|
|
slide.imageCaption,
|
|
|
|
|
basePath: deck.projectPath,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (slide.imagePath2.isNotEmpty &&
|
|
|
|
|
slide.imageCaption2.trim().isNotEmpty) {
|
|
|
|
|
await _captions.saveCaption(
|
|
|
|
|
slide.imagePath2,
|
|
|
|
|
slide.imageCaption2,
|
|
|
|
|
basePath: deck.projectPath,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _writeTheme(
|
|
|
|
|
String themesPath,
|
|
|
|
|
String themeName,
|
|
|
|
|
ThemeProfile profile,
|
|
|
|
|
String? logoUrl,
|
|
|
|
|
) async {
|
|
|
|
|
final safeThemeName = themeName.trim().isEmpty ? 'ocideck' : themeName;
|
|
|
|
|
final dest = File(p.join(themesPath, '$safeThemeName.css'));
|
|
|
|
|
try {
|
|
|
|
|
final base = (await rootBundle.loadString(
|
|
|
|
|
'assets/themes/ocideck.css',
|
|
|
|
|
)).replaceFirst('@theme ocideck', '@theme $safeThemeName');
|
|
|
|
|
await dest.writeAsString(_buildThemeCss(base, profile, logoUrl));
|
|
|
|
|
} catch (_) {
|
|
|
|
|
// Asset not bundled in this build context; skip
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<_LogoProjectAsset> _copyLogoToProject(
|
|
|
|
|
ThemeProfile profile,
|
|
|
|
|
String projectPath,
|
|
|
|
|
) async {
|
|
|
|
|
final logoPath = profile.logoPath;
|
|
|
|
|
if (logoPath == null || logoPath.trim().isEmpty) {
|
|
|
|
|
return _LogoProjectAsset(profile, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final normalized = logoPath.replaceAll('\\', '/');
|
|
|
|
|
final relativeLogoPath = p.posix.isRelative(normalized)
|
|
|
|
|
? p.posix.normalize(normalized)
|
|
|
|
|
: null;
|
|
|
|
|
if (relativeLogoPath != null && relativeLogoPath.startsWith('logos/')) {
|
|
|
|
|
return _LogoProjectAsset(
|
|
|
|
|
profile.copyWith(logoPath: relativeLogoPath),
|
|
|
|
|
'../$relativeLogoPath',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var sourcePath = p.isAbsolute(logoPath)
|
|
|
|
|
? logoPath
|
|
|
|
|
: p.normalize(p.join(projectPath, logoPath));
|
|
|
|
|
var src = File(sourcePath);
|
|
|
|
|
if (!await src.exists()) {
|
|
|
|
|
final fallback = await _findExistingProjectLogo(projectPath, normalized);
|
|
|
|
|
if (fallback == null) {
|
|
|
|
|
return _LogoProjectAsset(profile, null);
|
|
|
|
|
}
|
|
|
|
|
sourcePath = fallback;
|
|
|
|
|
src = File(sourcePath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final filename = p.posix.basename(normalized);
|
|
|
|
|
if (filename.isEmpty || filename == '.' || filename == '..') {
|
|
|
|
|
return _LogoProjectAsset(profile, null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final relativePath = p.posix.join('logos', filename);
|
|
|
|
|
final dest = File(p.join(projectPath, relativePath));
|
|
|
|
|
if (!p.equals(src.path, dest.path)) {
|
|
|
|
|
await dest.parent.create(recursive: true);
|
|
|
|
|
await src.copy(dest.path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return _LogoProjectAsset(
|
|
|
|
|
profile.copyWith(logoPath: relativePath),
|
|
|
|
|
'../$relativePath',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<String?> _findExistingProjectLogo(
|
|
|
|
|
String projectPath,
|
|
|
|
|
String normalizedLogoPath,
|
|
|
|
|
) async {
|
|
|
|
|
final filename = p.posix.basename(normalizedLogoPath);
|
|
|
|
|
if (filename.isEmpty || filename == '.' || filename == '..') return null;
|
|
|
|
|
|
|
|
|
|
final candidates = [
|
|
|
|
|
p.join(projectPath, 'logos', filename),
|
|
|
|
|
p.join(projectPath, 'images', filename),
|
|
|
|
|
p.join(projectPath, 'images', 'logo_$filename'),
|
|
|
|
|
];
|
|
|
|
|
for (final candidate in candidates) {
|
|
|
|
|
if (await File(candidate).exists()) return candidate;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _buildThemeCss(String base, ThemeProfile profile, String? logoUrl) {
|
|
|
|
|
final logoCss = logoUrl == null
|
|
|
|
|
? ''
|
|
|
|
|
: '''
|
|
|
|
|
|
|
|
|
|
section.logo-safe {
|
|
|
|
|
${_logoSafePaddingCss(profile)}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
section.split.logo-safe {
|
|
|
|
|
padding: 48px 0 48px var(--split-margin);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
${_splitLogoSafeCss(profile)}
|
|
|
|
|
|
|
|
|
|
section::before {
|
|
|
|
|
content: "";
|
|
|
|
|
position: absolute;
|
|
|
|
|
width: ${profile.logoSize}px;
|
|
|
|
|
height: ${profile.logoSize}px;
|
|
|
|
|
background-image: url("$logoUrl");
|
|
|
|
|
background-size: contain;
|
|
|
|
|
background-repeat: no-repeat;
|
|
|
|
|
background-position: center;
|
|
|
|
|
opacity: 0.9;
|
|
|
|
|
${_logoPositionCss(profile.logoPosition)}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
section.no-logo::before {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
''';
|
|
|
|
|
|
|
|
|
|
return '''
|
|
|
|
|
$base
|
|
|
|
|
|
|
|
|
|
/* OciDeck style profile */
|
|
|
|
|
section {
|
|
|
|
|
background: ${profile.slideBackgroundColor};
|
|
|
|
|
color: ${profile.textColor};
|
|
|
|
|
position: relative;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
section h1,
|
|
|
|
|
section h2,
|
|
|
|
|
section h3,
|
|
|
|
|
section strong {
|
|
|
|
|
color: ${profile.textColor};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
section li::marker {
|
|
|
|
|
color: ${profile.accentColor};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
section.title {
|
|
|
|
|
background: ${profile.titleBackgroundColor};
|
|
|
|
|
color: ${profile.titleTextColor};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
section.title h1,
|
|
|
|
|
section.title h2 {
|
|
|
|
|
color: ${profile.titleTextColor};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
section.section {
|
|
|
|
|
background: ${profile.sectionBackgroundColor};
|
|
|
|
|
color: ${profile.titleTextColor};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
section.section h1 {
|
|
|
|
|
color: ${profile.titleTextColor};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
table {
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
width: 100%;
|
|
|
|
|
font-size: 0.72em;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
th, td {
|
|
|
|
|
border: 1px solid ${profile.accentColor};
|
|
|
|
|
padding: 0.22em 0.45em;
|
|
|
|
|
text-align: left;
|
|
|
|
|
color: ${profile.tableTextColor};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
thead th, tr:first-child th {
|
|
|
|
|
background: ${profile.accentColor};
|
|
|
|
|
color: ${profile.tableHeaderTextColor};
|
|
|
|
|
}
|
|
|
|
|
$logoCss
|
|
|
|
|
''';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _logoPositionCss(String position) {
|
|
|
|
|
switch (position) {
|
|
|
|
|
case 'top-left':
|
|
|
|
|
return 'top: 40px;\n left: 28px;';
|
|
|
|
|
case 'top-right':
|
|
|
|
|
return 'top: 40px;\n right: 28px;';
|
|
|
|
|
case 'bottom-left':
|
|
|
|
|
return 'bottom: 12px;\n left: 28px;';
|
|
|
|
|
case 'bottom-right':
|
|
|
|
|
default:
|
|
|
|
|
return 'bottom: 12px;\n right: 28px;';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _logoSafePaddingCss(ThemeProfile profile) {
|
|
|
|
|
final reserved = profile.logoSize + 64;
|
|
|
|
|
switch (profile.logoPosition) {
|
|
|
|
|
case 'top-left':
|
|
|
|
|
case 'top-right':
|
|
|
|
|
return 'padding-top: ${reserved}px;';
|
|
|
|
|
case 'bottom-left':
|
|
|
|
|
case 'bottom-right':
|
|
|
|
|
default:
|
|
|
|
|
return 'padding-bottom: ${reserved}px;';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _splitLogoSafeCss(ThemeProfile profile) {
|
|
|
|
|
if (profile.logoPosition.endsWith('right')) return '';
|
|
|
|
|
final reserved = profile.logoSize + 24;
|
|
|
|
|
if (profile.logoPosition.startsWith('top')) {
|
|
|
|
|
return '''
|
|
|
|
|
section.split.logo-safe .split-text {
|
|
|
|
|
padding-top: ${reserved}px;
|
|
|
|
|
}
|
|
|
|
|
''';
|
|
|
|
|
}
|
|
|
|
|
return '''
|
|
|
|
|
section.split.logo-safe .split-text {
|
|
|
|
|
padding-bottom: ${reserved}px;
|
|
|
|
|
}
|
|
|
|
|
''';
|
|
|
|
|
}
|
|
|
|
|
}
|