// lib/editor/canvas/sticky_note_overlay.dart // // Paper-sticky UX for PDF scratch links: a floating card on the viewer that // inks into the SAME SidecarRepository the PDF editor holds (no second open, // no split-view race). Collapsed = page marker; expanded = this overlay. import 'dart:async'; import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; import '../../models/ink_stroke.dart'; import '../../models/scratch_link.dart'; import '../../storage/badnote_sidecar.dart'; import '../engine/brush.dart'; import '../input/pen_config.dart' show kDefaultEraserRadius; import '../notebook/ink_stroke_adapter.dart'; import '../persistence/sidecar_repository.dart'; import 'pen_canvas.dart'; import 'pen_palette_widgets.dart'; import 'pen_stroke.dart'; /// Default world size for a fresh sticky scratchpad (absolute px). const Size kStickyWorldSize = Size(1200, 900); /// Floating sticky-note card: write → autosave into [repo] under [link.id]. class StickyNoteOverlay extends StatefulWidget { const StickyNoteOverlay({ super.key, required this.link, required this.repo, required this.onClose, required this.onDelete, }); final ScratchLink link; final SidecarRepository repo; final VoidCallback onClose; final VoidCallback onDelete; @override State createState() => _StickyNoteOverlayState(); } class _StickyNoteOverlayState extends State { static const _uuid = Uuid(); final TransformationController _transform = TransformationController(); List _strokes = []; Size _world = kStickyWorldSize; CanvasTool _tool = CanvasTool.pen; BrushKind _brush = BrushKind.ballpoint; // ignore: prefer_final_fields — reserved for brush picker Color _color = kInkPalette.first; Timer? _saveTimer; bool _dirty = false; @override void initState() { super.initState(); final pad = widget.repo.scratchpadFor(widget.link.id); if (pad != null) { _world = Size(pad.canvasWidth, pad.canvasHeight); _strokes = pad.strokes.where((s) => isFreehandTool(s.tool)).toList(); } } @override void dispose() { _saveTimer?.cancel(); // Best-effort sync save before leaving the overlay. if (_dirty) { widget.repo.scheduleScratchpadSave( widget.link.id, SidecarScratchpad( canvasWidth: _world.width, canvasHeight: _world.height, strokes: List.of(_strokes), ), ); widget.repo.flush(); } _transform.dispose(); super.dispose(); } void _scheduleSave() { _dirty = true; _saveTimer?.cancel(); _saveTimer = Timer(const Duration(milliseconds: 600), _saveNow); } Future _saveNow() async { if (!_dirty) return; widget.repo.scheduleScratchpadSave( widget.link.id, SidecarScratchpad( canvasWidth: _world.width, canvasHeight: _world.height, strokes: List.of(_strokes), ), ); _dirty = false; await widget.repo.flush(); } void _onStrokeComplete(PenStroke pen) { setState(() { _strokes = [ ..._strokes, inkStrokeFromPen(pen, _world, id: _uuid.v4(), createdAt: DateTime.now()), ]; }); _scheduleSave(); } void _onErase(int index, List replacements) { if (index < 0 || index >= _strokes.length) return; setState(() { final next = List.of(_strokes)..removeAt(index); for (final r in replacements) { next.insert( index, inkStrokeFromPen(r, _world, id: _uuid.v4(), createdAt: DateTime.now()), ); } _strokes = next; }); _scheduleSave(); } Future _close() async { _saveTimer?.cancel(); await _saveNow(); widget.onClose(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Material( elevation: 8, borderRadius: BorderRadius.circular(4), color: const Color(0xFFFFF8E1), child: SizedBox( width: 280, height: 340, child: Column( children: [ Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: 4), decoration: const BoxDecoration( color: Color(0xFFFFE082), borderRadius: BorderRadius.vertical(top: Radius.circular(4)), ), child: Row( children: [ const Icon(Icons.sticky_note_2, size: 18), const SizedBox(width: 6), const Expanded( child: Text( '便利贴', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, ), ), ), IconButton( tooltip: '删除', icon: const Icon(Icons.delete_outline, size: 18), visualDensity: VisualDensity.compact, onPressed: () async { await _saveNow(); widget.onDelete(); }, ), IconButton( tooltip: '收起', icon: const Icon(Icons.close, size: 18), visualDensity: VisualDensity.compact, onPressed: _close, ), ], ), ), SizedBox( height: 32, child: ListView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 6), children: [ for (final c in kInkPalette) GestureDetector( onTap: () => setState(() => _color = c), child: Container( width: 18, height: 18, margin: const EdgeInsets.symmetric( horizontal: 3, vertical: 7), decoration: BoxDecoration( color: c, shape: BoxShape.circle, border: Border.all( color: _color == c ? cs.primary : cs.outlineVariant, width: _color == c ? 2 : 1, ), ), ), ), ], ), ), Expanded( child: ClipRect( child: PenCanvas( pageSize: _world, strokes: penStrokesFromInk(_strokes, _world), transformationController: _transform, tool: _tool, brush: _brush, color: _color, strokeWidth: 0.008, eraserRadius: kDefaultEraserRadius, minScale: 0.2, maxScale: 4.0, onStrokeComplete: _onStrokeComplete, onEraseStroke: _onErase, pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)), ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: Row( children: [ IconButton( tooltip: '笔', icon: Icon( Icons.edit, size: 18, color: _tool == CanvasTool.pen ? cs.primary : null, ), onPressed: () => setState(() => _tool = CanvasTool.pen), ), IconButton( tooltip: '橡皮', icon: Icon( Icons.cleaning_services_outlined, size: 18, color: _tool == CanvasTool.eraser ? cs.primary : null, ), onPressed: () => setState(() => _tool = CanvasTool.eraser), ), ], ), ), ], ), ), ); } }