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

@@ -23,8 +23,11 @@
import 'package:flutter/gestures.dart' show PointerDeviceKind;
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:uuid/uuid.dart';
import '../../l10n/app_localizations.dart';
import '../../models/scratch_link.dart';
import '../../screens/split_view_screen.dart';
import '../../services/database_service.dart';
import '../engine/brush.dart';
import '../engine/stroke_eraser.dart';
@@ -53,6 +56,10 @@ import 'pen_stroke.dart';
/// Produces a fixed-length hex string from the path so the id is filesystem-
/// independent (no slashes, spaces, or non-ASCII characters) and stable across
/// restarts. Collisions are astronomically unlikely for a single-user app.
/// On-screen size (px) of a scratch-link anchor marker. Fixed in screen space
/// (not scaled with zoom) so the tap target stays comfortably tappable.
const double _kMarkerSize = 36.0;
String _documentIdFromPath(String path) {
var hash = 5381;
for (final c in path.codeUnits) {
@@ -156,6 +163,17 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
/// pen falls through to pdfrx for native text selection.
bool _selectTextMode = false;
/// When true the "place scratch link" tool is active: a tap on a page drops a
/// new anchor (a sticky-note tab) instead of inking. Pen capture is disabled
/// so the tap is handled by the per-page GestureDetector overlay.
bool _placeLinkMode = false;
/// All scratch-link anchors for this document, loaded on open and updated on
/// add/delete. Rendered as tappable markers in [pageOverlaysBuilder].
final List<ScratchLink> _scratchLinks = [];
static const _uuid = Uuid();
Color _color = Colors.black;
bool _allowFingerDrawing = false;
@@ -176,8 +194,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
];
/// True when a PEN tool (pen/highlighter/eraser) is active — pen capture is on.
/// False in select-text mode so the pen reaches pdfrx text selection.
bool get _penCaptureEnabled => !_selectTextMode;
/// False in select-text mode (pen reaches pdfrx text selection) and in
/// place-link mode (a tap drops an anchor via the page overlay).
bool get _penCaptureEnabled => !_selectTextMode && !_placeLinkMode;
/// True when the eraser tool is active.
bool get _isEraser => _tool == CanvasTool.eraser && !_selectTextMode;
@@ -221,6 +240,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
}
_saveScheduler = scheduler;
await _loadPersistedStrokes(repo);
await _loadScratchLinks(service);
}
/// Load this document's scratch-link anchors into [_scratchLinks].
Future<void> _loadScratchLinks(DatabaseService service) async {
final links = await service.loadScratchLinks(_documentId);
if (!mounted) return;
setState(() {
_scratchLinks
..clear()
..addAll(links);
});
}
/// Load all persisted strokes for [_documentId] and populate [_strokesByPage].
@@ -618,11 +649,84 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
setState(() {
_tool = tool;
_selectTextMode = false;
_placeLinkMode = false;
});
}
void _enableSelectText() {
setState(() => _selectTextMode = true);
setState(() {
_selectTextMode = true;
_placeLinkMode = false;
});
}
/// Toggle "place scratch link" mode. While active, a tap on a page drops a
/// new anchor at the tapped normalized position.
void _togglePlaceLinkMode() {
setState(() {
_placeLinkMode = !_placeLinkMode;
if (_placeLinkMode) _selectTextMode = false;
});
}
// ── Scratch-link anchors ─────────────────────────────────────────────────────
/// Create + persist a new anchor at normalized [normalized] on [pageIndex],
/// then show it. Leaves place-link mode on so several can be dropped in a row.
Future<void> _placeScratchLink(int pageIndex, Offset normalized) async {
final link = ScratchLink(
id: _uuid.v4(),
documentId: _documentId,
pageIndex: pageIndex,
nx: normalized.dx.clamp(0.0, 1.0),
ny: normalized.dy.clamp(0.0, 1.0),
);
final service = await DatabaseService.getInstance();
await service.saveScratchLink(link);
if (!mounted) return;
setState(() => _scratchLinks.add(link));
}
/// Open the anchor's split view (left = this PDF at the anchor page, right =
/// the anchor's private infinite scratchpad).
void _openScratchLink(ScratchLink link) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SplitViewScreen(
filePath: widget.pdfPath,
documentId: _documentId,
scratchLinkId: link.id,
initialPage: link.pageIndex,
),
),
);
}
/// Confirm + delete an anchor (and its private scratchpad).
Future<void> _confirmDeleteScratchLink(ScratchLink link) async {
final l = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.scratchLinkDeleteTitle),
content: Text(l.scratchLinkDeleteBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l.delete),
),
],
),
);
if (confirmed != true) return;
final service = await DatabaseService.getInstance();
await service.deleteScratchLink(link.id);
if (!mounted) return;
setState(() => _scratchLinks.removeWhere((s) => s.id == link.id));
}
void _toggleFingerDrawing() {
@@ -727,6 +831,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
// normalized page space scaled to the on-screen page rect.
pageOverlaysBuilder: (context, pageRectInViewer, page) {
final pageIndex = page.pageNumber - 1;
final pageW = pageRectInViewer.width;
final pageH = pageRectInViewer.height;
final linksOnPage =
_scratchLinks.where((l) => l.pageIndex == pageIndex);
return [
Positioned.fill(
child: IgnorePointer(
@@ -744,6 +852,35 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
),
),
),
// Tap-to-place layer: only swallows taps while place-link mode is on.
// Otherwise it's a no-op (IgnorePointer) so ink/scroll fall through.
if (_placeLinkMode)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: (details) {
final local = details.localPosition;
if (pageW <= 0 || pageH <= 0) return;
final nx = (local.dx / pageW).clamp(0.0, 1.0);
final ny = (local.dy / pageH).clamp(0.0, 1.0);
_placeScratchLink(pageIndex, Offset(nx, ny));
},
),
),
// Anchor markers (sticky-note tabs): tap → split view, long-press →
// delete. Sized in screen px so the tap target stays usable at any
// zoom; positioned at (nx*pageW, ny*pageH).
for (final link in linksOnPage)
Positioned(
left: link.nx * pageW - _kMarkerSize / 2,
top: link.ny * pageH - _kMarkerSize / 2,
width: _kMarkerSize,
height: _kMarkerSize,
child: _ScratchLinkMarker(
onTap: () => _openScratchLink(link),
onLongPress: () => _confirmDeleteScratchLink(link),
),
),
];
},
// (2) Viewer-level pen capture. Stylus is captured ONLY when a pen tool
@@ -814,6 +951,14 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
onPressed: _hasSelection ? _highlightSelection : null,
),
PaletteDivider(cs: cs),
// Place scratch link (sticky-note tab). Tap a page to drop an anchor.
ToolButton(
icon: Icons.sticky_note_2_outlined,
selected: _placeLinkMode,
tooltip: l.toolPlaceScratchLink,
onPressed: _togglePlaceLinkMode,
),
PaletteDivider(cs: cs),
// Undo / redo (per page).
ToolButton(
icon: Icons.undo,
@@ -1087,3 +1232,35 @@ class _PageOverlayPainter extends CustomPainter {
old.pageSize != pageSize ||
old.thinning != thinning;
}
/// A small sticky-note "tab" marker glued to a page at a scratch-link anchor.
/// Tap opens the anchor's split view; long-press deletes the anchor.
class _ScratchLinkMarker extends StatelessWidget {
const _ScratchLinkMarker({required this.onTap, required this.onLongPress});
final VoidCallback onTap;
final VoidCallback onLongPress;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onLongPress: onLongPress,
child: Material(
color: cs.tertiaryContainer,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: cs.outlineVariant),
),
child: Icon(
Icons.sticky_note_2,
size: 20,
color: cs.onTertiaryContainer,
),
),
);
}
}