2026-08-05 19:52:15 +08:00
|
|
|
// lib/editor/canvas/sticky_note_overlay.dart
|
|
|
|
|
//
|
|
|
|
|
// Paper-sticky UX for PDF scratch links: a floating card on the viewer that
|
2026-08-06 16:01:01 +08:00
|
|
|
// inks into the SAME SidecarRepository the PDF editor holds. Shares the parent
|
|
|
|
|
// editor's brush/color/tool so there is one toolbar mental model (OneNote-like).
|
2026-08-05 19:52:15 +08:00
|
|
|
|
|
|
|
|
import 'dart:async';
|
2026-08-06 16:01:01 +08:00
|
|
|
import 'dart:math' as math;
|
2026-08-05 19:52:15 +08:00
|
|
|
|
|
|
|
|
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';
|
2026-08-06 16:01:01 +08:00
|
|
|
import 'editor_tool.dart';
|
2026-08-05 19:52:15 +08:00
|
|
|
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: 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,
|
2026-08-06 16:01:01 +08:00
|
|
|
this.brush = BrushKind.ballpoint,
|
|
|
|
|
this.color = const Color(0xFF1A1A1A),
|
|
|
|
|
this.tool = EditorToolKind.brush,
|
|
|
|
|
this.allowFingerDrawing = false,
|
2026-08-05 19:52:15 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final ScratchLink link;
|
|
|
|
|
final SidecarRepository repo;
|
|
|
|
|
final VoidCallback onClose;
|
|
|
|
|
final VoidCallback onDelete;
|
|
|
|
|
|
2026-08-06 16:01:01 +08:00
|
|
|
/// Shared from the parent PDF toolbar (no mini duplicate palette).
|
|
|
|
|
final BrushKind brush;
|
|
|
|
|
final Color color;
|
|
|
|
|
final EditorToolKind tool;
|
|
|
|
|
final bool allowFingerDrawing;
|
|
|
|
|
|
2026-08-05 19:52:15 +08:00
|
|
|
@override
|
|
|
|
|
State<StickyNoteOverlay> createState() => _StickyNoteOverlayState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
|
|
|
|
static const _uuid = Uuid();
|
|
|
|
|
|
|
|
|
|
final TransformationController _transform = TransformationController();
|
|
|
|
|
List<InkStroke> _strokes = [];
|
|
|
|
|
Size _world = kStickyWorldSize;
|
|
|
|
|
Timer? _saveTimer;
|
|
|
|
|
bool _dirty = false;
|
|
|
|
|
|
2026-08-06 16:01:01 +08:00
|
|
|
/// On-screen card size (user-resizable). World canvas stays [_world].
|
|
|
|
|
double _cardW = 300;
|
|
|
|
|
double _cardH = 360;
|
|
|
|
|
|
2026-08-05 19:52:15 +08:00
|
|
|
@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();
|
2026-08-06 16:01:01 +08:00
|
|
|
// Prefer a card that roughly matches aspect of the world, clamped.
|
|
|
|
|
final aspect = _world.width / math.max(_world.height, 1);
|
|
|
|
|
_cardW = (280.0 * aspect).clamp(220.0, 520.0);
|
|
|
|
|
_cardH = (_cardW / aspect + 40).clamp(260.0, 640.0);
|
2026-08-05 19:52:15 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_saveTimer?.cancel();
|
|
|
|
|
if (_dirty) {
|
|
|
|
|
widget.repo.scheduleScratchpadSave(
|
|
|
|
|
widget.link.id,
|
|
|
|
|
SidecarScratchpad(
|
|
|
|
|
canvasWidth: _world.width,
|
|
|
|
|
canvasHeight: _world.height,
|
|
|
|
|
strokes: List<InkStroke>.of(_strokes),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
widget.repo.flush();
|
|
|
|
|
}
|
|
|
|
|
_transform.dispose();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _scheduleSave() {
|
|
|
|
|
_dirty = true;
|
|
|
|
|
_saveTimer?.cancel();
|
|
|
|
|
_saveTimer = Timer(const Duration(milliseconds: 600), _saveNow);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _saveNow() async {
|
|
|
|
|
if (!_dirty) return;
|
|
|
|
|
widget.repo.scheduleScratchpadSave(
|
|
|
|
|
widget.link.id,
|
|
|
|
|
SidecarScratchpad(
|
|
|
|
|
canvasWidth: _world.width,
|
|
|
|
|
canvasHeight: _world.height,
|
|
|
|
|
strokes: List<InkStroke>.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<PenStroke> replacements) {
|
|
|
|
|
if (index < 0 || index >= _strokes.length) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
final next = List<InkStroke>.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<void> _close() async {
|
|
|
|
|
_saveTimer?.cancel();
|
|
|
|
|
await _saveNow();
|
|
|
|
|
widget.onClose();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 16:01:01 +08:00
|
|
|
CanvasTool get _canvasTool {
|
|
|
|
|
switch (widget.tool) {
|
|
|
|
|
case EditorToolKind.eraser:
|
|
|
|
|
return CanvasTool.eraser;
|
|
|
|
|
case EditorToolKind.select:
|
|
|
|
|
return CanvasTool.select;
|
|
|
|
|
case EditorToolKind.highlighter:
|
|
|
|
|
case EditorToolKind.brush:
|
|
|
|
|
case EditorToolKind.shape:
|
|
|
|
|
case EditorToolKind.text:
|
|
|
|
|
return CanvasTool.pen;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
BrushKind get _canvasBrush =>
|
|
|
|
|
widget.tool == EditorToolKind.highlighter
|
|
|
|
|
? BrushKind.highlighter
|
|
|
|
|
: widget.brush;
|
|
|
|
|
|
|
|
|
|
void _onResizeDrag(DragUpdateDetails d) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_cardW = (_cardW + d.delta.dx).clamp(220.0, 640.0);
|
|
|
|
|
_cardH = (_cardH + d.delta.dy).clamp(240.0, 720.0);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 19:52:15 +08:00
|
|
|
@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(
|
2026-08-06 16:01:01 +08:00
|
|
|
width: _cardW,
|
|
|
|
|
height: _cardH,
|
|
|
|
|
child: Stack(
|
2026-08-05 19:52:15 +08:00
|
|
|
children: [
|
2026-08-06 16:01:01 +08:00
|
|
|
Column(
|
|
|
|
|
children: [
|
|
|
|
|
Container(
|
|
|
|
|
height: 36,
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
|
|
|
decoration: const BoxDecoration(
|
|
|
|
|
color: Color(0xFFFFE082),
|
|
|
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
|
2026-08-05 19:52:15 +08:00
|
|
|
),
|
2026-08-06 16:01:01 +08:00
|
|
|
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,
|
2026-08-05 19:52:15 +08:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-08-06 16:01:01 +08:00
|
|
|
Text(
|
|
|
|
|
'用顶栏笔/色',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
color: cs.onSurface.withValues(alpha: 0.55),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-08-05 19:52:15 +08:00
|
|
|
),
|
2026-08-06 16:01:01 +08:00
|
|
|
Expanded(
|
|
|
|
|
child: ClipRect(
|
|
|
|
|
child: PenCanvas(
|
|
|
|
|
pageSize: _world,
|
|
|
|
|
strokes: penStrokesFromInk(_strokes, _world),
|
|
|
|
|
transformationController: _transform,
|
|
|
|
|
tool: _canvasTool,
|
|
|
|
|
brush: _canvasBrush,
|
|
|
|
|
color: widget.color,
|
|
|
|
|
strokeWidth: brushProfileFor(_canvasBrush).baseWidthFraction,
|
|
|
|
|
eraserRadius: kDefaultEraserRadius,
|
|
|
|
|
allowFingerDrawing: widget.allowFingerDrawing,
|
|
|
|
|
minScale: 0.2,
|
|
|
|
|
maxScale: 4.0,
|
|
|
|
|
onStrokeComplete: _onStrokeComplete,
|
|
|
|
|
onEraseStroke: _onErase,
|
|
|
|
|
pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)),
|
2026-08-05 19:52:15 +08:00
|
|
|
),
|
|
|
|
|
),
|
2026-08-06 16:01:01 +08:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
Positioned(
|
|
|
|
|
right: 0,
|
|
|
|
|
bottom: 0,
|
|
|
|
|
child: GestureDetector(
|
|
|
|
|
onPanUpdate: _onResizeDrag,
|
|
|
|
|
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),
|
2026-08-05 19:52:15 +08:00
|
|
|
),
|
|
|
|
|
),
|
2026-08-06 16:01:01 +08:00
|
|
|
),
|
2026-08-05 19:52:15 +08:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|