From 227abf351e9a7e231ca9a13b3451428c91de5969 Mon Sep 17 00:00:00 2001 From: Brenno de Winter Date: Sun, 7 Jun 2026 11:14:51 +0200 Subject: [PATCH] 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 .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 --- lib/l10n/app_localizations.dart | 28 ++ lib/models/annotation.dart | 72 +++++ lib/models/deck.dart | 9 + lib/services/annotation_codec.dart | 103 +++++++ lib/services/file_service.dart | 49 ++- lib/state/deck_provider.dart | 11 + lib/widgets/app_shell.dart | 2 + .../presentation/annotation_overlay.dart | 250 +++++++++++++++ lib/widgets/presentation/audience_window.dart | 67 +++- .../presentation/fullscreen_presenter.dart | 286 ++++++++++++++++-- test/annotation_test.dart | 80 +++++ 11 files changed, 927 insertions(+), 30 deletions(-) create mode 100644 lib/models/annotation.dart create mode 100644 lib/services/annotation_codec.dart create mode 100644 lib/widgets/presentation/annotation_overlay.dart create mode 100644 test/annotation_test.dart diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 28d246a..9eebff7 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1175,6 +1175,10 @@ const _dutchSourceStrings = { 'Broncode': 'Codice sorgente', 'Programmeertaal': 'Linguaggio di programmazione', 'TLP van deze slide': 'TLP di questa slide', + 'Wis annotaties (C)': 'Cancella annotazioni (C)', + 'Stoppen (Esc)': 'Interrompi (Esc)', + 'Pen · markeerstift · gum': 'Penna · evidenziatore · gomma', + 'Laser · annotaties wissen': 'Laser · cancella annotazioni', 'Plak of typ hier je broncode...': 'Incolla o digita qui il tuo codice sorgente...', 'Overgeslagen': 'Saltata', @@ -1347,6 +1351,10 @@ const _dutchSourceStrings = { 'Broncode': 'Quellcode', 'Programmeertaal': 'Programmiersprache', 'TLP van deze slide': 'TLP dieser Folie', + 'Wis annotaties (C)': 'Anmerkungen löschen (C)', + 'Stoppen (Esc)': 'Beenden (Esc)', + 'Pen · markeerstift · gum': 'Stift · Marker · Radierer', + 'Laser · annotaties wissen': 'Laser · Anmerkungen löschen', 'Plak of typ hier je broncode...': 'Quellcode hier einfügen oder eingeben...', 'Overgeslagen': 'Übersprungen', @@ -1520,6 +1528,10 @@ const _dutchSourceStrings = { 'Broncode': 'Code source', 'Programmeertaal': 'Langage de programmation', 'TLP van deze slide': 'TLP de cette diapositive', + 'Wis annotaties (C)': 'Effacer les annotations (C)', + 'Stoppen (Esc)': 'Arrêter (Esc)', + 'Pen · markeerstift · gum': 'Stylo · surligneur · gomme', + 'Laser · annotaties wissen': 'Laser · effacer les annotations', 'Plak of typ hier je broncode...': 'Collez ou tapez votre code source ici...', 'Overgeslagen': 'Ignorée', @@ -1692,6 +1704,10 @@ const _dutchSourceStrings = { 'Broncode': 'Código fuente', 'Programmeertaal': 'Lenguaje de programación', 'TLP van deze slide': 'TLP de esta diapositiva', + 'Wis annotaties (C)': 'Borrar anotaciones (C)', + 'Stoppen (Esc)': 'Detener (Esc)', + 'Pen · markeerstift · gum': 'Lápiz · marcador · goma', + 'Laser · annotaties wissen': 'Láser · borrar anotaciones', 'Plak of typ hier je broncode...': 'Pega o escribe aquí tu código fuente...', 'Overgeslagen': 'Omitida', @@ -1865,6 +1881,10 @@ const _dutchSourceStrings = { 'Broncode': 'Boarnekoade', 'Programmeertaal': 'Programmeartaal', 'TLP van deze slide': 'TLP fan dizze slide', + 'Wis annotaties (C)': 'Annotaasjes wiskje (C)', + 'Stoppen (Esc)': 'Stopje (Esc)', + 'Pen · markeerstift · gum': 'Pen · markearstift · gom', + 'Laser · annotaties wissen': 'Laser · annotaasjes wiskje', 'Plak of typ hier je broncode...': 'Plak of typ hjir dyn boarnekoade...', 'Overgeslagen': 'Oerslein', 'Kopiëren': 'Kopiearje', @@ -2038,6 +2058,10 @@ const _dutchSourceStrings = { 'Broncode': 'Código fuente', 'Programmeertaal': 'Lenguahe di programashon', 'TLP van deze slide': 'TLP di e slide aki', + 'Wis annotaties (C)': 'Kita anotashonnan (C)', + 'Stoppen (Esc)': 'Stòp (Esc)', + 'Pen · markeerstift · gum': 'Pèn · marker · gòm', + 'Laser · annotaties wissen': 'Laser · kita anotashonnan', 'Plak of typ hier je broncode...': 'Pega òf tek bo código fuente akinan...', 'Overgeslagen': 'Saltá', 'Kopiëren': 'Kopia', @@ -2200,6 +2224,10 @@ const _dutchSourceStringAdditions = { 'Plak of typ hier je broncode...': 'Paste or type your source code here...', 'Programmeertaal': 'Programming language', 'TLP van deze slide': 'TLP of this slide', + 'Wis annotaties (C)': 'Clear annotations (C)', + 'Stoppen (Esc)': 'Stop (Esc)', + 'Pen · markeerstift · gum': 'Pen · highlighter · eraser', + 'Laser · annotaties wissen': 'Laser · clear annotations', 'Platte tekst': 'Plain text', 'Titel (optioneel)': 'Title (optional)', 'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.': diff --git a/lib/models/annotation.dart b/lib/models/annotation.dart new file mode 100644 index 0000000..525aa22 --- /dev/null +++ b/lib/models/annotation.dart @@ -0,0 +1,72 @@ +import 'dart:ui'; + +/// Annotation tools available while presenting. Drawings live in a layer that +/// is fully separate from the Marp content — they are never written to the +/// markdown. +enum InkTool { laser, pen, highlighter, eraser } + +/// A single freehand stroke on the annotation layer. +/// +/// Coordinates are normalized (0..1) within the 16:9 slide rectangle and the +/// width is a fraction of the slide width, so a stroke renders identically on +/// the laptop preview and the beamer regardless of resolution or letterboxing. +class InkStroke { + final InkTool tool; + final int color; // ARGB + final double width; // fraction of the slide width + final List points; // normalized 0..1 + + const InkStroke({ + required this.tool, + required this.color, + required this.width, + required this.points, + }); + + InkStroke copyWith({List? points}) => InkStroke( + tool: tool, + color: color, + width: width, + points: points ?? this.points, + ); + + /// Compact JSON: points are flattened to [x0, y0, x1, y1, …]. + Map toJson() => { + 'tool': tool.name, + 'color': color, + 'width': width, + 'points': [ + for (final p in points) ...[ + _round(p.dx), + _round(p.dy), + ], + ], + }; + + static double _round(double v) => (v * 10000).roundToDouble() / 10000; + + factory InkStroke.fromJson(Map json) { + final raw = (json['points'] as List?)?.cast() ?? const []; + final pts = []; + for (var i = 0; i + 1 < raw.length; i += 2) { + pts.add(Offset(raw[i].toDouble(), raw[i + 1].toDouble())); + } + return InkStroke( + tool: InkTool.values.firstWhere( + (t) => t.name == json['tool'], + orElse: () => InkTool.pen, + ), + color: (json['color'] as num?)?.toInt() ?? 0xFFEF4444, + width: (json['width'] as num?)?.toDouble() ?? 0.004, + points: pts, + ); + } +} + +/// Encode/decode a per-slide map of strokes keyed by slide id. +List> encodeStrokes(List strokes) => + [for (final s in strokes) s.toJson()]; + +List decodeStrokes(List raw) => [ + for (final e in raw) InkStroke.fromJson(Map.from(e as Map)), +]; diff --git a/lib/models/deck.dart b/lib/models/deck.dart index b61675d..c995fb5 100644 --- a/lib/models/deck.dart +++ b/lib/models/deck.dart @@ -1,3 +1,4 @@ +import 'annotation.dart'; import 'slide.dart'; import 'settings.dart'; @@ -108,6 +109,11 @@ class Deck { /// Traffic Light Protocol-classificatie van deze presentatie. final TlpLevel tlp; + /// Annotatielaag: vrije-hand-tekeningen per slide, gekeyd op [Slide.id]. + /// Bewust géén onderdeel van de Marp-markdown — dit wordt los bewaard in een + /// sidecar zodat het deck pure, uitwisselbare Marp blijft. + final Map> annotations; + const Deck({ required this.title, this.theme = 'ocideck', @@ -122,6 +128,7 @@ class Deck { this.description = '', this.keywords = '', this.tlp = TlpLevel.none, + this.annotations = const {}, }); Deck copyWith({ @@ -139,6 +146,7 @@ class Deck { String? description, String? keywords, TlpLevel? tlp, + Map>? annotations, }) { return Deck( title: title ?? this.title, @@ -154,6 +162,7 @@ class Deck { description: description ?? this.description, keywords: keywords ?? this.keywords, tlp: tlp ?? this.tlp, + annotations: annotations ?? this.annotations, ); } } diff --git a/lib/services/annotation_codec.dart b/lib/services/annotation_codec.dart new file mode 100644 index 0000000..53f26ea --- /dev/null +++ b/lib/services/annotation_codec.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; + +import '../models/annotation.dart'; +import '../models/slide.dart'; + +/// Serializes the annotation layer into a sidecar payload that is fully +/// decoupled from the Marp markdown. +/// +/// Slide ids are regenerated every time a deck is parsed, so on disk we anchor +/// each slide's strokes by its position plus a content fingerprint. On load we +/// re-attach strokes to the matching slide (same fingerprint, preferring the +/// same index), and silently drop strokes whose slide no longer exists. +class AnnotationCodec { + static const int version = 1; + + /// A stable hash of a slide's visual content (ignores notes/timing/tlp). + static String fingerprint(Slide s) { + final buf = StringBuffer() + ..write(s.type.index) + ..write('${s.title}') + ..write('${s.subtitle}') + ..write('${s.bullets.join('')}') + ..write('${s.bullets2.join('')}') + ..write('${s.imagePath}') + ..write('${s.imagePath2}') + ..write('${s.quote}') + ..write('${s.quoteAuthor}') + ..write('${s.customMarkdown}') + ..write('${s.codeLanguage}') + ..write('${s.videoPath}') + ..write('${s.tableRows.map((r) => r.join('')).join('')}'); + return _fnv1a(buf.toString()); + } + + static String _fnv1a(String input) { + var hash = 0x811c9dc5; + for (final unit in input.codeUnits) { + hash ^= unit; + hash = (hash * 0x01000193) & 0xFFFFFFFF; + } + return hash.toRadixString(16).padLeft(8, '0'); + } + + /// Encode the id-keyed [annotations] for [slides] into a JSON string, or null + /// when there is nothing to store. + static String? encode(List slides, Map> annotations) { + final entries = >[]; + for (var i = 0; i < slides.length; i++) { + final strokes = annotations[slides[i].id]; + if (strokes == null || strokes.isEmpty) continue; + entries.add({ + 'index': i, + 'fp': fingerprint(slides[i]), + 'strokes': encodeStrokes(strokes), + }); + } + if (entries.isEmpty) return null; + return jsonEncode({'version': version, 'slides': entries}); + } + + /// Decode [json] against the freshly parsed [slides], returning a map keyed by + /// the current slide ids. + static Map> decode(String json, List slides) { + final result = >{}; + try { + final data = jsonDecode(json); + final raw = (data is Map ? data['slides'] : null) as List? ?? const []; + final used = {}; + for (final e in raw) { + final entry = Map.from(e as Map); + final fp = entry['fp'] as String?; + final index = (entry['index'] as num?)?.toInt() ?? -1; + final strokes = decodeStrokes( + (entry['strokes'] as List?) ?? const [], + ); + if (strokes.isEmpty) continue; + + int target = -1; + // Prefer the same index when its fingerprint still matches. + if (index >= 0 && + index < slides.length && + !used.contains(index) && + fingerprint(slides[index]) == fp) { + target = index; + } else { + // Otherwise re-anchor to any unused slide with the same fingerprint. + for (var i = 0; i < slides.length; i++) { + if (!used.contains(i) && fingerprint(slides[i]) == fp) { + target = i; + break; + } + } + } + if (target < 0) continue; // slide gone/changed → drop these strokes + used.add(target); + result[slides[target].id] = strokes; + } + } catch (_) { + return {}; + } + return result; + } +} diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index acc75f2..29147dc 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -9,6 +9,7 @@ import '../models/deck.dart'; import '../l10n/app_localizations.dart'; import '../models/settings.dart'; import '../models/slide.dart'; +import 'annotation_codec.dart'; import 'caption_service.dart'; import 'image_service.dart'; import 'markdown_service.dart'; @@ -145,7 +146,37 @@ class FileService { } final deck = _md.parseDeck(raw, filePath: filePath); if (deck == null) return null; - return _hydrateImageCaptions(deck); + 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 `.md` → `.ink.json`. + String _sidecarPath(String mdPath) => p.setExtension(mdPath, '.ink.json'); + + /// Write the annotation sidecar next to [filePath], or remove it when empty. + Future _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); + } } Future saveDeckAs(Deck deck, {String? initialDirectory}) async { @@ -228,6 +259,20 @@ class FileService { ArchiveFile('${_safeName(deck.title)}.md', mdBytes.length, mdBytes), ); + // 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, + ), + ); + } + // Thema-CSS (zodat het pakket ook in Marp/CLI bruikbaar is). final css = await _packageThemeCss(packDeck.theme, profile, logoRel); if (css != null) { @@ -410,6 +455,8 @@ class FileService { final markdown = _md.generateDeck(updatedDeck); await File(filePath).writeAsString(markdown); + // Annotations live in a separate sidecar so the Marp .md stays pure. + await _writeSidecar(updatedDeck, filePath); return updatedDeck; } diff --git a/lib/state/deck_provider.dart b/lib/state/deck_provider.dart index d056a4c..5301b03 100644 --- a/lib/state/deck_provider.dart +++ b/lib/state/deck_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/legacy.dart'; +import '../models/annotation.dart'; import '../models/deck.dart'; import '../models/settings.dart'; import '../models/slide.dart'; @@ -416,6 +417,16 @@ class DeckNotifier extends StateNotifier { _mutate(deck.copyWith(themeProfile: profile)); } + /// Update the (separate) annotation layer. Kept out of the undo/redo history + /// and the content revision so drawing while presenting stays lightweight; + /// marks the deck dirty so the strokes get saved to the sidecar. + void setAnnotations(Map> annotations) { + final deck = state.deck; + if (deck == null) return; + state = state.copyWith(deck: deck.copyWith(annotations: annotations)); + if (!state.isDirty) state = state.copyWith(isDirty: true); + } + // ── Markdown mode ────────────────────────────────────────────────────────── String generateMarkdown() { diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 39910fd..a09e2fe 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -976,6 +976,8 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { themeProfile: deck.themeProfile, initialIndex: initial, tlp: deck.tlp, + annotations: deck.annotations, + onAnnotationsChanged: deckNotifier.setAnnotations, ); } diff --git a/lib/widgets/presentation/annotation_overlay.dart b/lib/widgets/presentation/annotation_overlay.dart new file mode 100644 index 0000000..b4a2ed3 --- /dev/null +++ b/lib/widgets/presentation/annotation_overlay.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import '../../models/annotation.dart'; + +/// A transparent drawing plane that sits on top of a 16:9 slide canvas. It is +/// used both interactively (presenter laptop) and display-only (beamer). +/// +/// All stroke coordinates are normalized to this box (0..1), so the same data +/// renders identically wherever the slide is shown. +class AnnotationLayer extends StatefulWidget { + /// Committed strokes for the current slide. + final List strokes; + + /// Active tool, or null when annotation is off (pointer passes through to the + /// slide so clicks still advance). + final InkTool? tool; + + /// Current pen colour (ARGB) and width (fraction of slide width). + final int color; + final double width; + + /// Whether this layer captures pointer input (presenter) or only renders + /// (beamer). + final bool interactive; + + /// Laser position to display (normalized), used by the beamer. + final Offset? laserPoint; + + /// Called with the new committed list after a draw or erase. + final ValueChanged>? onStrokesChanged; + + /// Called as the laser moves (normalized), or null when it leaves. + final ValueChanged? onLaserMove; + + const AnnotationLayer({ + super.key, + required this.strokes, + this.tool, + this.color = 0xFFEF4444, + this.width = 0.004, + this.interactive = false, + this.laserPoint, + this.onStrokesChanged, + this.onLaserMove, + }); + + @override + State createState() => _AnnotationLayerState(); +} + +class _AnnotationLayerState extends State { + List _active = const []; + Offset? _laser; + Size _size = Size.zero; + + bool get _drawing => + widget.tool == InkTool.pen || widget.tool == InkTool.highlighter; + + Offset _norm(Offset local) => _size.shortestSide == 0 + ? Offset.zero + : Offset( + (local.dx / _size.width).clamp(0.0, 1.0), + (local.dy / _size.height).clamp(0.0, 1.0), + ); + + void _commitActive() { + if (_active.length < 2) { + setState(() => _active = const []); + return; + } + final stroke = InkStroke( + tool: widget.tool!, + color: widget.color, + width: widget.width, + points: List.from(_active), + ); + final next = [...widget.strokes, stroke]; + setState(() => _active = const []); + widget.onStrokesChanged?.call(next); + } + + void _eraseAt(Offset norm) { + const threshold = 0.025; + final kept = [ + for (final s in widget.strokes) + if (!s.points.any((p) => (p - norm).distance < threshold)) s, + ]; + if (kept.length != widget.strokes.length) { + widget.onStrokesChanged?.call(kept); + } + } + + void _down(Offset local) { + final n = _norm(local); + switch (widget.tool) { + case InkTool.pen: + case InkTool.highlighter: + setState(() => _active = [n]); + case InkTool.eraser: + _eraseAt(n); + case InkTool.laser: + setState(() => _laser = n); + widget.onLaserMove?.call(n); + case null: + break; + } + } + + void _move(Offset local) { + final n = _norm(local); + switch (widget.tool) { + case InkTool.pen: + case InkTool.highlighter: + if (_active.isNotEmpty) setState(() => _active = [..._active, n]); + case InkTool.eraser: + _eraseAt(n); + case InkTool.laser: + setState(() => _laser = n); + widget.onLaserMove?.call(n); + case null: + break; + } + } + + void _up() { + if (_drawing) _commitActive(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (_, constraints) { + _size = Size(constraints.maxWidth, constraints.maxHeight); + final painter = CustomPaint( + size: _size, + painter: _InkPainter( + strokes: widget.strokes, + active: _active, + activeTool: widget.tool, + activeColor: widget.color, + activeWidth: widget.width, + laser: widget.interactive ? _laser : widget.laserPoint, + ), + ); + + // Off, or non-interactive: let pointer fall through to the slide. + if (!widget.interactive || widget.tool == null) { + return IgnorePointer(child: painter); + } + + return Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (e) => _down(e.localPosition), + onPointerMove: (e) => _move(e.localPosition), + onPointerHover: widget.tool == InkTool.laser + ? (e) => _move(e.localPosition) + : null, + onPointerUp: (_) => _up(), + child: MouseRegion( + cursor: widget.tool == InkTool.laser + ? SystemMouseCursors.none + : SystemMouseCursors.precise, + onExit: widget.tool == InkTool.laser + ? (_) { + setState(() => _laser = null); + widget.onLaserMove?.call(null); + } + : null, + child: painter, + ), + ); + }, + ); + } +} + +class _InkPainter extends CustomPainter { + final List strokes; + final List active; + final InkTool? activeTool; + final int activeColor; + final double activeWidth; + final Offset? laser; + + _InkPainter({ + required this.strokes, + required this.active, + required this.activeTool, + required this.activeColor, + required this.activeWidth, + required this.laser, + }); + + @override + void paint(Canvas canvas, Size size) { + for (final s in strokes) { + _drawStroke(canvas, size, s.points, s.tool, s.color, s.width); + } + if (active.length >= 2 && + (activeTool == InkTool.pen || activeTool == InkTool.highlighter)) { + _drawStroke(canvas, size, active, activeTool!, activeColor, activeWidth); + } + if (laser != null) _drawLaser(canvas, size, laser!); + } + + void _drawStroke( + Canvas canvas, + Size size, + List pts, + InkTool tool, + int color, + double width, + ) { + if (pts.isEmpty) return; + final highlighter = tool == InkTool.highlighter; + final paint = Paint() + ..color = Color(color).withValues(alpha: highlighter ? 0.35 : 1.0) + ..strokeWidth = width * size.width + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + if (pts.length < 2) return; + final path = Path() + ..moveTo(pts.first.dx * size.width, pts.first.dy * size.height); + for (var i = 1; i < pts.length; i++) { + path.lineTo(pts[i].dx * size.width, pts[i].dy * size.height); + } + canvas.drawPath(path, paint); + } + + void _drawLaser(Canvas canvas, Size size, Offset n) { + final c = Offset(n.dx * size.width, n.dy * size.height); + final r = size.width * 0.012; + canvas.drawCircle( + c, + r * 2.2, + Paint() + ..color = const Color(0xFFFF3B30).withValues(alpha: 0.25) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, r), + ); + canvas.drawCircle(c, r, Paint()..color = const Color(0xFFFF3B30)); + canvas.drawCircle( + c, + r * 0.45, + Paint()..color = Colors.white.withValues(alpha: 0.9), + ); + } + + @override + bool shouldRepaint(_InkPainter old) => true; +} diff --git a/lib/widgets/presentation/audience_window.dart b/lib/widgets/presentation/audience_window.dart index 02858eb..03fd352 100644 --- a/lib/widgets/presentation/audience_window.dart +++ b/lib/widgets/presentation/audience_window.dart @@ -1,12 +1,14 @@ import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../../models/annotation.dart'; import '../../models/deck.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; import '../../services/markdown_service.dart'; import '../../utils/url_launcher_util.dart'; import '../slides/slide_preview.dart'; +import 'annotation_overlay.dart'; /// Channel the audience (beamer) window listens on for updates from the /// presenter (laptop) window. @@ -41,6 +43,11 @@ class _AudienceWindowAppState extends State { int _index = 0; int _blank = 0; // 0 = none, 1 = black, 2 = white + // Annotation layer, keyed by slide index (the beamer has no stable ids). + final Map> _ink = {}; + int? _laserIndex; + Offset? _laserPoint; + @override void initState() { super.initState(); @@ -51,6 +58,14 @@ class _AudienceWindowAppState extends State { _slides = deck?.slides ?? const []; _theme = deck?.themeProfile ?? const ThemeProfile(); _tlp = deck?.tlp ?? TlpLevel.none; + // Pre-existing strokes passed at creation, keyed by index. + final ink = widget.args['ink']; + if (ink is Map) { + ink.forEach((k, v) { + final i = int.tryParse('$k'); + if (i != null && v is List) _ink[i] = decodeStrokes(v); + }); + } audienceChannel.setMethodCallHandler(_onPresenterCall); } @@ -68,6 +83,23 @@ class _AudienceWindowAppState extends State { setState(() { _index = (m['index'] as num?)?.toInt() ?? _index; _blank = (m['blank'] as num?)?.toInt() ?? 0; + _laserPoint = null; // laser never carries over to another slide + }); + case 'ink': + final m = Map.from(call.arguments as Map); + final i = (m['index'] as num?)?.toInt(); + if (i == null || !mounted) return null; + setState(() => _ink[i] = decodeStrokes((m['strokes'] as List?) ?? const [])); + case 'laser': + final m = Map.from(call.arguments as Map); + final i = (m['index'] as num?)?.toInt(); + final pt = m['point'] as List?; + if (!mounted) return null; + setState(() { + _laserIndex = i; + _laserPoint = pt == null + ? null + : Offset((pt[0] as num).toDouble(), (pt[1] as num).toDouble()); }); case 'close': try { @@ -123,18 +155,29 @@ class _AudienceWindowAppState extends State { child: SizedBox( width: slideW, height: slideH, - child: SlidePreviewWidget( - slide: slide, - projectPath: _projectPath, - themeProfile: _theme, - onLinkTap: openExternalUrl, - slideNumber: _index + 1, - slideCount: _slides.length, - tlp: _tlp, - enableMedia: true, - autoplayMedia: true, - // Audio finishing on the beamer drives the presenter's auto-advance. - onAudioComplete: () => _send('audioComplete'), + child: Stack( + fit: StackFit.expand, + children: [ + SlidePreviewWidget( + slide: slide, + projectPath: _projectPath, + themeProfile: _theme, + onLinkTap: openExternalUrl, + slideNumber: _index + 1, + slideCount: _slides.length, + tlp: _tlp, + enableMedia: true, + autoplayMedia: true, + // Audio finishing on the beamer drives the presenter's + // auto-advance. + onAudioComplete: () => _send('audioComplete'), + ), + AnnotationLayer( + strokes: _ink[_index] ?? const [], + interactive: false, + laserPoint: _laserIndex == _index ? _laserPoint : null, + ), + ], ), ), ); diff --git a/lib/widgets/presentation/fullscreen_presenter.dart b/lib/widgets/presentation/fullscreen_presenter.dart index 5f8220f..72daf8c 100644 --- a/lib/widgets/presentation/fullscreen_presenter.dart +++ b/lib/widgets/presentation/fullscreen_presenter.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:screen_retriever/screen_retriever.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:window_manager/window_manager.dart'; +import '../../models/annotation.dart'; import '../../models/deck.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; @@ -14,6 +15,7 @@ import '../../services/markdown_service.dart'; import '../../utils/url_launcher_util.dart'; import '../../l10n/app_localizations.dart'; import '../slides/slide_preview.dart'; +import 'annotation_overlay.dart'; import 'audience_window.dart'; /// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint). @@ -31,6 +33,11 @@ class FullscreenPresenter extends StatefulWidget { /// for the classic single-screen mode. final WindowController? audienceWindow; + /// Annotation layer keyed by [Slide.id], and a callback to persist changes + /// made while presenting back to the deck. + final Map> initialAnnotations; + final void Function(Map>)? onAnnotationsChanged; + const FullscreenPresenter({ super.key, required this.slides, @@ -39,6 +46,8 @@ class FullscreenPresenter extends StatefulWidget { required this.initialIndex, this.tlp = TlpLevel.none, this.audienceWindow, + this.initialAnnotations = const {}, + this.onAnnotationsChanged, }); /// Entry point used by the app: pick dual-screen mode when a second display is @@ -51,6 +60,8 @@ class FullscreenPresenter extends StatefulWidget { required ThemeProfile themeProfile, required int initialIndex, TlpLevel tlp = TlpLevel.none, + Map> annotations = const {}, + void Function(Map>)? onAnnotationsChanged, }) async { var displayCount = 0; if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { @@ -76,6 +87,8 @@ class FullscreenPresenter extends StatefulWidget { themeProfile: themeProfile, initialIndex: initialIndex, tlp: tlp, + annotations: annotations, + onAnnotationsChanged: onAnnotationsChanged, ); } else { await show( @@ -85,6 +98,8 @@ class FullscreenPresenter extends StatefulWidget { themeProfile: themeProfile, initialIndex: initialIndex, tlp: tlp, + annotations: annotations, + onAnnotationsChanged: onAnnotationsChanged, ); } } @@ -96,6 +111,8 @@ class FullscreenPresenter extends StatefulWidget { required ThemeProfile themeProfile, required int initialIndex, TlpLevel tlp = TlpLevel.none, + Map> annotations = const {}, + void Function(Map>)? onAnnotationsChanged, }) async { final hadWakeLock = await _wakeLockEnabled(); await _enableWakeLock(); @@ -112,6 +129,8 @@ class FullscreenPresenter extends StatefulWidget { themeProfile: themeProfile, initialIndex: initialIndex, tlp: tlp, + initialAnnotations: annotations, + onAnnotationsChanged: onAnnotationsChanged, ), transitionsBuilder: (context, animation, secondary, child) => FadeTransition(opacity: animation, child: child), @@ -135,6 +154,8 @@ class FullscreenPresenter extends StatefulWidget { required ThemeProfile themeProfile, required int initialIndex, TlpLevel tlp = TlpLevel.none, + Map> annotations = const {}, + void Function(Map>)? onAnnotationsChanged, }) async { // A self-contained markdown deck is the payload for the audience window; it // carries the slides, the style profile and the TLP level in one string. @@ -147,10 +168,20 @@ class FullscreenPresenter extends StatefulWidget { tlp: tlp, ), ); + // Pre-existing annotations re-keyed by index so the beamer shows them + // immediately (the audience window has no stable slide ids of its own). + final inkByIndex = {}; + for (var i = 0; i < slides.length; i++) { + final strokes = annotations[slides[i].id]; + if (strokes != null && strokes.isNotEmpty) { + inkByIndex['$i'] = encodeStrokes(strokes); + } + } final argument = jsonEncode({ 'markdown': markdown, 'projectPath': projectPath, 'index': initialIndex, + 'ink': inkByIndex, }); WindowController? audience; @@ -172,6 +203,8 @@ class FullscreenPresenter extends StatefulWidget { themeProfile: themeProfile, initialIndex: initialIndex, tlp: tlp, + annotations: annotations, + onAnnotationsChanged: onAnnotationsChanged, ); } return; @@ -192,6 +225,8 @@ class FullscreenPresenter extends StatefulWidget { initialIndex: initialIndex, tlp: tlp, audienceWindow: audience, + initialAnnotations: annotations, + onAnnotationsChanged: onAnnotationsChanged, ), transitionsBuilder: (context, animation, secondary, child) => FadeTransition(opacity: animation, child: child), @@ -307,12 +342,35 @@ class _FullscreenPresenterState extends State { int? _lastSentIndex; int? _lastSentBlank; + // ── Annotatielaag ───────────────────────────────────────────────────────── + /// Strokes per slide, keyed by [Slide.id] (stable within the session). + late Map> _ink; + + /// Active annotation tool, or null when annotation is off. + InkTool? _tool; + int _inkColor = 0xFFEF4444; // rood + static const _penWidth = 0.004; + static const _highlighterWidth = 0.022; + DateTime _lastLaserSent = DateTime.fromMillisecondsSinceEpoch(0); + + double get _toolWidth => + _tool == InkTool.highlighter ? _highlighterWidth : _penWidth; + + List get _currentStrokes { + final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id; + return _ink[id] ?? const []; + } + @override void initState() { super.initState(); _index = widget.initialIndex; _startTime = DateTime.now(); _focusNode = FocusNode(); + _ink = { + for (final e in widget.initialAnnotations.entries) + e.key: List.from(e.value), + }; if (_dual) { // The laptop shows the presenter view; the slide lives on the beamer. _presenterView = true; @@ -363,11 +421,71 @@ class _FullscreenPresenterState extends State { if (aw == null) return; final blank = _blankCode; if (_index == _lastSentIndex && blank == _lastSentBlank) return; + final indexChanged = _index != _lastSentIndex; _lastSentIndex = _index; _lastSentBlank = blank; audienceChannel .invokeMethod('update', {'index': _index, 'blank': blank}) .catchError((_) => null); + // On a slide change, push that slide's strokes so saved/earlier ink shows. + if (indexChanged) _pushInk(); + } + + // ── Annotatielaag ───────────────────────────────────────────────────────── + + /// Send the current slide's strokes to the beamer (keyed by index there). + void _pushInk() { + if (widget.audienceWindow == null) return; + audienceChannel + .invokeMethod('ink', { + 'index': _index, + 'strokes': encodeStrokes(_currentStrokes), + }) + .catchError((_) => null); + } + + void _onStrokesChanged(List strokes) { + final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id; + setState(() { + if (strokes.isEmpty) { + _ink.remove(id); + } else { + _ink[id] = strokes; + } + }); + widget.onAnnotationsChanged?.call(_ink); + _pushInk(); + } + + void _onLaserMove(Offset? point) { + if (widget.audienceWindow == null) return; + final now = DateTime.now(); + // Throttle to keep the channel calm; always send the "gone" (null) event. + if (point != null && + now.difference(_lastLaserSent) < const Duration(milliseconds: 33)) { + return; + } + _lastLaserSent = now; + audienceChannel + .invokeMethod('laser', { + 'index': _index, + 'point': point == null ? null : [point.dx, point.dy], + }) + .catchError((_) => null); + } + + /// Select a tool, or toggle it off when it is already active. + void _setTool(InkTool tool) { + setState(() => _tool = _tool == tool ? null : tool); + if (_tool != InkTool.laser) _onLaserMove(null); // hide laser on tool switch + } + + void _clearCurrentInk() { + final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id; + if (!_ink.containsKey(id)) return; + setState(() => _ink.remove(id)); + widget.onAnnotationsChanged?.call(_ink); + _pushInk(); } /// Decode the current slide's images plus its neighbours into the image cache @@ -779,9 +897,27 @@ class _FullscreenPresenterState extends State { case LogicalKeyboardKey.keyS: _cycleDisplay(); return KeyEventResult.handled; + case LogicalKeyboardKey.keyD: + _setTool(InkTool.pen); + return KeyEventResult.handled; + case LogicalKeyboardKey.keyT: + _setTool(InkTool.highlighter); + return KeyEventResult.handled; + case LogicalKeyboardKey.keyE: + _setTool(InkTool.eraser); + return KeyEventResult.handled; + case LogicalKeyboardKey.keyX: + _setTool(InkTool.laser); + return KeyEventResult.handled; + case LogicalKeyboardKey.keyC: + _clearCurrentInk(); + return KeyEventResult.handled; case LogicalKeyboardKey.escape: - // Gelaagd: getypt nummer wissen, dan blanco scherm, dan pas afsluiten. - if (_typed.isNotEmpty) { + // Gelaagd: gereedschap weg, getypt nummer wissen, blanco scherm, afsluiten. + if (_tool != null) { + setState(() => _tool = null); + _onLaserMove(null); + } else if (_typed.isNotEmpty) { _clearTyped(); } else if (_blank != _Blank.none) { setState(() => _blank = _Blank.none); @@ -860,6 +996,13 @@ class _FullscreenPresenterState extends State { ? _buildPresenterView(context) : _buildAudienceView(context), if (_gridOpen) Positioned.fill(child: _buildGridOverlay()), + if (_tool != null && !_gridOpen && !_helpOpen) + Positioned( + left: 0, + right: 0, + bottom: 16, + child: Center(child: _buildAnnotationToolbar()), + ), if (_typed.isNotEmpty) Positioned( left: 0, @@ -874,6 +1017,94 @@ class _FullscreenPresenterState extends State { ); } + /// Zwevende balk met annotatiegereedschap, kleuren en wissen. + Widget _buildAnnotationToolbar() { + const palette = [ + 0xFFEF4444, // rood + 0xFFF59E0B, // amber + 0xFF22C55E, // groen + 0xFF3B82F6, // blauw + 0xFFFFFFFF, // wit + 0xFF111111, // zwart + ]; + Widget toolBtn(InkTool tool, IconData icon, String tip) { + final active = _tool == tool; + return Tooltip( + message: tip, + child: IconButton( + onPressed: () => _setTool(tool), + icon: Icon(icon, size: 20), + color: active ? const Color(0xFF60A5FA) : Colors.white70, + style: IconButton.styleFrom( + backgroundColor: active ? Colors.white10 : Colors.transparent, + ), + visualDensity: VisualDensity.compact, + ), + ); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF2A2A2A)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + toolBtn(InkTool.pen, Icons.edit, 'Pen (D)'), + toolBtn(InkTool.highlighter, Icons.brush, 'Markeerstift (T)'), + toolBtn(InkTool.eraser, Icons.cleaning_services_outlined, 'Gum (E)'), + toolBtn(InkTool.laser, Icons.my_location, 'Laser (X)'), + const SizedBox(width: 8), + Container(width: 1, height: 22, color: Colors.white24), + const SizedBox(width: 8), + for (final c in palette) + GestureDetector( + onTap: () => setState(() => _inkColor = c), + child: Container( + width: 20, + height: 20, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: Color(c), + shape: BoxShape.circle, + border: Border.all( + color: _inkColor == c ? Colors.white : Colors.white24, + width: _inkColor == c ? 2.5 : 1, + ), + ), + ), + ), + const SizedBox(width: 8), + Container(width: 1, height: 22, color: Colors.white24), + Tooltip( + message: context.l10n.d('Wis annotaties (C)'), + child: IconButton( + onPressed: _clearCurrentInk, + icon: const Icon(Icons.delete_outline, size: 20), + color: Colors.white70, + visualDensity: VisualDensity.compact, + ), + ), + Tooltip( + message: context.l10n.d('Stoppen (Esc)'), + child: IconButton( + onPressed: () { + setState(() => _tool = null); + _onLaserMove(null); + }, + icon: const Icon(Icons.close, size: 20), + color: Colors.white70, + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + ); + } + /// Badge met het getypte slidenummer ("→ 12 / 28 · Enter"). Widget _buildTypedBadge(int total) { return Container( @@ -919,6 +1150,11 @@ class _FullscreenPresenterState extends State { ('P', l10n.d('Presenter view (notities, klok)')), ('S', l10n.d('Scherm wisselen (meerdere schermen)')), ('B · W', l10n.d('Zwart · wit scherm')), + ( + 'D · T · E', + l10n.d('Pen · markeerstift · gum'), + ), + ('X · C', l10n.d('Laser · annotaties wissen')), ('R', l10n.d('Verstreken tijd resetten')), ('A', l10n.d('Automatische modus aan/uit')), ('L', l10n.d('Herhalen (loop) aan/uit')), @@ -1044,21 +1280,37 @@ class _FullscreenPresenterState extends State { child: SizedBox( width: slideW, height: slideH, - child: SlidePreviewWidget( - slide: slide, - projectPath: widget.projectPath, - themeProfile: widget.themeProfile, - onLinkTap: openExternalUrl, - slideNumber: _index + 1, - slideCount: widget.slides.length, - tlp: widget.tlp, - // Tijdens het presenteren speelt media en starten audio/video - // vanzelf; het audio-einde stuurt de auto-advance aan. In dual- - // schermmodus speelt de media op het beamervenster, niet hier, - // anders zou het geluid dubbel klinken. - enableMedia: !_dual, - autoplayMedia: !_dual, - onAudioComplete: _onAudioCompleted, + child: Stack( + fit: StackFit.expand, + children: [ + SlidePreviewWidget( + slide: slide, + projectPath: widget.projectPath, + themeProfile: widget.themeProfile, + onLinkTap: openExternalUrl, + slideNumber: _index + 1, + slideCount: widget.slides.length, + tlp: widget.tlp, + // Tijdens het presenteren speelt media en starten audio/video + // vanzelf; het audio-einde stuurt de auto-advance aan. In dual- + // schermmodus speelt de media op het beamervenster, niet hier, + // anders zou het geluid dubbel klinken. + enableMedia: !_dual, + autoplayMedia: !_dual, + onAudioComplete: _onAudioCompleted, + ), + // Annotatielaag bovenop de dia. Laat klikken door wanneer er + // geen gereedschap actief is (zodat tikken blijft doorbladeren). + AnnotationLayer( + strokes: _currentStrokes, + tool: _tool, + color: _inkColor, + width: _toolWidth, + interactive: true, + onStrokesChanged: _onStrokesChanged, + onLaserMove: _onLaserMove, + ), + ], ), ), ); diff --git a/test/annotation_test.dart b/test/annotation_test.dart new file mode 100644 index 0000000..d169b60 --- /dev/null +++ b/test/annotation_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/annotation.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/services/annotation_codec.dart'; + +void main() { + group('InkStroke JSON', () { + test('round-trips tool, color, width and points', () { + const stroke = InkStroke( + tool: InkTool.highlighter, + color: 0xFF22C55E, + width: 0.022, + points: [Offset(0.1, 0.2), Offset(0.3, 0.45)], + ); + final back = InkStroke.fromJson(stroke.toJson()); + expect(back.tool, InkTool.highlighter); + expect(back.color, 0xFF22C55E); + expect(back.width, closeTo(0.022, 1e-9)); + expect(back.points.length, 2); + expect(back.points[1].dx, closeTo(0.3, 1e-4)); + expect(back.points[1].dy, closeTo(0.45, 1e-4)); + }); + }); + + group('AnnotationCodec', () { + InkStroke stroke() => const InkStroke( + tool: InkTool.pen, + color: 0xFFEF4444, + width: 0.004, + points: [Offset(0.1, 0.1), Offset(0.2, 0.2)], + ); + + test('encodes nothing when there are no strokes', () { + final slides = [Slide.create(SlideType.bullets)]; + expect(AnnotationCodec.encode(slides, {}), isNull); + }); + + test('round-trips strokes for the same deck', () { + final slides = [ + Slide.create(SlideType.bullets).copyWith(title: 'A'), + Slide.create(SlideType.bullets).copyWith(title: 'B'), + ]; + final ann = { + slides[1].id: [stroke()], + }; + final json = AnnotationCodec.encode(slides, ann)!; + final back = AnnotationCodec.decode(json, slides); + expect(back.keys, [slides[1].id]); + expect(back[slides[1].id]!.single.points.length, 2); + }); + + test('re-anchors strokes to the matching slide after reordering', () { + final a = Slide.create(SlideType.bullets).copyWith(title: 'A'); + final b = Slide.create(SlideType.bullets).copyWith(title: 'B'); + final json = AnnotationCodec.encode([a, b], { + a.id: [stroke()], + })!; + + // Reload parses fresh slides with NEW ids but identical content, in a + // different order. + final a2 = Slide.create(SlideType.bullets).copyWith(title: 'A'); + final b2 = Slide.create(SlideType.bullets).copyWith(title: 'B'); + final back = AnnotationCodec.decode(json, [b2, a2]); + expect(back.containsKey(a2.id), isTrue); + expect(back.containsKey(b2.id), isFalse); + }); + + test('drops strokes when the slide content changed', () { + final a = Slide.create(SlideType.bullets).copyWith(title: 'A'); + final json = AnnotationCodec.encode([a], { + a.id: [stroke()], + })!; + final edited = Slide.create( + SlideType.bullets, + ).copyWith(title: 'A (changed)'); + final back = AnnotationCodec.decode(json, [edited]); + expect(back, isEmpty); + }); + }); +}