feat(pdf): anchored scratch links replace board
Some checks failed
CI / Windows build (push) Has been cancelled

Replace the rejected standalone sticky-card board with the real
feature: place a link anchor anywhere on a PDF page, tap it to open
split view whose right pane is THAT anchor's own infinite scratchpad
(keyed by anchor id) — like a paper sticky-note tab.

- ScratchLink model + scratch_links table (id, doc, page, nx, ny).
- PDF editor: "place link" tool drops/loads/shows tappable markers;
  tap opens SplitViewScreen for that anchor; long-press deletes.
- SplitViewScreen rebuilt on pdfrx (was syncfusion), right scratchpad
  keyed by scratchLinkId, new brush palette (was AnnotationToolbar).
- Remove board_screen + its test + the home board entry.

analyze clean, tests green.
This commit is contained in:
2026-06-24 20:02:12 +08:00
parent f757701391
commit 9bb5c483d6
13 changed files with 759 additions and 1076 deletions

View File

@@ -1,650 +0,0 @@
// lib/screens/board_screen.dart
//
// F7 — 双链 + 无限便利贴. The reachable, persisted UI over the (already
// unit-tested) pure [Board] model. An infinite InteractiveViewer canvas hosts a
// Stack of sticky cards; cards are draggable, their text is editable, and
// `[[link]]` spans render as tappable chips that pan/center the board on the
// matching card (matched by card id — see _navigateToTarget). Backlinks
// ("什么链接到这里 / Linked from") come straight from board.backlinksOf(id).
//
// Persistence: the whole board is saved (cards as rows) via
// DatabaseService.saveBoardCards, debounced 800ms like the ink editors, and
// reloaded on open so it survives a restart.
//
// OUT OF SCOPE (left as TODOs):
// TODO(board-ink): ink/handwriting ON cards (the model mentions a per-card
// StrokeHost). Not wired here.
// TODO(board-multi): multiple-boards management UI. A single default board id
// is used when [boardId] is null; the param is kept for later.
// TODO(board-link-autocomplete): real-time `[[` autocomplete of card ids.
import 'dart:async';
import 'package:flutter/material.dart';
import '../editor/board/board.dart';
import '../l10n/app_localizations.dart';
import '../services/database_service.dart';
/// Default board id used when no [boardId] is supplied. One shared board is in
/// scope for increment 1; the param survives for future multi-board support.
const String kDefaultBoardId = 'default';
/// Default size of a freshly added sticky card (board content coordinates).
const Size _kNewCardSize = Size(180, 140);
/// The logical extent of the (conceptually infinite) board content. The Stack
/// needs a finite size; InteractiveViewer's infinite boundaryMargin lets the
/// user pan far beyond it, and cards are centered around the middle so there is
/// room to grow in every direction.
const double _kCanvasExtent = 100000;
const Offset _kCanvasCenter = Offset(_kCanvasExtent / 2, _kCanvasExtent / 2);
class BoardScreen extends StatefulWidget {
const BoardScreen({super.key, this.boardId});
/// Which board to open. Null → [kDefaultBoardId].
final String? boardId;
@override
State<BoardScreen> createState() => _BoardScreenState();
}
class _BoardScreenState extends State<BoardScreen> {
final TransformationController _transform = TransformationController();
/// The viewport size of the InteractiveViewer (used to center on a card).
final GlobalKey _viewportKey = GlobalKey();
Board _board = Board.empty;
bool _loading = true;
/// Currently selected card (drives the backlinks panel + delete affordance).
String? _selectedId;
/// The card whose text is being edited inline (null = none).
String? _editingId;
DatabaseService? _db;
Timer? _saveTimer;
String get _boardId => widget.boardId ?? kDefaultBoardId;
@override
void initState() {
super.initState();
// Center the initial viewport on the canvas center so newly added cards
// (placed at viewport center, which starts near _kCanvasCenter) are onscreen.
WidgetsBinding.instance.addPostFrameCallback((_) => _centerInitially());
_load();
}
@override
void dispose() {
// Flush any pending debounced save synchronously-ish before tearing down.
_saveTimer?.cancel();
if (_dirty) {
// Fire-and-forget: the future completes after dispose but the write still
// lands (DatabaseService is a singleton independent of this widget).
unawaited(_db?.saveBoardCards(_boardId, _board.cards) ?? Future.value());
}
_transform.dispose();
super.dispose();
}
bool _dirty = false;
Future<void> _load() async {
final db = await DatabaseService.getInstance();
final board = await db.loadBoard(_boardId);
if (!mounted) return;
setState(() {
_db = db;
_board = board;
_loading = false;
});
}
void _centerInitially() {
final ctx = _viewportKey.currentContext;
if (ctx == null) return;
final size = ctx.size;
if (size == null) return;
// Translate so that _kCanvasCenter sits at the middle of the viewport.
_transform.value = Matrix4.identity()
..translateByDouble(
size.width / 2 - _kCanvasCenter.dx,
size.height / 2 - _kCanvasCenter.dy,
0,
1,
);
}
// ── Mutations (each schedules a debounced save) ────────────────────────
void _mutate(Board next) {
setState(() => _board = next);
_dirty = true;
_scheduleSave();
}
void _scheduleSave() {
_saveTimer?.cancel();
// Capture the snapshot synchronously (Board is immutable, so _board is a
// stable value) — same discipline as SaveScheduler.
final snapshot = _board;
final boardId = _boardId;
_saveTimer = Timer(const Duration(milliseconds: 800), () async {
await _db?.saveBoardCards(boardId, snapshot.cards);
_dirty = false;
});
}
String _newCardId() => 'card-${DateTime.now().microsecondsSinceEpoch}';
/// The board-content point currently at the center of the viewport.
Offset _viewportCenterInContent() {
final ctx = _viewportKey.currentContext;
final size = ctx?.size ?? const Size(400, 600);
final viewportCenter = Offset(size.width / 2, size.height / 2);
// Inverse-map the viewport-center screen point into content coordinates.
final inv = Matrix4.inverted(_transform.value);
return MatrixUtils.transformPoint(inv, viewportCenter);
}
void _addCard() {
final center = _viewportCenterInContent();
final pos = center -
Offset(_kNewCardSize.width / 2, _kNewCardSize.height / 2);
final l = AppLocalizations.of(context);
final card = BoardCard(
id: _newCardId(),
position: pos,
size: _kNewCardSize,
text: l.boardNewCardText,
);
_mutate(_board.add(card));
setState(() {
_selectedId = card.id;
_editingId = card.id;
});
}
void _deleteCard(String id) {
_mutate(_board.removeById(id));
setState(() {
if (_selectedId == id) _selectedId = null;
if (_editingId == id) _editingId = null;
});
}
/// Pan/center the board on the card matching [target] (matched by card id).
/// Returns false when no such card exists (a dangling link).
bool _navigateToTarget(String target) {
final card = _board.cardById(target);
if (card == null) return false;
_centerOnCard(card);
setState(() => _selectedId = card.id);
return true;
}
void _centerOnCard(BoardCard card) {
final ctx = _viewportKey.currentContext;
final size = ctx?.size ?? const Size(400, 600);
final scale = _transform.value.getMaxScaleOnAxis();
final cardCenter = card.position +
Offset(card.size.width / 2, card.size.height / 2);
// We want: screen = scale * content + translation == viewportCenter.
final tx = size.width / 2 - scale * cardCenter.dx;
final ty = size.height / 2 - scale * cardCenter.dy;
_transform.value = Matrix4.identity()
..translateByDouble(tx, ty, 0, 1)
..scaleByDouble(scale, scale, scale, 1);
}
// ── Build ──────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final cs = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: Text(l.boardTitle), centerTitle: true),
floatingActionButton: FloatingActionButton(
onPressed: _loading ? null : _addCard,
tooltip: l.boardAddCard,
child: const Icon(Icons.add),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: Stack(
children: [
InteractiveViewer(
key: _viewportKey,
transformationController: _transform,
boundaryMargin: const EdgeInsets.all(double.infinity),
minScale: 0.2,
maxScale: 4,
// A finite, very large content area for the card Stack.
constrained: false,
child: GestureDetector(
// Tap on empty canvas → deselect / stop editing.
behavior: HitTestBehavior.translucent,
onTap: () => setState(() {
_selectedId = null;
_editingId = null;
}),
child: SizedBox(
width: _kCanvasExtent,
height: _kCanvasExtent,
child: Stack(
children: [
for (final card in _board.cards)
_BoardCardWidget(
key: ValueKey(card.id),
card: card,
board: _board,
selected: card.id == _selectedId,
editing: card.id == _editingId,
onSelect: () =>
setState(() => _selectedId = card.id),
onStartEdit: () =>
setState(() => _editingId = card.id),
onTextChanged: (t) =>
_mutate(_board.setText(card.id, t)),
onMoved: (pos) =>
_mutate(_board.moveCard(card.id, pos)),
onDelete: () => _confirmDelete(card.id),
onTapLink: _onTapLink,
scale: _transform.value.getMaxScaleOnAxis(),
),
],
),
),
),
),
if (_selectedId != null)
_BacklinksPanel(
board: _board,
selectedId: _selectedId!,
onTapCard: (id) {
final c = _board.cardById(id);
if (c != null) _centerOnCard(c);
setState(() => _selectedId = id);
},
onClose: () => setState(() => _selectedId = null),
background: cs.surfaceContainerHigh,
),
],
),
);
}
void _onTapLink(String target) {
final l = AppLocalizations.of(context);
final ok = _navigateToTarget(target);
if (!ok) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.boardDanglingLink(target))),
);
}
}
Future<void> _confirmDelete(String id) async {
final l = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.boardDeleteCardTitle),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l.delete),
),
],
),
);
if (confirmed == true) _deleteCard(id);
}
}
// ───────────────────────────────────────────────────────────────────────────
// One sticky card.
class _BoardCardWidget extends StatefulWidget {
const _BoardCardWidget({
super.key,
required this.card,
required this.board,
required this.selected,
required this.editing,
required this.onSelect,
required this.onStartEdit,
required this.onTextChanged,
required this.onMoved,
required this.onDelete,
required this.onTapLink,
required this.scale,
});
final BoardCard card;
final Board board;
final bool selected;
final bool editing;
final VoidCallback onSelect;
final VoidCallback onStartEdit;
final ValueChanged<String> onTextChanged;
final ValueChanged<Offset> onMoved;
final VoidCallback onDelete;
final ValueChanged<String> onTapLink;
/// Current board zoom — used to convert screen drag deltas into content space.
final double scale;
@override
State<_BoardCardWidget> createState() => _BoardCardWidgetState();
}
class _BoardCardWidgetState extends State<_BoardCardWidget> {
TextEditingController? _controller;
@override
void didUpdateWidget(_BoardCardWidget old) {
super.didUpdateWidget(old);
// Keep the live editing controller in sync if the card text changes
// underneath us (e.g. external mutation) while NOT editing.
if (!widget.editing && _controller != null) {
_controller!.dispose();
_controller = null;
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final card = widget.card;
return Positioned(
left: card.position.dx,
top: card.position.dy,
width: card.size.width,
height: card.size.height,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onSelect,
onLongPress: widget.onDelete,
// Dragging the card body moves it (content delta = screen delta / scale).
onPanStart: (_) => widget.onSelect(),
onPanUpdate: (d) {
final scale = widget.scale == 0 ? 1.0 : widget.scale;
widget.onMoved(card.position + d.delta / scale);
},
child: Material(
elevation: widget.selected ? 6 : 2,
color: cs.secondaryContainer,
surfaceTintColor: cs.surfaceTint,
borderRadius: BorderRadius.circular(12),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: widget.selected
? Border.all(color: cs.primary, width: 2)
: null,
),
padding: const EdgeInsets.all(10),
child: widget.editing
? _buildEditor(cs)
: _buildBody(context, cs),
),
),
),
);
}
Widget _buildEditor(ColorScheme cs) {
_controller ??= TextEditingController(text: widget.card.text);
return TextField(
controller: _controller,
autofocus: true,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: TextStyle(color: cs.onSecondaryContainer, fontSize: 14),
decoration: const InputDecoration(
border: InputBorder.none,
isCollapsed: true,
),
onChanged: widget.onTextChanged,
);
}
Widget _buildBody(BuildContext context, ColorScheme cs) {
return GestureDetector(
// A tap that is NOT on a link enters edit mode.
onTap: widget.onStartEdit,
child: SizedBox.expand(
child: SingleChildScrollView(
child: _LinkedText(
text: widget.card.text,
board: widget.board,
onTapLink: widget.onTapLink,
baseColor: cs.onSecondaryContainer,
),
),
),
);
}
}
// ───────────────────────────────────────────────────────────────────────────
// Renders card text with [[link]] spans as tappable inline chips. Existing card
// ids are styled as live links; unknown targets are dangling (dashed/red).
class _LinkedText extends StatelessWidget {
const _LinkedText({
required this.text,
required this.board,
required this.onTapLink,
required this.baseColor,
});
final String text;
final Board board;
final ValueChanged<String> onTapLink;
final Color baseColor;
static final RegExp _linkPattern = RegExp(r'\[\[([^\]]*)\]\]');
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final spans = <InlineSpan>[];
var last = 0;
for (final m in _linkPattern.allMatches(text)) {
if (m.start > last) {
spans.add(TextSpan(text: text.substring(last, m.start)));
}
final target = (m.group(1) ?? '').trim();
final exists = target.isNotEmpty && board.cardById(target) != null;
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _LinkChip(
label: target.isEmpty ? '[[]]' : target,
exists: exists,
onTap: target.isEmpty ? null : () => onTapLink(target),
cs: cs,
),
),
);
last = m.end;
}
if (last < text.length) {
spans.add(TextSpan(text: text.substring(last)));
}
return Text.rich(
TextSpan(
style: TextStyle(color: baseColor, fontSize: 14),
children: spans,
),
);
}
}
class _LinkChip extends StatelessWidget {
const _LinkChip({
required this.label,
required this.exists,
required this.onTap,
required this.cs,
});
final String label;
final bool exists;
final VoidCallback? onTap;
final ColorScheme cs;
@override
Widget build(BuildContext context) {
final color = exists ? cs.primary : cs.error;
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: color,
width: 1,
// Dangling links get a visibly different (no fill, error) look.
style: BorderStyle.solid,
),
),
child: Text(
label,
style: TextStyle(
color: color,
fontSize: 13,
fontWeight: FontWeight.w600,
decoration:
exists ? TextDecoration.none : TextDecoration.lineThrough,
),
),
),
);
}
}
// ───────────────────────────────────────────────────────────────────────────
// "什么链接到这里 / Linked from" panel for the selected card.
class _BacklinksPanel extends StatelessWidget {
const _BacklinksPanel({
required this.board,
required this.selectedId,
required this.onTapCard,
required this.onClose,
required this.background,
});
final Board board;
final String selectedId;
final ValueChanged<String> onTapCard;
final VoidCallback onClose;
final Color background;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final backlinks = board.backlinksOf(selectedId).toList()..sort();
return Positioned(
right: 12,
top: 12,
bottom: 12,
width: 240,
child: Material(
elevation: 4,
color: background,
borderRadius: BorderRadius.circular(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 4),
child: Row(
children: [
Expanded(
child: Text(
l.boardBacklinks,
style: Theme.of(context).textTheme.titleSmall,
),
),
IconButton(
icon: const Icon(Icons.close),
tooltip: l.close,
onPressed: onClose,
),
],
),
),
const Divider(height: 1),
Expanded(
child: backlinks.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
l.boardNoBacklinks,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
)
: ListView(
children: [
for (final id in backlinks)
ListTile(
dense: true,
leading: const Icon(Icons.link, size: 18),
title: Text(
_preview(board, id),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
id,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () => onTapCard(id),
),
],
),
),
],
),
),
);
}
String _preview(Board board, String id) {
final c = board.cardById(id);
if (c == null) return id;
final text = c.text.trim();
return text.isEmpty ? id : text.replaceAll('\n', ' ');
}
}

View File

@@ -11,10 +11,8 @@ import '../services/pdf_service.dart';
import '../services/pptx_service.dart';
import '../editor/canvas/pen_note_screen.dart';
import '../editor/canvas/pen_slide_screen.dart';
import 'board_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart';
import 'split_view_screen.dart';
// [M1] Relative date helper — no new package dependencies.
String _formatDate(DateTime d) {
@@ -61,15 +59,6 @@ class HomeScreen extends ConsumerWidget {
tooltip: l.importPpt,
onPressed: () => _importPptx(context),
),
IconButton(
icon: const Icon(Icons.dashboard_customize),
tooltip: l.boardOpen,
onPressed: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const BoardScreen()));
},
),
IconButton(
icon: const Icon(Icons.search),
tooltip: l.search,
@@ -502,32 +491,18 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
'${document.docType.toUpperCase()} · ${document.pageCount} pages · $dateStr',
style: Theme.of(context).textTheme.bodySmall,
),
// [H2] Trailing row: split-view (PDF only) + remove
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isPdf)
IconButton(
icon: Icon(
Icons.vertical_split,
color: _hovering
? Theme.of(context).colorScheme.primary
: subtleColor.withValues(alpha: 0.4),
),
tooltip: 'Open in Split View',
onPressed: () => _openSplitView(context),
),
IconButton(
icon: Icon(
Icons.delete_outline,
color: _hovering
? Theme.of(context).colorScheme.error
: subtleColor.withValues(alpha: 0.4),
),
tooltip: 'Remove document',
onPressed: () => _confirmDelete(context),
),
],
// [H2] Trailing remove button. Split view is now reached only by
// tapping a scratch-link anchor inside the PDF editor, so the
// standalone split-view entry was removed.
trailing: IconButton(
icon: Icon(
Icons.delete_outline,
color: _hovering
? Theme.of(context).colorScheme.error
: subtleColor.withValues(alpha: 0.4),
),
tooltip: 'Remove document',
onPressed: () => _confirmDelete(context),
),
// [L2] Routing bug fix: route by docType
onTap: () => _openDocument(context),
@@ -577,19 +552,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
}
}
void _openSplitView(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SplitViewScreen(
filePath: widget.document.filePath,
documentId: widget.document.id,
),
),
);
}
void _showContextMenu(BuildContext context, Offset position) async {
final isPdf = widget.document.docType == 'pdf';
final result = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
@@ -609,17 +572,6 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
],
),
),
if (isPdf)
PopupMenuItem(
value: 'split',
child: Row(
children: const [
Icon(Icons.vertical_split),
SizedBox(width: 8),
Text('Open in Split View'),
],
),
),
PopupMenuItem(
value: 'remove',
child: Row(
@@ -641,15 +593,12 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
if (!mounted) return;
if (result == 'open') {
_openDocument(this.context);
} else if (result == 'split') {
_openSplitView(this.context);
} else if (result == 'remove') {
_confirmDelete(this.context);
}
}
void _showDocumentMenu(BuildContext context) {
final isPdf = widget.document.docType == 'pdf';
showModalBottomSheet(
context: context,
builder: (ctx) {
@@ -657,16 +606,6 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isPdf)
ListTile(
leading: const Icon(Icons.vertical_split),
title: const Text('Open in Split View'),
subtitle: const Text('PDF reference + scratchpad'),
onTap: () {
Navigator.of(ctx).pop();
_openSplitView(context);
},
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: const Text(

View File

@@ -1,33 +1,56 @@
// 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 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.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 '../models/ink_stroke.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../services/database_service.dart';
import '../services/undo_manager.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/annotation_toolbar.dart';
/// Split-view derivation mode: left pane = reference PDF, right pane = infinite
/// scratchpad for formula derivation. Scratchpad strokes are persisted per
/// document via [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad].
/// 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 per ANCHOR via
/// [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad], keyed by
/// [scratchLinkId].
class SplitViewScreen extends StatefulWidget {
final String filePath;
final String documentId;
/// The owning anchor id. Doubles as the scratchpad storage key so this is the
/// anchor's private scratch space.
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.documentId,
required this.scratchLinkId,
this.initialPage = 0,
});
@override
@@ -37,63 +60,55 @@ class SplitViewScreen extends StatefulWidget {
class _SplitViewState extends State<SplitViewScreen> {
// -- PDF (left pane) --
final PdfViewerController _pdfController = PdfViewerController();
int _currentPage = 0;
int _currentPage = 0; // 0-based
int _pageCount = 0;
String _fileName = '';
// -- Split divider --
double _leftPaneFraction = 0.5;
bool _isDraggingDivider = false;
// -- Scratchpad (right pane) --
// The scratchpad is an infinite WORLD: strokes are stored in absolute world
// pixels ([InkStroke], unchanged persistence format), and rendered through the
// performant PenCanvas by normalizing against the CURRENT world size. When the
// world auto-expands, the stored world coords don't move — only the
// normalization divisor grows — so ink stays put with zero drift.
// 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();
/// Pan/zoom transform for the scratchpad world (PenCanvas drives this).
final TransformationController _scratchTransform = TransformationController();
/// Set once the initial view has been framed onto existing ink.
bool _scratchCentered = false;
Size get _worldSize => Size(_canvasWidth, _canvasHeight);
/// Maps the scratchpad toolbar's [PenTool] to the pen-canvas tool. Shapes and
/// text fall back to pen (the pen-first scratchpad is freehand).
CanvasTool get _canvasTool => switch (_currentTool) {
PenTool.eraser => CanvasTool.eraser,
PenTool.highlighter => CanvasTool.highlighter,
_ => CanvasTool.pen,
};
// -- Tool state (new Material You brush palette) --
CanvasTool _tool = CanvasTool.pen;
BrushKind _penBrush = BrushKind.fountainPen;
Color _color = Colors.black;
// -- Tool state --
PenTool _currentTool = PenTool.pen;
Color _currentColor = Colors.black;
double _currentStrokeWidth = 2.0;
bool _filled = false;
PressureCurveType _pressureCurveType = PressureCurveType.linear;
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
static const double _penWidthFraction = 0.006;
static const double _highlighterWidthFraction = 0.02;
static const List<Color> _palette = [
Colors.black,
Colors.red,
Colors.blue,
Colors.green,
Colors.orange,
];
// -- Auto-save debounce --
Timer? _saveTimer;
bool _dirty = false;
// -- Page link markers (optional feature) --
final List<_PageLink> _pageLinks = [];
static const double _edgeThreshold = 200.0;
static const double _expandAmount = 1000.0;
@override
void initState() {
super.initState();
_currentPage = widget.initialPage;
_loadScratchpad();
}
@@ -101,27 +116,26 @@ class _SplitViewState extends State<SplitViewScreen> {
void dispose() {
_saveTimer?.cancel();
_saveImmediate();
_pdfController.dispose();
// PdfViewerController (pdfrx) has no dispose(); it detaches with the viewer.
_scratchTransform.dispose();
super.dispose();
}
// -- Persistence --
// -- Persistence (keyed by the ANCHOR id, not the documentId) --
Future<void> _loadScratchpad() async {
final db = await DatabaseService.getInstance();
final strokes = await db.loadScratchpad(widget.documentId);
if (mounted) {
setState(() {
// 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);
}
});
}
final strokes = await db.loadScratchpad(widget.scratchLinkId);
if (!mounted) return;
setState(() {
// 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() {
@@ -135,13 +149,11 @@ class _SplitViewState extends State<SplitViewScreen> {
_dirty = false;
final db = await DatabaseService.getInstance();
final json = jsonEncode(_strokes.map((s) => s.toJson()).toList());
await db.saveScratchpad(widget.documentId, json);
await db.saveScratchpad(widget.scratchLinkId, json);
}
// -- Scratchpad stroke callbacks --
/// PenCanvas committed a stroke (normalized to the current world). Convert it
/// to absolute world coords for storage.
void _onStrokeComplete(PenStroke pen) {
final stroke = inkStrokeFromPen(pen, _worldSize,
id: _uuid.v4(), createdAt: DateTime.now());
@@ -153,8 +165,6 @@ class _SplitViewState extends State<SplitViewScreen> {
_scheduleSave();
}
/// PenCanvas erased through stroke [index] (into [_strokes]); [replacements]
/// are the surviving sub-strokes (normalized) — convert back to world coords.
void _onErase(int index, List<PenStroke> replacements) {
if (index < 0 || index >= _strokes.length) return;
setState(() {
@@ -229,62 +239,32 @@ class _SplitViewState extends State<SplitViewScreen> {
setState(() => _isDraggingDivider = false);
}
// -- PDF page navigation --
// -- PDF page navigation (left pane) --
void _prevPage() {
if (_currentPage > 0) {
_pdfController.previousPage();
}
if (_currentPage > 0) _pdfController.goToPage(pageNumber: _currentPage);
}
void _nextPage() {
if (_currentPage < _pageCount - 1) {
_pdfController.nextPage();
_pdfController.goToPage(pageNumber: _currentPage + 2);
}
}
// -- Page link creation (long-press on left pane) --
CanvasTool get _activeTool => _tool;
void _onPdfLongPress(int pageNumber) {
// Place a page link marker at the current scratchpad viewport center.
// We approximate the viewport center as (0, 0) since InteractiveViewer
// manages its own transform — the user can reposition by panning.
setState(() {
_pageLinks.add(
_PageLink(
pageNumber: pageNumber,
position: const Offset(100, 100), // default top-left area
),
);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Page link marker added for page $pageNumber')),
);
}
void _onPageLinkTap(_PageLink link) {
_pdfController.jumpToPage(link.pageNumber);
setState(() {
_currentPage = link.pageNumber - 1;
});
}
void _deletePageLink(_PageLink link) {
setState(() {
_pageLinks.remove(link);
});
}
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: Text(
_fileName.isEmpty ? 'Split View' : _fileName,
style: const TextStyle(fontSize: 16),
),
title: const Text('Scratch link', style: TextStyle(fontSize: 16)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
@@ -293,7 +273,6 @@ class _SplitViewState extends State<SplitViewScreen> {
},
),
actions: [
// Left pane page navigation
IconButton(
icon: const Icon(Icons.navigate_before),
tooltip: 'Previous page (PDF)',
@@ -314,7 +293,6 @@ class _SplitViewState extends State<SplitViewScreen> {
onPressed: _currentPage < _pageCount - 1 ? _nextPage : null,
),
const SizedBox(width: 8),
// Canvas info
Tooltip(
message:
'Scratchpad size: ${_canvasWidth.round()} x ${_canvasHeight.round()}',
@@ -332,7 +310,6 @@ class _SplitViewState extends State<SplitViewScreen> {
),
body: Column(
children: [
// Label clarifying that the toolbar controls the scratchpad pane.
Padding(
padding: const EdgeInsets.only(left: 12, top: 4),
child: Align(
@@ -340,47 +317,22 @@ class _SplitViewState extends State<SplitViewScreen> {
child: Text(
'Scratchpad tools',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
// Toolbar (applies to scratchpad only)
AnnotationToolbar(
currentTool: _currentTool,
currentColor: _currentColor,
currentStrokeWidth: _currentStrokeWidth,
filled: _filled,
pressureCurveType: _pressureCurveType,
stabilizationLevel: _stabilizationLevel,
canUndo: _undoManager.canUndo,
canRedo: _undoManager.canRedo,
onToolChanged: (tool) => setState(() => _currentTool = tool),
onColorChanged: (color) => setState(() => _currentColor = color),
onStrokeWidthChanged: (w) =>
setState(() => _currentStrokeWidth = w),
onFilledChanged: (f) => setState(() => _filled = f),
onPressureCurveChanged: (v) =>
setState(() => _pressureCurveType = v),
onStabilizationChanged: (v) =>
setState(() => _stabilizationLevel = v),
onUndo: _undo,
onRedo: _redo,
),
// Split view body
_buildBrushPalette(cs),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final totalWidth = constraints.maxWidth;
final leftWidth = totalWidth * _leftPaneFraction;
final rightWidth =
totalWidth - leftWidth - 12; // 12px divider hit area
final rightWidth = totalWidth - leftWidth - 12;
return Row(
children: [
// Left pane: PDF reference (read-only)
SizedBox(width: leftWidth, child: _buildPdfPane()),
// Draggable divider: 12px hit area, 4px visual strip.
GestureDetector(
onHorizontalDragStart: _onDividerDragStart,
onHorizontalDragUpdate: (d) =>
@@ -401,7 +353,6 @@ class _SplitViewState extends State<SplitViewScreen> {
),
),
),
// Right pane: Infinite scratchpad
SizedBox(width: rightWidth, child: _buildScratchpadPane()),
],
);
@@ -413,72 +364,118 @@ class _SplitViewState extends State<SplitViewScreen> {
);
}
Widget _buildPdfPane() {
return Stack(
children: [
GestureDetector(
onLongPress: () {
// Long-press on PDF to create page link marker
_onPdfLongPress(_currentPage + 1);
},
child: SfPdfViewer.file(
File(widget.filePath),
controller: _pdfController,
canShowScrollHead: true,
canShowScrollStatus: true,
onPageChanged: (PdfPageChangedDetails details) {
setState(() {
_currentPage = details.newPageNumber - 1;
});
},
onDocumentLoaded: (PdfDocumentLoadedDetails details) {
setState(() {
_pageCount = details.document.pages.count;
_fileName = widget.filePath.split(Platform.pathSeparator).last;
});
},
/// 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,
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),
],
),
),
// Page link markers overlay (on PDF pane, showing linked pages)
if (_pageLinks.isNotEmpty)
Positioned(bottom: 8, left: 8, child: _buildPageLinkChips()),
],
),
);
}
Widget _buildPageLinkChips() {
return Wrap(
spacing: 4,
runSpacing: 4,
children: _pageLinks.map((link) {
return GestureDetector(
onTap: () => _onPageLinkTap(link),
onLongPress: () => _deletePageLink(link),
child: Chip(
avatar: const Icon(Icons.link, size: 14, color: Colors.white),
label: Text(
'p${link.pageNumber}',
style: const TextStyle(fontSize: 11, color: Colors.white),
),
backgroundColor: Colors.blue.shade600,
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
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,
),
);
}).toList(),
),
),
);
}
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() {
// Render the world through the performant PenCanvas: strokes normalized
// against the current world size; toolbar width is in world pixels, so the
// pen-canvas fraction is width / worldWidth.
return LayoutBuilder(
builder: (context, constraints) {
// On first layout, frame the view so existing ink is actually visible
// (otherwise identity shows only the empty top-left corner of the huge
// world). Empty scratchpad falls back to a comfortable 1:1 near origin.
if (!_scratchCentered) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
@@ -493,10 +490,11 @@ class _SplitViewState extends State<SplitViewScreen> {
pageSize: _worldSize,
strokes: penStrokesFromInk(_strokes, _worldSize),
transformationController: _scratchTransform,
tool: _canvasTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth / _canvasWidth,
// The world is huge, so allow zooming further out to survey it.
tool: _activeTool,
brush: _penBrush,
color: _color,
strokeWidth: _strokeWidth,
eraserRadius: kDefaultEraserRadius,
minScale: 0.1,
maxScale: 8.0,
onStrokeComplete: _onStrokeComplete,
@@ -508,9 +506,8 @@ class _SplitViewState extends State<SplitViewScreen> {
);
}
/// Position the scratchpad so existing ink is on-screen. Fits the strokes'
/// world bounding box into [pane] (with padding, scale clamped); for an empty
/// scratchpad, shows the top-left working area at 1:1.
/// 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) {
@@ -534,9 +531,10 @@ class _SplitViewState extends State<SplitViewScreen> {
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 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;
@@ -548,11 +546,3 @@ class _SplitViewState extends State<SplitViewScreen> {
..setTranslationRaw(tx, ty, 0);
}
}
/// A marker linking a scratchpad position to a specific PDF page.
class _PageLink {
final int pageNumber;
final Offset position;
const _PageLink({required this.pageNumber, required this.position});
}