feat(pdf): anchored scratch links replace board
Some checks failed
CI / Windows build (push) Has been cancelled
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:
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user