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>
This commit is contained in:
Brenno de Winter 2026-06-07 11:14:51 +02:00
parent d1862935ab
commit 227abf351e
11 changed files with 927 additions and 30 deletions

View file

@ -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.':

View file

@ -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<Offset> points; // normalized 0..1
const InkStroke({
required this.tool,
required this.color,
required this.width,
required this.points,
});
InkStroke copyWith({List<Offset>? points}) => InkStroke(
tool: tool,
color: color,
width: width,
points: points ?? this.points,
);
/// Compact JSON: points are flattened to [x0, y0, x1, y1, ].
Map<String, dynamic> 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<String, dynamic> json) {
final raw = (json['points'] as List?)?.cast<num>() ?? const [];
final pts = <Offset>[];
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<Map<String, dynamic>> encodeStrokes(List<InkStroke> strokes) =>
[for (final s in strokes) s.toJson()];
List<InkStroke> decodeStrokes(List<dynamic> raw) => [
for (final e in raw) InkStroke.fromJson(Map<String, dynamic>.from(e as Map)),
];

View file

@ -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<String, List<InkStroke>> 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<String, List<InkStroke>>? 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,
);
}
}

View file

@ -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<Slide> slides, Map<String, List<InkStroke>> annotations) {
final entries = <Map<String, dynamic>>[];
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<String, List<InkStroke>> decode(String json, List<Slide> slides) {
final result = <String, List<InkStroke>>{};
try {
final data = jsonDecode(json);
final raw = (data is Map ? data['slides'] : null) as List? ?? const [];
final used = <int>{};
for (final e in raw) {
final entry = Map<String, dynamic>.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;
}
}

View file

@ -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 `<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);
}
}
Future<String?> 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;
}

View file

@ -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<DeckState> {
_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<String, List<InkStroke>> 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() {

View file

@ -976,6 +976,8 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
themeProfile: deck.themeProfile,
initialIndex: initial,
tlp: deck.tlp,
annotations: deck.annotations,
onAnnotationsChanged: deckNotifier.setAnnotations,
);
}

View file

@ -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<InkStroke> 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<List<InkStroke>>? onStrokesChanged;
/// Called as the laser moves (normalized), or null when it leaves.
final ValueChanged<Offset?>? 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<AnnotationLayer> createState() => _AnnotationLayerState();
}
class _AnnotationLayerState extends State<AnnotationLayer> {
List<Offset> _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<Offset>.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<InkStroke> strokes;
final List<Offset> 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<Offset> 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;
}

View file

@ -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<AudienceWindowApp> {
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<int, List<InkStroke>> _ink = {};
int? _laserIndex;
Offset? _laserPoint;
@override
void initState() {
super.initState();
@ -51,6 +58,14 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
_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<AudienceWindowApp> {
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<String, dynamic>.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<String, dynamic>.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<AudienceWindowApp> {
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,
),
],
),
),
);

View file

@ -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<String, List<InkStroke>> initialAnnotations;
final void Function(Map<String, List<InkStroke>>)? 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<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? 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<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? 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<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? 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 = <String, dynamic>{};
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<FullscreenPresenter> {
int? _lastSentIndex;
int? _lastSentBlank;
// Annotatielaag
/// Strokes per slide, keyed by [Slide.id] (stable within the session).
late Map<String, List<InkStroke>> _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<InkStroke> 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<InkStroke>.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<FullscreenPresenter> {
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<InkStroke> 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<FullscreenPresenter> {
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<FullscreenPresenter> {
? _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<FullscreenPresenter> {
);
}
/// 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<FullscreenPresenter> {
('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<FullscreenPresenter> {
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,
),
],
),
),
);

80
test/annotation_test.dart Normal file
View file

@ -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);
});
});
}