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:
parent
d1862935ab
commit
227abf351e
11 changed files with 927 additions and 30 deletions
|
|
@ -1175,6 +1175,10 @@ const _dutchSourceStrings = {
|
||||||
'Broncode': 'Codice sorgente',
|
'Broncode': 'Codice sorgente',
|
||||||
'Programmeertaal': 'Linguaggio di programmazione',
|
'Programmeertaal': 'Linguaggio di programmazione',
|
||||||
'TLP van deze slide': 'TLP di questa slide',
|
'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...':
|
'Plak of typ hier je broncode...':
|
||||||
'Incolla o digita qui il tuo codice sorgente...',
|
'Incolla o digita qui il tuo codice sorgente...',
|
||||||
'Overgeslagen': 'Saltata',
|
'Overgeslagen': 'Saltata',
|
||||||
|
|
@ -1347,6 +1351,10 @@ const _dutchSourceStrings = {
|
||||||
'Broncode': 'Quellcode',
|
'Broncode': 'Quellcode',
|
||||||
'Programmeertaal': 'Programmiersprache',
|
'Programmeertaal': 'Programmiersprache',
|
||||||
'TLP van deze slide': 'TLP dieser Folie',
|
'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...':
|
'Plak of typ hier je broncode...':
|
||||||
'Quellcode hier einfügen oder eingeben...',
|
'Quellcode hier einfügen oder eingeben...',
|
||||||
'Overgeslagen': 'Übersprungen',
|
'Overgeslagen': 'Übersprungen',
|
||||||
|
|
@ -1520,6 +1528,10 @@ const _dutchSourceStrings = {
|
||||||
'Broncode': 'Code source',
|
'Broncode': 'Code source',
|
||||||
'Programmeertaal': 'Langage de programmation',
|
'Programmeertaal': 'Langage de programmation',
|
||||||
'TLP van deze slide': 'TLP de cette diapositive',
|
'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...':
|
'Plak of typ hier je broncode...':
|
||||||
'Collez ou tapez votre code source ici...',
|
'Collez ou tapez votre code source ici...',
|
||||||
'Overgeslagen': 'Ignorée',
|
'Overgeslagen': 'Ignorée',
|
||||||
|
|
@ -1692,6 +1704,10 @@ const _dutchSourceStrings = {
|
||||||
'Broncode': 'Código fuente',
|
'Broncode': 'Código fuente',
|
||||||
'Programmeertaal': 'Lenguaje de programación',
|
'Programmeertaal': 'Lenguaje de programación',
|
||||||
'TLP van deze slide': 'TLP de esta diapositiva',
|
'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...':
|
'Plak of typ hier je broncode...':
|
||||||
'Pega o escribe aquí tu código fuente...',
|
'Pega o escribe aquí tu código fuente...',
|
||||||
'Overgeslagen': 'Omitida',
|
'Overgeslagen': 'Omitida',
|
||||||
|
|
@ -1865,6 +1881,10 @@ const _dutchSourceStrings = {
|
||||||
'Broncode': 'Boarnekoade',
|
'Broncode': 'Boarnekoade',
|
||||||
'Programmeertaal': 'Programmeartaal',
|
'Programmeertaal': 'Programmeartaal',
|
||||||
'TLP van deze slide': 'TLP fan dizze slide',
|
'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...',
|
'Plak of typ hier je broncode...': 'Plak of typ hjir dyn boarnekoade...',
|
||||||
'Overgeslagen': 'Oerslein',
|
'Overgeslagen': 'Oerslein',
|
||||||
'Kopiëren': 'Kopiearje',
|
'Kopiëren': 'Kopiearje',
|
||||||
|
|
@ -2038,6 +2058,10 @@ const _dutchSourceStrings = {
|
||||||
'Broncode': 'Código fuente',
|
'Broncode': 'Código fuente',
|
||||||
'Programmeertaal': 'Lenguahe di programashon',
|
'Programmeertaal': 'Lenguahe di programashon',
|
||||||
'TLP van deze slide': 'TLP di e slide aki',
|
'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...',
|
'Plak of typ hier je broncode...': 'Pega òf tek bo código fuente akinan...',
|
||||||
'Overgeslagen': 'Saltá',
|
'Overgeslagen': 'Saltá',
|
||||||
'Kopiëren': 'Kopia',
|
'Kopiëren': 'Kopia',
|
||||||
|
|
@ -2200,6 +2224,10 @@ const _dutchSourceStringAdditions = {
|
||||||
'Plak of typ hier je broncode...': 'Paste or type your source code here...',
|
'Plak of typ hier je broncode...': 'Paste or type your source code here...',
|
||||||
'Programmeertaal': 'Programming language',
|
'Programmeertaal': 'Programming language',
|
||||||
'TLP van deze slide': 'TLP of this slide',
|
'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',
|
'Platte tekst': 'Plain text',
|
||||||
'Titel (optioneel)': 'Title (optional)',
|
'Titel (optioneel)': 'Title (optional)',
|
||||||
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.':
|
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.':
|
||||||
|
|
|
||||||
72
lib/models/annotation.dart
Normal file
72
lib/models/annotation.dart
Normal 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)),
|
||||||
|
];
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'annotation.dart';
|
||||||
import 'slide.dart';
|
import 'slide.dart';
|
||||||
import 'settings.dart';
|
import 'settings.dart';
|
||||||
|
|
||||||
|
|
@ -108,6 +109,11 @@ class Deck {
|
||||||
/// Traffic Light Protocol-classificatie van deze presentatie.
|
/// Traffic Light Protocol-classificatie van deze presentatie.
|
||||||
final TlpLevel tlp;
|
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({
|
const Deck({
|
||||||
required this.title,
|
required this.title,
|
||||||
this.theme = 'ocideck',
|
this.theme = 'ocideck',
|
||||||
|
|
@ -122,6 +128,7 @@ class Deck {
|
||||||
this.description = '',
|
this.description = '',
|
||||||
this.keywords = '',
|
this.keywords = '',
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
|
this.annotations = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
Deck copyWith({
|
Deck copyWith({
|
||||||
|
|
@ -139,6 +146,7 @@ class Deck {
|
||||||
String? description,
|
String? description,
|
||||||
String? keywords,
|
String? keywords,
|
||||||
TlpLevel? tlp,
|
TlpLevel? tlp,
|
||||||
|
Map<String, List<InkStroke>>? annotations,
|
||||||
}) {
|
}) {
|
||||||
return Deck(
|
return Deck(
|
||||||
title: title ?? this.title,
|
title: title ?? this.title,
|
||||||
|
|
@ -154,6 +162,7 @@ class Deck {
|
||||||
description: description ?? this.description,
|
description: description ?? this.description,
|
||||||
keywords: keywords ?? this.keywords,
|
keywords: keywords ?? this.keywords,
|
||||||
tlp: tlp ?? this.tlp,
|
tlp: tlp ?? this.tlp,
|
||||||
|
annotations: annotations ?? this.annotations,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
lib/services/annotation_codec.dart
Normal file
103
lib/services/annotation_codec.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import '../models/deck.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
|
import 'annotation_codec.dart';
|
||||||
import 'caption_service.dart';
|
import 'caption_service.dart';
|
||||||
import 'image_service.dart';
|
import 'image_service.dart';
|
||||||
import 'markdown_service.dart';
|
import 'markdown_service.dart';
|
||||||
|
|
@ -145,7 +146,37 @@ class FileService {
|
||||||
}
|
}
|
||||||
final deck = _md.parseDeck(raw, filePath: filePath);
|
final deck = _md.parseDeck(raw, filePath: filePath);
|
||||||
if (deck == null) return null;
|
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 {
|
Future<String?> saveDeckAs(Deck deck, {String? initialDirectory}) async {
|
||||||
|
|
@ -228,6 +259,20 @@ class FileService {
|
||||||
ArchiveFile('${_safeName(deck.title)}.md', mdBytes.length, mdBytes),
|
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).
|
// Thema-CSS (zodat het pakket ook in Marp/CLI bruikbaar is).
|
||||||
final css = await _packageThemeCss(packDeck.theme, profile, logoRel);
|
final css = await _packageThemeCss(packDeck.theme, profile, logoRel);
|
||||||
if (css != null) {
|
if (css != null) {
|
||||||
|
|
@ -410,6 +455,8 @@ class FileService {
|
||||||
|
|
||||||
final markdown = _md.generateDeck(updatedDeck);
|
final markdown = _md.generateDeck(updatedDeck);
|
||||||
await File(filePath).writeAsString(markdown);
|
await File(filePath).writeAsString(markdown);
|
||||||
|
// Annotations live in a separate sidecar so the Marp .md stays pure.
|
||||||
|
await _writeSidecar(updatedDeck, filePath);
|
||||||
return updatedDeck;
|
return updatedDeck;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_riverpod/legacy.dart';
|
import 'package:flutter_riverpod/legacy.dart';
|
||||||
|
import '../models/annotation.dart';
|
||||||
import '../models/deck.dart';
|
import '../models/deck.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
|
|
@ -416,6 +417,16 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
||||||
_mutate(deck.copyWith(themeProfile: profile));
|
_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 ──────────────────────────────────────────────────────────
|
// ── Markdown mode ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
String generateMarkdown() {
|
String generateMarkdown() {
|
||||||
|
|
|
||||||
|
|
@ -976,6 +976,8 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
themeProfile: deck.themeProfile,
|
themeProfile: deck.themeProfile,
|
||||||
initialIndex: initial,
|
initialIndex: initial,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
|
annotations: deck.annotations,
|
||||||
|
onAnnotationsChanged: deckNotifier.setAnnotations,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
250
lib/widgets/presentation/annotation_overlay.dart
Normal file
250
lib/widgets/presentation/annotation_overlay.dart
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import '../../models/annotation.dart';
|
||||||
import '../../models/deck.dart';
|
import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
import '../../services/markdown_service.dart';
|
import '../../services/markdown_service.dart';
|
||||||
import '../../utils/url_launcher_util.dart';
|
import '../../utils/url_launcher_util.dart';
|
||||||
import '../slides/slide_preview.dart';
|
import '../slides/slide_preview.dart';
|
||||||
|
import 'annotation_overlay.dart';
|
||||||
|
|
||||||
/// Channel the audience (beamer) window listens on for updates from the
|
/// Channel the audience (beamer) window listens on for updates from the
|
||||||
/// presenter (laptop) window.
|
/// presenter (laptop) window.
|
||||||
|
|
@ -41,6 +43,11 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
||||||
int _index = 0;
|
int _index = 0;
|
||||||
int _blank = 0; // 0 = none, 1 = black, 2 = white
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -51,6 +58,14 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
||||||
_slides = deck?.slides ?? const [];
|
_slides = deck?.slides ?? const [];
|
||||||
_theme = deck?.themeProfile ?? const ThemeProfile();
|
_theme = deck?.themeProfile ?? const ThemeProfile();
|
||||||
_tlp = deck?.tlp ?? TlpLevel.none;
|
_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);
|
audienceChannel.setMethodCallHandler(_onPresenterCall);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,6 +83,23 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
||||||
setState(() {
|
setState(() {
|
||||||
_index = (m['index'] as num?)?.toInt() ?? _index;
|
_index = (m['index'] as num?)?.toInt() ?? _index;
|
||||||
_blank = (m['blank'] as num?)?.toInt() ?? 0;
|
_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':
|
case 'close':
|
||||||
try {
|
try {
|
||||||
|
|
@ -123,7 +155,10 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: slideW,
|
width: slideW,
|
||||||
height: slideH,
|
height: slideH,
|
||||||
child: SlidePreviewWidget(
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
SlidePreviewWidget(
|
||||||
slide: slide,
|
slide: slide,
|
||||||
projectPath: _projectPath,
|
projectPath: _projectPath,
|
||||||
themeProfile: _theme,
|
themeProfile: _theme,
|
||||||
|
|
@ -133,9 +168,17 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
||||||
tlp: _tlp,
|
tlp: _tlp,
|
||||||
enableMedia: true,
|
enableMedia: true,
|
||||||
autoplayMedia: true,
|
autoplayMedia: true,
|
||||||
// Audio finishing on the beamer drives the presenter's auto-advance.
|
// Audio finishing on the beamer drives the presenter's
|
||||||
|
// auto-advance.
|
||||||
onAudioComplete: () => _send('audioComplete'),
|
onAudioComplete: () => _send('audioComplete'),
|
||||||
),
|
),
|
||||||
|
AnnotationLayer(
|
||||||
|
strokes: _ink[_index] ?? const [],
|
||||||
|
interactive: false,
|
||||||
|
laserPoint: _laserIndex == _index ? _laserPoint : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
|
||||||
import 'package:screen_retriever/screen_retriever.dart';
|
import 'package:screen_retriever/screen_retriever.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
import '../../models/annotation.dart';
|
||||||
import '../../models/deck.dart';
|
import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
|
|
@ -14,6 +15,7 @@ import '../../services/markdown_service.dart';
|
||||||
import '../../utils/url_launcher_util.dart';
|
import '../../utils/url_launcher_util.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../slides/slide_preview.dart';
|
import '../slides/slide_preview.dart';
|
||||||
|
import 'annotation_overlay.dart';
|
||||||
import 'audience_window.dart';
|
import 'audience_window.dart';
|
||||||
|
|
||||||
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
|
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
|
||||||
|
|
@ -31,6 +33,11 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
/// for the classic single-screen mode.
|
/// for the classic single-screen mode.
|
||||||
final WindowController? audienceWindow;
|
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({
|
const FullscreenPresenter({
|
||||||
super.key,
|
super.key,
|
||||||
required this.slides,
|
required this.slides,
|
||||||
|
|
@ -39,6 +46,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
required this.initialIndex,
|
required this.initialIndex,
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
this.audienceWindow,
|
this.audienceWindow,
|
||||||
|
this.initialAnnotations = const {},
|
||||||
|
this.onAnnotationsChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Entry point used by the app: pick dual-screen mode when a second display is
|
/// 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 ThemeProfile themeProfile,
|
||||||
required int initialIndex,
|
required int initialIndex,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
Map<String, List<InkStroke>> annotations = const {},
|
||||||
|
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||||
}) async {
|
}) async {
|
||||||
var displayCount = 0;
|
var displayCount = 0;
|
||||||
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
|
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
|
||||||
|
|
@ -76,6 +87,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
annotations: annotations,
|
||||||
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await show(
|
await show(
|
||||||
|
|
@ -85,6 +98,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
annotations: annotations,
|
||||||
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -96,6 +111,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
required ThemeProfile themeProfile,
|
required ThemeProfile themeProfile,
|
||||||
required int initialIndex,
|
required int initialIndex,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
Map<String, List<InkStroke>> annotations = const {},
|
||||||
|
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||||
}) async {
|
}) async {
|
||||||
final hadWakeLock = await _wakeLockEnabled();
|
final hadWakeLock = await _wakeLockEnabled();
|
||||||
await _enableWakeLock();
|
await _enableWakeLock();
|
||||||
|
|
@ -112,6 +129,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
initialAnnotations: annotations,
|
||||||
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
),
|
),
|
||||||
transitionsBuilder: (context, animation, secondary, child) =>
|
transitionsBuilder: (context, animation, secondary, child) =>
|
||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(opacity: animation, child: child),
|
||||||
|
|
@ -135,6 +154,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
required ThemeProfile themeProfile,
|
required ThemeProfile themeProfile,
|
||||||
required int initialIndex,
|
required int initialIndex,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
Map<String, List<InkStroke>> annotations = const {},
|
||||||
|
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||||
}) async {
|
}) async {
|
||||||
// A self-contained markdown deck is the payload for the audience window; it
|
// 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.
|
// carries the slides, the style profile and the TLP level in one string.
|
||||||
|
|
@ -147,10 +168,20 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
tlp: tlp,
|
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({
|
final argument = jsonEncode({
|
||||||
'markdown': markdown,
|
'markdown': markdown,
|
||||||
'projectPath': projectPath,
|
'projectPath': projectPath,
|
||||||
'index': initialIndex,
|
'index': initialIndex,
|
||||||
|
'ink': inkByIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
WindowController? audience;
|
WindowController? audience;
|
||||||
|
|
@ -172,6 +203,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
annotations: annotations,
|
||||||
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
@ -192,6 +225,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
audienceWindow: audience,
|
audienceWindow: audience,
|
||||||
|
initialAnnotations: annotations,
|
||||||
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
),
|
),
|
||||||
transitionsBuilder: (context, animation, secondary, child) =>
|
transitionsBuilder: (context, animation, secondary, child) =>
|
||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(opacity: animation, child: child),
|
||||||
|
|
@ -307,12 +342,35 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
int? _lastSentIndex;
|
int? _lastSentIndex;
|
||||||
int? _lastSentBlank;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_index = widget.initialIndex;
|
_index = widget.initialIndex;
|
||||||
_startTime = DateTime.now();
|
_startTime = DateTime.now();
|
||||||
_focusNode = FocusNode();
|
_focusNode = FocusNode();
|
||||||
|
_ink = {
|
||||||
|
for (final e in widget.initialAnnotations.entries)
|
||||||
|
e.key: List<InkStroke>.from(e.value),
|
||||||
|
};
|
||||||
if (_dual) {
|
if (_dual) {
|
||||||
// The laptop shows the presenter view; the slide lives on the beamer.
|
// The laptop shows the presenter view; the slide lives on the beamer.
|
||||||
_presenterView = true;
|
_presenterView = true;
|
||||||
|
|
@ -363,11 +421,71 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
if (aw == null) return;
|
if (aw == null) return;
|
||||||
final blank = _blankCode;
|
final blank = _blankCode;
|
||||||
if (_index == _lastSentIndex && blank == _lastSentBlank) return;
|
if (_index == _lastSentIndex && blank == _lastSentBlank) return;
|
||||||
|
final indexChanged = _index != _lastSentIndex;
|
||||||
_lastSentIndex = _index;
|
_lastSentIndex = _index;
|
||||||
_lastSentBlank = blank;
|
_lastSentBlank = blank;
|
||||||
audienceChannel
|
audienceChannel
|
||||||
.invokeMethod('update', {'index': _index, 'blank': blank})
|
.invokeMethod('update', {'index': _index, 'blank': blank})
|
||||||
.catchError((_) => null);
|
.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
|
/// 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:
|
case LogicalKeyboardKey.keyS:
|
||||||
_cycleDisplay();
|
_cycleDisplay();
|
||||||
return KeyEventResult.handled;
|
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:
|
case LogicalKeyboardKey.escape:
|
||||||
// Gelaagd: getypt nummer wissen, dan blanco scherm, dan pas afsluiten.
|
// Gelaagd: gereedschap weg, getypt nummer wissen, blanco scherm, afsluiten.
|
||||||
if (_typed.isNotEmpty) {
|
if (_tool != null) {
|
||||||
|
setState(() => _tool = null);
|
||||||
|
_onLaserMove(null);
|
||||||
|
} else if (_typed.isNotEmpty) {
|
||||||
_clearTyped();
|
_clearTyped();
|
||||||
} else if (_blank != _Blank.none) {
|
} else if (_blank != _Blank.none) {
|
||||||
setState(() => _blank = _Blank.none);
|
setState(() => _blank = _Blank.none);
|
||||||
|
|
@ -860,6 +996,13 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
? _buildPresenterView(context)
|
? _buildPresenterView(context)
|
||||||
: _buildAudienceView(context),
|
: _buildAudienceView(context),
|
||||||
if (_gridOpen) Positioned.fill(child: _buildGridOverlay()),
|
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)
|
if (_typed.isNotEmpty)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
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").
|
/// Badge met het getypte slidenummer ("→ 12 / 28 · Enter").
|
||||||
Widget _buildTypedBadge(int total) {
|
Widget _buildTypedBadge(int total) {
|
||||||
return Container(
|
return Container(
|
||||||
|
|
@ -919,6 +1150,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
('P', l10n.d('Presenter view (notities, klok)')),
|
('P', l10n.d('Presenter view (notities, klok)')),
|
||||||
('S', l10n.d('Scherm wisselen (meerdere schermen)')),
|
('S', l10n.d('Scherm wisselen (meerdere schermen)')),
|
||||||
('B · W', l10n.d('Zwart · wit scherm')),
|
('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')),
|
('R', l10n.d('Verstreken tijd resetten')),
|
||||||
('A', l10n.d('Automatische modus aan/uit')),
|
('A', l10n.d('Automatische modus aan/uit')),
|
||||||
('L', l10n.d('Herhalen (loop) aan/uit')),
|
('L', l10n.d('Herhalen (loop) aan/uit')),
|
||||||
|
|
@ -1044,7 +1280,10 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: slideW,
|
width: slideW,
|
||||||
height: slideH,
|
height: slideH,
|
||||||
child: SlidePreviewWidget(
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
SlidePreviewWidget(
|
||||||
slide: slide,
|
slide: slide,
|
||||||
projectPath: widget.projectPath,
|
projectPath: widget.projectPath,
|
||||||
themeProfile: widget.themeProfile,
|
themeProfile: widget.themeProfile,
|
||||||
|
|
@ -1060,6 +1299,19 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
autoplayMedia: !_dual,
|
autoplayMedia: !_dual,
|
||||||
onAudioComplete: _onAudioCompleted,
|
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
80
test/annotation_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue