Files
BadNote/lib/screens/split_view_screen.dart
Akiba So 4a6fe7d05e
All checks were successful
CI / Windows build (push) Successful in 8m19s
fix: Surface pen pressure, zoom glitches, sticky notes, selection UX
Wire Win32 pressure into Dart, tighten pinch guards, use geometric shape
strokes, expand the ink palette, and replace scratch-link split view with
an on-page sticky that shares the sidecar repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:52:15 +08:00

577 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// lib/screens/split_view_screen.dart
//
// Anchor-keyed split view: opened by tapping a PDF-anchored scratch link.
// LEFT pane = the source PDF (pdfrx PdfViewer, read-only reference + page
// nav), opened at the anchor's page.
// RIGHT pane = an INFINITE freehand scratchpad that BELONGS TO THE ANCHOR,
// keyed by [scratchLinkId] (not the documentId). Each anchor has
// its own private scratch space, persisted via the existing
// scratchpad storage (InkStroke JSON, format unchanged).
//
// The right pane reuses the pen-first PenCanvas world-coord engine and the
// Material You brush palette shared with pen_note_screen (BrushPickerButton /
// ToolButton / color dots), replacing the old AnnotationToolbar.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:uuid/uuid.dart';
import '../editor/canvas/pen_canvas.dart';
import '../editor/canvas/pen_palette_widgets.dart';
import '../editor/canvas/pen_stroke.dart';
import '../editor/engine/brush.dart';
import '../editor/input/pen_config.dart' show kDefaultEraserRadius;
import '../editor/notebook/ink_stroke_adapter.dart';
import '../editor/persistence/sidecar_repository.dart';
import '../models/ink_stroke.dart';
import '../services/undo_manager.dart';
import '../storage/badnote_sidecar.dart';
/// Split-view derivation surface for a single scratch-link anchor: left pane =
/// the reference PDF (at [initialPage]), right pane = the anchor's private
/// infinite scratchpad. Scratchpad strokes persist inside the source file's
/// SIDECAR (`<filePath>.badnote.json`), embedded in the anchor's
/// `scratchLinks[id].scratchpad` (keyed by [scratchLinkId]). Strokes keep the
/// absolute world-pixel [InkStroke] format unchanged.
class SplitViewScreen extends StatefulWidget {
final String filePath;
/// The owning anchor id. Selects which `scratchLinks[].scratchpad` in the
/// sidecar this is the private scratch space for.
final String scratchLinkId;
/// 0-based page the anchor sits on; the left PDF opens here.
final int initialPage;
const SplitViewScreen({
super.key,
required this.filePath,
required this.scratchLinkId,
this.initialPage = 0,
});
@override
State<SplitViewScreen> createState() => _SplitViewState();
}
class _SplitViewState extends State<SplitViewScreen> {
// -- PDF (left pane) --
final PdfViewerController _pdfController = PdfViewerController();
int _currentPage = 0; // 0-based
int _pageCount = 0;
// -- Split divider --
double _leftPaneFraction = 0.5;
bool _isDraggingDivider = false;
// -- Scratchpad (right pane) --
// Infinite WORLD: strokes stored in absolute world pixels ([InkStroke],
// unchanged persistence format), rendered through PenCanvas by normalizing
// against the CURRENT world size. World auto-expands without moving ink.
final UndoManager _undoManager = UndoManager();
List<InkStroke> _strokes = [];
double _canvasWidth = 4000;
double _canvasHeight = 4000;
static const _uuid = Uuid();
final TransformationController _scratchTransform = TransformationController();
bool _scratchCentered = false;
Size get _worldSize => Size(_canvasWidth, _canvasHeight);
// -- Tool state (new Material You brush palette) --
CanvasTool _tool = CanvasTool.pen;
BrushKind _penBrush = BrushKind.fountainPen;
Color _color = Colors.black;
static const double _penWidthFraction = 0.006;
static const double _highlighterWidthFraction = 0.02;
static const List<Color> _palette = kInkPalette;
// -- Auto-save debounce --
Timer? _saveTimer;
bool _dirty = false;
/// Sidecar persistence for the source file (the scratchpad is embedded in the
/// anchor's `scratchLinks[id].scratchpad`). Null until [_loadScratchpad]
/// resolves.
SidecarRepository? _repo;
static const double _edgeThreshold = 200.0;
static const double _expandAmount = 1000.0;
@override
void initState() {
super.initState();
_currentPage = widget.initialPage;
_loadScratchpad();
}
@override
void dispose() {
_saveTimer?.cancel();
// Schedule a final flush; retain-counted repo may still be held by the
// PDF editor, so dispose only drops our retain.
if (_dirty) {
unawaited(_saveImmediate());
} else {
unawaited(_repo?.flush() ?? Future<void>.value());
}
_repo?.dispose();
// PdfViewerController (pdfrx) has no dispose(); it detaches with the viewer.
_scratchTransform.dispose();
super.dispose();
}
// -- Persistence (sidecar's scratchLinks[id].scratchpad, keyed by anchor id) --
Future<void> _loadScratchpad() async {
final repo = await SidecarRepository.open(widget.filePath, docType: 'pdf');
if (!mounted) {
repo.dispose();
return;
}
_repo = repo;
final pad = repo.scratchpadFor(widget.scratchLinkId);
setState(() {
if (pad != null) {
// Restore the world size so the infinite canvas reopens at its grown
// extent (previously always reset to 4000×4000).
_canvasWidth = pad.canvasWidth;
_canvasHeight = pad.canvasHeight;
}
final strokes = pad?.strokes ?? const <InkStroke>[];
// Keep only freehand strokes so the canvas list stays 1:1 with the undo
// manager (shapes/text have no pen-canvas representation).
final freehand = strokes.where((s) => isFreehandTool(s.tool)).toList();
_strokes = freehand;
for (final s in freehand) {
_undoManager.addStroke(s);
}
});
}
void _scheduleSave() {
_dirty = true;
_saveTimer?.cancel();
_saveTimer = Timer(const Duration(seconds: 3), _saveImmediate);
}
Future<void> _saveImmediate() async {
if (!_dirty) return;
final repo = _repo;
if (repo == null) return;
// Keep dirty until schedule succeeds so a race during load can't swallow ink.
repo.scheduleScratchpadSave(
widget.scratchLinkId,
SidecarScratchpad(
canvasWidth: _canvasWidth,
canvasHeight: _canvasHeight,
strokes: List<InkStroke>.of(_strokes),
),
);
_dirty = false;
await repo.flush();
}
// -- Scratchpad stroke callbacks --
void _onStrokeComplete(PenStroke pen) {
final stroke = inkStrokeFromPen(pen, _worldSize,
id: _uuid.v4(), createdAt: DateTime.now());
setState(() {
_strokes.add(stroke);
_undoManager.addStroke(stroke);
_checkCanvasExpansion(stroke);
});
_scheduleSave();
}
void _onErase(int index, List<PenStroke> replacements) {
if (index < 0 || index >= _strokes.length) return;
setState(() {
final original = _strokes[index];
final inkReplacements = [
for (final r in replacements)
inkStrokeFromPen(r, _worldSize,
id: _uuid.v4(), createdAt: DateTime.now()),
];
_undoManager.removeStroke(original, replacements: inkReplacements);
_strokes = List.from(_undoManager.currentStrokes);
});
_scheduleSave();
}
void _undo() {
setState(() {
_undoManager.undo();
_strokes = List.from(_undoManager.currentStrokes);
});
_scheduleSave();
}
void _redo() {
setState(() {
_undoManager.redo();
_strokes = List.from(_undoManager.currentStrokes);
});
_scheduleSave();
}
// -- Auto-expand canvas --
void _checkCanvasExpansion(InkStroke stroke) {
double maxRight = 0;
double maxBottom = 0;
for (final p in stroke.points) {
if (p.x > maxRight) maxRight = p.x;
if (p.y > maxBottom) maxBottom = p.y;
}
bool expanded = false;
if (maxRight > _canvasWidth - _edgeThreshold) {
_canvasWidth += _expandAmount;
expanded = true;
}
if (maxBottom > _canvasHeight - _edgeThreshold) {
_canvasHeight += _expandAmount;
expanded = true;
}
if (expanded) setState(() {});
}
// -- Divider drag --
void _onDividerDragStart(DragStartDetails details) {
setState(() => _isDraggingDivider = true);
}
void _onDividerDragUpdate(
DragUpdateDetails details,
BoxConstraints constraints,
) {
final renderWidth = constraints.maxWidth;
if (renderWidth <= 0) return;
final delta = details.delta.dx / renderWidth;
setState(() {
_leftPaneFraction = (_leftPaneFraction + delta).clamp(0.2, 0.8);
});
}
void _onDividerDragEnd(DragEndDetails details) {
setState(() => _isDraggingDivider = false);
}
// -- PDF page navigation (left pane) --
void _prevPage() {
if (_currentPage > 0) _pdfController.goToPage(pageNumber: _currentPage);
}
void _nextPage() {
if (_currentPage < _pageCount - 1) {
_pdfController.goToPage(pageNumber: _currentPage + 2);
}
}
CanvasTool get _activeTool => _tool;
double get _strokeWidth => _tool == CanvasTool.highlighter
? _highlighterWidthFraction
: _penWidthFraction;
// -- Build --
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Scratch link', style: TextStyle(fontSize: 16)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () async {
await _saveImmediate();
if (context.mounted) Navigator.of(context).pop();
},
),
actions: [
IconButton(
icon: const Icon(Icons.navigate_before),
tooltip: 'Previous page (PDF)',
onPressed: _currentPage > 0 ? _prevPage : null,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Center(
child: Text(
'${_currentPage + 1} / $_pageCount',
style: const TextStyle(fontSize: 13),
),
),
),
IconButton(
icon: const Icon(Icons.navigate_next),
tooltip: 'Next page (PDF)',
onPressed: _currentPage < _pageCount - 1 ? _nextPage : null,
),
const SizedBox(width: 8),
Tooltip(
message:
'Scratchpad size: ${_canvasWidth.round()} x ${_canvasHeight.round()}',
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Center(
child: Text(
'${_canvasWidth.round()}x${_canvasHeight.round()}',
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
),
),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 12, top: 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Scratchpad tools',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
_buildBrushPalette(cs),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final totalWidth = constraints.maxWidth;
final leftWidth = totalWidth * _leftPaneFraction;
final rightWidth = totalWidth - leftWidth - 12;
return Row(
children: [
SizedBox(width: leftWidth, child: _buildPdfPane()),
GestureDetector(
onHorizontalDragStart: _onDividerDragStart,
onHorizontalDragUpdate: (d) =>
_onDividerDragUpdate(d, constraints),
onHorizontalDragEnd: _onDividerDragEnd,
child: MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
child: SizedBox(
width: 12,
child: Center(
child: Container(
width: 4,
color: _isDraggingDivider
? Theme.of(context).colorScheme.primary
: Theme.of(context).dividerColor,
),
),
),
),
),
SizedBox(width: rightWidth, child: _buildScratchpadPane()),
],
);
},
),
),
],
),
);
}
/// Material You brush palette (shared chrome with the pen-first editors):
/// brush picker + highlighter + eraser, undo/redo, color dots.
Widget _buildBrushPalette(ColorScheme cs) {
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
BrushPickerButton(
selected: _penBrush,
active: _tool == CanvasTool.pen,
tooltip: 'Brush',
labelFor: brushLabelEn,
// The scratchpad keeps a single shared color (no per-brush color
// memory in this surface); show it for every brush.
colorFor: (_) => _color,
onSelected: (b) => setState(() {
_penBrush = b;
_tool = CanvasTool.pen;
}),
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == CanvasTool.highlighter,
tooltip: 'Highlighter',
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
),
ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _tool == CanvasTool.eraser,
tooltip: 'Eraser',
onPressed: () => setState(() => _tool = CanvasTool.eraser),
),
PaletteDivider(cs: cs),
ToolButton(
icon: Icons.undo,
selected: false,
tooltip: 'Undo',
onPressed: _undoManager.canUndo ? _undo : null,
),
ToolButton(
icon: Icons.redo,
selected: false,
tooltip: 'Redo',
onPressed: _undoManager.canRedo ? _redo : null,
),
PaletteDivider(cs: cs),
for (final c in _palette) _colorDot(c, cs),
],
),
),
),
);
}
Widget _colorDot(Color c, ColorScheme cs) {
final selected =
_color.toARGB32() == c.toARGB32() && _tool != CanvasTool.eraser;
return GestureDetector(
onTap: () => setState(() {
_color = c;
if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen;
}),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: 24,
height: 24,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: selected ? cs.onSurface : cs.outlineVariant,
width: selected ? 3 : 1,
),
),
),
);
}
Widget _buildPdfPane() {
// Read-only reference PDF on the same engine (pdfrx) as the rest of the
// app, opened at the anchor's page.
return PdfViewer.file(
widget.filePath,
controller: _pdfController,
params: PdfViewerParams(
onViewerReady: (document, controller) {
if (!mounted) return;
setState(() {
_pageCount = document.pages.length;
});
final target = widget.initialPage.clamp(0, _pageCount - 1);
if (target > 0) {
controller.goToPage(pageNumber: target + 1);
}
},
onPageChanged: (pageNumber) {
if (pageNumber == null || !mounted) return;
final idx = pageNumber - 1;
if (idx != _currentPage) setState(() => _currentPage = idx);
},
),
);
}
Widget _buildScratchpadPane() {
return LayoutBuilder(
builder: (context, constraints) {
if (!_scratchCentered) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_frameScratchpad(
Size(constraints.maxWidth, constraints.maxHeight));
setState(() => _scratchCentered = true);
});
}
return Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: PenCanvas(
pageSize: _worldSize,
strokes: penStrokesFromInk(_strokes, _worldSize),
transformationController: _scratchTransform,
tool: _activeTool,
brush: _penBrush,
color: _color,
strokeWidth: _strokeWidth,
eraserRadius: kDefaultEraserRadius,
minScale: 0.1,
maxScale: 8.0,
onStrokeComplete: _onStrokeComplete,
onEraseStroke: _onErase,
pageWidget: const ColoredBox(color: Colors.white),
),
);
},
);
}
/// Position the scratchpad so existing ink is on-screen (fit its bbox into
/// [pane], scale clamped); an empty scratchpad shows the top-left at 1:1.
void _frameScratchpad(Size pane) {
if (pane.isEmpty) return;
if (_strokes.isEmpty) {
_scratchTransform.value = Matrix4.identity();
return;
}
double minX = double.infinity, minY = double.infinity;
double maxX = -double.infinity, maxY = -double.infinity;
for (final s in _strokes) {
for (final p in s.points) {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
}
if (minX > maxX) {
_scratchTransform.value = Matrix4.identity();
return;
}
const pad = 80.0;
final boxW = (maxX - minX) + pad * 2;
final boxH = (maxY - minY) + pad * 2;
final scale = (pane.width / boxW < pane.height / boxH
? pane.width / boxW
: pane.height / boxH)
.clamp(0.15, 1.5);
final cx = (minX + maxX) / 2;
final cy = (minY + maxY) / 2;
final tx = pane.width / 2 - scale * cx;
final ty = pane.height / 2 - scale * cy;
_scratchTransform.value = Matrix4.identity()
..setEntry(0, 0, scale)
..setEntry(1, 1, scale)
..setEntry(2, 2, scale)
..setTranslationRaw(tx, ty, 0);
}
}