// lib/editor/canvas/sticky_note_overlay.dart // // Page-anchored paper sticky: sized/positioned by the parent in page space, // shares the editor brush/color/tool, locks inner pan/zoom so writing feels // like drawing on the sticky surface itself. 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 'editor_tool.dart'; import 'pen_canvas.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 glued to a PDF page (parent supplies pixel size). class StickyNoteOverlay extends StatefulWidget { const StickyNoteOverlay({ super.key, required this.link, required this.repo, required this.onClose, required this.onDelete, required this.onDragPx, required this.onResizePx, this.brush = BrushKind.ballpoint, this.color = const Color(0xFF1A1A1A), this.tool = EditorToolKind.brush, this.strokeWidth = 0.008, this.allowFingerDrawing = false, }); final ScratchLink link; final SidecarRepository repo; final VoidCallback onClose; final VoidCallback onDelete; /// Header drag delta in viewer/page pixels. final void Function(double dx, double dy) onDragPx; /// Corner resize delta in viewer/page pixels. final void Function(double dx, double dy) onResizePx; final BrushKind brush; final Color color; final EditorToolKind tool; final double strokeWidth; final bool allowFingerDrawing; @override State createState() => _StickyNoteOverlayState(); } class _StickyNoteOverlayState extends State { static const _uuid = Uuid(); final TransformationController _transform = TransformationController(); List _strokes = []; Size _world = kStickyWorldSize; 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(); if (_dirty) { _persist(flush: true); } _transform.dispose(); super.dispose(); } void _scheduleSave() { _dirty = true; _saveTimer?.cancel(); _saveTimer = Timer(const Duration(milliseconds: 600), () => _persist()); } Future _persist({bool flush = false}) async { if (!_dirty && !flush) return; widget.repo.scheduleScratchpadSave( widget.link.id, SidecarScratchpad( canvasWidth: _world.width, canvasHeight: _world.height, strokes: List.of(_strokes), ), ); _dirty = false; if (flush) 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 _persist(flush: true); widget.onClose(); } CanvasTool get _canvasTool { switch (widget.tool) { case EditorToolKind.eraser: return CanvasTool.eraser; case EditorToolKind.select: return CanvasTool.select; case EditorToolKind.highlighter: return CanvasTool.highlighter; case EditorToolKind.brush: case EditorToolKind.shape: case EditorToolKind.text: return CanvasTool.pen; } } BrushKind get _canvasBrush => widget.tool == EditorToolKind.highlighter ? BrushKind.highlighter : widget.brush; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Material( elevation: 8, borderRadius: BorderRadius.circular(4), color: const Color(0xFFFFF8E1), clipBehavior: Clip.antiAlias, child: Stack( children: [ Column( children: [ _StickyHeader( onDragDelta: widget.onDragPx, onClose: _close, onDelete: () async { await _persist(flush: true); widget.onDelete(); }, cs: cs, ), Expanded( child: PenCanvas( pageSize: _world, strokes: penStrokesFromInk(_strokes, _world), transformationController: _transform, tool: _canvasTool, brush: _canvasBrush, color: widget.color, strokeWidth: widget.strokeWidth, eraserRadius: kDefaultEraserRadius, allowFingerDrawing: widget.allowFingerDrawing, scaleEnabled: false, panEnabled: false, minScale: 1.0, maxScale: 1.0, onStrokeComplete: _onStrokeComplete, onEraseStroke: _onErase, pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)), ), ), ], ), Positioned( right: 0, bottom: 0, child: GestureDetector( onPanUpdate: (d) => widget.onResizePx(d.delta.dx, d.delta.dy), child: MouseRegion( cursor: SystemMouseCursors.resizeUpLeftDownRight, child: SizedBox( width: 28, height: 28, child: Icon( Icons.south_east, size: 16, color: cs.onSurface.withValues(alpha: 0.45), ), ), ), ), ), ], ), ); } } class _StickyHeader extends StatelessWidget { const _StickyHeader({ required this.onDragDelta, required this.onClose, required this.onDelete, required this.cs, }); final void Function(double dx, double dy) onDragDelta; final VoidCallback onClose; final VoidCallback onDelete; final ColorScheme cs; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onPanUpdate: (d) => onDragDelta(d.delta.dx, d.delta.dy), child: 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.drag_indicator, size: 18), const SizedBox(width: 4), const Expanded( child: Text( '便利贴', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600), ), ), Text( '拖标题定位 · 角缩放', style: TextStyle( fontSize: 10, color: cs.onSurface.withValues(alpha: 0.5), ), ), IconButton( tooltip: '删除', icon: const Icon(Icons.delete_outline, size: 18), visualDensity: VisualDensity.compact, onPressed: onDelete, ), IconButton( tooltip: '收起', icon: const Icon(Icons.close, size: 18), visualDensity: VisualDensity.compact, onPressed: onClose, ), ], ), ), ); } }