Ocideck/lib/services/annotation_codec.dart
Brenno de Winter 227abf351e 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

103 lines
3.6 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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