Ocideck/lib/widgets/presentation/annotation_overlay.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

250 lines
7 KiB
Dart

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