Files
BadNote/lib/editor/canvas/sticky_note_overlay.dart
Akiba So 307161f465
All checks were successful
CI / Windows build (push) Successful in 9m55s
fix: restore PDF pen capture and overhaul sticky/pens/pages
Reinstall PenCaptureBinding so stylus ink hits again; keep finger Listener translucent under pinch; page-anchor sticky with drag/resize; OneNote pen slots (brush+width+color); blank-note multi-page; default side button to hold-select-text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 02:38:58 +08:00

292 lines
8.3 KiB
Dart

// 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<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;
@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<void> _persist({bool flush = false}) async {
if (!_dirty && !flush) return;
widget.repo.scheduleScratchpadSave(
widget.link.id,
SidecarScratchpad(
canvasWidth: _world.width,
canvasHeight: _world.height,
strokes: List<InkStroke>.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<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 _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,
),
],
),
),
);
}
}