feat(storage): PDF editor persists to per-file sidecar
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Phase 2 (core swap). The PDF editor and split-view scratchpad stop writing SQLite and persist to a per-file sidecar `<pdfPath>.badnote.json` (debounced, atomic temp+rename+.bak) — so annotations travel with the file. The source path is the identity (no more djb2 doc-id). - SidecarRepository wraps the Phase-1 store with debounced autosave. - pen_editor: per-page ink, scratch-links AND highlights now persist to the sidecar and restore on reopen (closes persist-highlights). - New "un-highlight" tool: tap a stored highlight to remove it — the highlight could not be removed before. - split_view: each anchor's scratchpad lives in the sidecar's scratchLinks[id].scratchpad, keyed by anchor id. Note: pre-existing SQLite annotations are migrated later (Phase 5); note/slide editors swap in Phase 4. analyze clean, tests green.
This commit is contained in:
@@ -16,9 +16,11 @@
|
||||
// "highlight selection" action turns the current selection into stored
|
||||
// normalized highlight rects.
|
||||
//
|
||||
// Persistence (strokes) reuses the existing EditorRepository / SaveScheduler
|
||||
// wiring verbatim. Highlights are in-memory only for now — see
|
||||
// TODO(persist-highlights).
|
||||
// Persistence (Phase 2): strokes, highlights and scratch-links all write to a
|
||||
// per-file SIDECAR (`<pdfPath>.badnote.json`) via SidecarRepository — debounced,
|
||||
// atomic (temp+rename+.bak). The source file PATH is the identity (the sidecar
|
||||
// IS the identity; the old djb2 path-hash document id is gone). Highlights now
|
||||
// survive reopen, and a stored highlight can be removed (the un-highlight tool).
|
||||
|
||||
import 'package:flutter/foundation.dart'
|
||||
show ValueListenable, visibleForTesting;
|
||||
@@ -30,7 +32,7 @@ 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 '../../storage/badnote_sidecar.dart';
|
||||
import '../engine/brush.dart';
|
||||
import '../engine/shape_geometry.dart';
|
||||
import '../engine/stroke_eraser.dart';
|
||||
@@ -44,8 +46,7 @@ import '../input/pen_input_service.dart';
|
||||
import '../input/pressure_curve.dart'
|
||||
show PressureCurve, kNaturalPressureFloor;
|
||||
import '../pdf/pen_capture_region.dart';
|
||||
import '../persistence/editor_repository.dart';
|
||||
import '../persistence/save_scheduler.dart';
|
||||
import '../persistence/sidecar_repository.dart';
|
||||
import '../ui/pen_settings_page.dart';
|
||||
import '../ui/thumbnail_grid.dart';
|
||||
import 'editor_tool.dart';
|
||||
@@ -55,23 +56,10 @@ import 'pen_palette_widgets.dart';
|
||||
import 'pen_stroke.dart';
|
||||
import 'pinch_scale_solver.dart';
|
||||
|
||||
/// Stable deterministic document-id for a file path (djb2 hash → hex).
|
||||
///
|
||||
/// 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) {
|
||||
hash = ((hash << 5) + hash + c) & 0xFFFFFFFF;
|
||||
}
|
||||
return hash.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
|
||||
class PenEditorScreen extends StatefulWidget {
|
||||
const PenEditorScreen({
|
||||
super.key,
|
||||
@@ -109,7 +97,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
|
||||
/// Text highlights per page, keyed by 0-based page index. Each Rect is in
|
||||
/// NORMALIZED page coords (left/top/right/bottom in [0,1]) so it stays glued
|
||||
/// under zoom. In-memory only for now — see TODO(persist-highlights).
|
||||
/// under zoom. Persisted to the sidecar (Phase 2 — highlights now survive
|
||||
/// reopen and can be removed via the un-highlight tool).
|
||||
final Map<int, List<Rect>> _highlightsByPage = {};
|
||||
|
||||
/// Per-page undo/redo history. Snapshot-before-change discipline: the
|
||||
@@ -125,10 +114,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
|
||||
// ── Persistence ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Stable document-id derived from the PDF file path.
|
||||
late final String _documentId;
|
||||
|
||||
SaveScheduler? _saveScheduler;
|
||||
/// Per-file sidecar persistence. Identity is the source file PATH (the
|
||||
/// sidecar IS the identity — no more djb2 path-hash document id). Null until
|
||||
/// [_initPersistence] resolves.
|
||||
SidecarRepository? _repo;
|
||||
|
||||
/// Repaint signal for the page overlays. Bumped on every pen move/commit/erase
|
||||
/// and on every viewer transform so the per-page CustomPaint re-projects the
|
||||
@@ -250,6 +239,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
/// so the tap is handled by the per-page GestureDetector overlay.
|
||||
bool _placeLinkMode = false;
|
||||
|
||||
/// When true the "remove highlight" tool is active: a tap on an existing
|
||||
/// highlight rect deletes it (the un-highlight action). Pen capture is
|
||||
/// disabled so the tap is handled by the per-page GestureDetector overlay.
|
||||
bool _removeHighlightMode = 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 = [];
|
||||
@@ -279,7 +273,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
/// True when an ink tool (brush/highlighter/eraser/select/shape) is active —
|
||||
/// pen capture is on. False in select-text mode (pen reaches pdfrx text
|
||||
/// selection) and in place-link mode (a tap drops an anchor via the overlay).
|
||||
bool get _penCaptureEnabled => !_selectTextMode && !_placeLinkMode;
|
||||
bool get _penCaptureEnabled =>
|
||||
!_selectTextMode && !_placeLinkMode && !_removeHighlightMode;
|
||||
|
||||
/// True when the eraser tool is active.
|
||||
bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode;
|
||||
@@ -294,7 +289,6 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_documentId = _documentIdFromPath(widget.pdfPath);
|
||||
// Begin listening to the native Windows pen plugin (barrel/eraser/tilt).
|
||||
// No-op on platforms without the plugin (W3).
|
||||
PenInputService.instance.start();
|
||||
@@ -320,42 +314,25 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
}
|
||||
|
||||
Future<void> _initPersistence() async {
|
||||
final service = await DatabaseService.getInstance();
|
||||
if (!mounted) return;
|
||||
final repo = await EditorRepository.fromService(service);
|
||||
final scheduler = SaveScheduler(repo);
|
||||
final repo = await SidecarRepository.open(widget.pdfPath, docType: 'pdf');
|
||||
if (!mounted) {
|
||||
scheduler.dispose();
|
||||
repo.dispose();
|
||||
return;
|
||||
}
|
||||
_saveScheduler = scheduler;
|
||||
await _loadPersistedStrokes(repo);
|
||||
await _loadScratchLinks(service);
|
||||
_repo = repo;
|
||||
_loadFromSidecar(repo);
|
||||
}
|
||||
|
||||
/// 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].
|
||||
Future<void> _loadPersistedStrokes(EditorRepository repo) async {
|
||||
final hosted = await repo.loadDocument(_documentId);
|
||||
if (!mounted) return;
|
||||
final loaded = <int, List<PenStroke>>{};
|
||||
for (final entry in hosted.entries) {
|
||||
final pageIndex = _pageIndexFromHostId(entry.key);
|
||||
if (pageIndex == null) continue;
|
||||
loaded[pageIndex] = entry.value
|
||||
/// Hydrate the in-memory editor state from the sidecar [repo] loaded on open:
|
||||
/// per-page strokes, per-page highlights, and scratch-link anchors.
|
||||
void _loadFromSidecar(SidecarRepository repo) {
|
||||
final loadedStrokes = <int, List<PenStroke>>{};
|
||||
for (final entry in repo.loadedStrokes.entries) {
|
||||
loadedStrokes[entry.key] = entry.value
|
||||
.map((es) => PenStroke(
|
||||
points: es.points
|
||||
.map((ep) => PenPoint(ep.x, ep.y, ep.pressure, tilt: ep.tilt))
|
||||
.map((ep) =>
|
||||
PenPoint(ep.x, ep.y, ep.pressure, tilt: ep.tilt))
|
||||
.toList(),
|
||||
color: es.color,
|
||||
width: es.width,
|
||||
@@ -372,32 +349,34 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
if (loaded.isNotEmpty) {
|
||||
setState(() {
|
||||
for (final entry in loaded.entries) {
|
||||
_strokesByPage[entry.key] = entry.value;
|
||||
}
|
||||
});
|
||||
_bumpOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the page index from a host_id of the form
|
||||
/// `"doc:<documentId>:page:<pageIndex>"`.
|
||||
int? _pageIndexFromHostId(String hostId) {
|
||||
const marker = ':page:';
|
||||
final idx = hostId.lastIndexOf(marker);
|
||||
if (idx == -1) return null;
|
||||
return int.tryParse(hostId.substring(idx + marker.length));
|
||||
final loadedHighlights = <int, List<Rect>>{};
|
||||
for (final entry in repo.loadedHighlights.entries) {
|
||||
loadedHighlights[entry.key] =
|
||||
entry.value.map((h) => h.toRect()).toList();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
for (final entry in loadedStrokes.entries) {
|
||||
_strokesByPage[entry.key] = entry.value;
|
||||
}
|
||||
for (final entry in loadedHighlights.entries) {
|
||||
_highlightsByPage[entry.key] = entry.value;
|
||||
}
|
||||
_scratchLinks
|
||||
..clear()
|
||||
..addAll(repo.loadedScratchLinks.map((s) => s.link));
|
||||
});
|
||||
_bumpOverlay();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Flush any pending scheduled saves before tearing down.
|
||||
final scheduler = _saveScheduler;
|
||||
if (scheduler != null) {
|
||||
scheduler.flush(); // fire-and-forget; DB write continues in isolate
|
||||
scheduler.dispose();
|
||||
// Flush any pending sidecar write before tearing down.
|
||||
final repo = _repo;
|
||||
if (repo != null) {
|
||||
repo.flush(); // fire-and-forget; atomic write completes off the UI tree
|
||||
repo.dispose();
|
||||
}
|
||||
_overlayRepaint.dispose();
|
||||
_liveStrokeVN.dispose();
|
||||
@@ -446,18 +425,14 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
_bumpOverlay();
|
||||
}
|
||||
|
||||
/// Convert [strokes] to [EditorStroke]s and hand them to the save scheduler.
|
||||
/// Convert [strokes] to [EditorStroke]s and hand them to the sidecar repo.
|
||||
void _schedulePageSave(int pageIndex, List<PenStroke> strokes) {
|
||||
final scheduler = _saveScheduler;
|
||||
if (scheduler == null) return;
|
||||
final repo = _repo;
|
||||
if (repo == null) return;
|
||||
final editorStrokes = strokes
|
||||
.map((s) => simplifyStroke(EditorStroke.fromPenStroke(s)))
|
||||
.toList();
|
||||
scheduler.schedule(
|
||||
'page',
|
||||
EditorRepository.pageHostId(_documentId, pageIndex),
|
||||
editorStrokes,
|
||||
);
|
||||
repo.scheduleStrokeSave(pageIndex, editorStrokes);
|
||||
}
|
||||
|
||||
void _performUndo() {
|
||||
@@ -917,12 +892,52 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
}
|
||||
_hasSelection = false;
|
||||
});
|
||||
// TODO(persist-highlights): highlights are in-memory only; wire them into
|
||||
// EditorRepository (a new host_kind) for cross-session persistence.
|
||||
// Persist each touched page's highlights to the sidecar (Phase 2 — closes
|
||||
// the old in-memory-only limitation; highlights now survive reopen).
|
||||
for (final pageIndex in added.keys) {
|
||||
_scheduleHighlightSave(pageIndex);
|
||||
}
|
||||
await delegate.clearTextSelection();
|
||||
_bumpOverlay();
|
||||
}
|
||||
|
||||
/// Serialize the current highlights for [pageIndex] to the sidecar.
|
||||
void _scheduleHighlightSave(int pageIndex) {
|
||||
final repo = _repo;
|
||||
if (repo == null) return;
|
||||
final rects = _highlightsByPage[pageIndex] ?? const <Rect>[];
|
||||
repo.scheduleHighlightSave(
|
||||
pageIndex,
|
||||
rects.map((r) => SidecarHighlight.fromRect(r)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove the highlight rect on [pageIndex] under the normalized point
|
||||
/// [normalized] (topmost hit), persisting the change. Returns true if one was
|
||||
/// removed. This is the "un-highlight" action (a stored highlight could not be
|
||||
/// removed before): in SELECT-TEXT mode a tap on an existing highlight erases
|
||||
/// it.
|
||||
bool _removeHighlightAt(int pageIndex, Offset normalized) {
|
||||
final rects = _highlightsByPage[pageIndex];
|
||||
if (rects == null || rects.isEmpty) return false;
|
||||
for (var i = rects.length - 1; i >= 0; i--) {
|
||||
if (rects[i].contains(normalized)) {
|
||||
setState(() {
|
||||
final next = List<Rect>.of(rects)..removeAt(i);
|
||||
if (next.isEmpty) {
|
||||
_highlightsByPage.remove(pageIndex);
|
||||
} else {
|
||||
_highlightsByPage[pageIndex] = next;
|
||||
}
|
||||
});
|
||||
_scheduleHighlightSave(pageIndex);
|
||||
_bumpOverlay();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Navigation / tools ─────────────────────────────────────────────────────
|
||||
|
||||
void _goToPage(int index) {
|
||||
@@ -936,6 +951,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
_tool = tool;
|
||||
_selectTextMode = false;
|
||||
_placeLinkMode = false;
|
||||
_removeHighlightMode = false;
|
||||
if (tool != EditorToolKind.select) _selected = null;
|
||||
});
|
||||
}
|
||||
@@ -944,6 +960,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
setState(() {
|
||||
_selectTextMode = true;
|
||||
_placeLinkMode = false;
|
||||
_removeHighlightMode = false;
|
||||
_selected = null;
|
||||
});
|
||||
}
|
||||
@@ -953,7 +970,23 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
void _togglePlaceLinkMode() {
|
||||
setState(() {
|
||||
_placeLinkMode = !_placeLinkMode;
|
||||
if (_placeLinkMode) _selectTextMode = false;
|
||||
if (_placeLinkMode) {
|
||||
_selectTextMode = false;
|
||||
_removeHighlightMode = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Toggle "remove highlight" mode (the un-highlight action). While active, a
|
||||
/// tap on an existing highlight deletes it (persisted to the sidecar).
|
||||
void _toggleRemoveHighlightMode() {
|
||||
setState(() {
|
||||
_removeHighlightMode = !_removeHighlightMode;
|
||||
if (_removeHighlightMode) {
|
||||
_selectTextMode = false;
|
||||
_placeLinkMode = false;
|
||||
_selected = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -964,30 +997,48 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
Future<void> _placeScratchLink(int pageIndex, Offset normalized) async {
|
||||
final link = ScratchLink(
|
||||
id: _uuid.v4(),
|
||||
documentId: _documentId,
|
||||
// The source file path is the identity now (the sidecar IS the identity);
|
||||
// documentId is retained on the model only so the split view can address
|
||||
// the source PDF. Use the file path so it's stable across reopen.
|
||||
documentId: widget.pdfPath,
|
||||
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);
|
||||
_repo?.scheduleScratchLinkUpsert(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(
|
||||
/// the anchor's private infinite scratchpad, stored in the sidecar).
|
||||
///
|
||||
/// Flushes any pending sidecar write FIRST so the split view (which opens its
|
||||
/// own [SidecarRepository] on the same file) sees this anchor on disk before
|
||||
/// it writes the scratchpad back. On return, reload so any scratchpad change
|
||||
/// made there is reflected in this editor's in-memory sidecar repo.
|
||||
Future<void> _openScratchLink(ScratchLink link) async {
|
||||
await _repo?.flush();
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SplitViewScreen(
|
||||
filePath: widget.pdfPath,
|
||||
documentId: _documentId,
|
||||
scratchLinkId: link.id,
|
||||
initialPage: link.pageIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
// The split view wrote the scratchpad into the on-disk sidecar via its own
|
||||
// repo; re-open ours so subsequent saves here don't clobber that scratchpad.
|
||||
final repo = await SidecarRepository.open(widget.pdfPath, docType: 'pdf');
|
||||
if (!mounted) {
|
||||
repo.dispose();
|
||||
return;
|
||||
}
|
||||
_repo?.dispose();
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
/// Confirm + delete an anchor (and its private scratchpad).
|
||||
@@ -1011,8 +1062,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
final service = await DatabaseService.getInstance();
|
||||
await service.deleteScratchLink(link.id);
|
||||
_repo?.scheduleScratchLinkDelete(link.id);
|
||||
if (!mounted) return;
|
||||
setState(() => _scratchLinks.removeWhere((s) => s.id == link.id));
|
||||
}
|
||||
@@ -1168,6 +1218,22 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
// Tap-to-remove-highlight layer (the un-highlight action): only
|
||||
// swallows taps while remove-highlight mode is on; a tap on an
|
||||
// existing highlight deletes it (persisted to the sidecar).
|
||||
if (_removeHighlightMode)
|
||||
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);
|
||||
_removeHighlightAt(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).
|
||||
@@ -1297,6 +1363,14 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
tooltip: l.actionHighlightSelection,
|
||||
onPressed: _hasSelection ? _highlightSelection : null,
|
||||
),
|
||||
// Un-highlight: toggle a mode where tapping an existing highlight
|
||||
// removes it (the user couldn't remove highlights before).
|
||||
ToolButton(
|
||||
icon: Icons.highlight_off,
|
||||
selected: _removeHighlightMode,
|
||||
tooltip: l.toolRemoveHighlight,
|
||||
onPressed: _toggleRemoveHighlightMode,
|
||||
),
|
||||
PaletteDivider(cs: cs),
|
||||
// Place scratch link (sticky-note tab). Tap a page to drop an anchor.
|
||||
ToolButton(
|
||||
|
||||
Reference in New Issue
Block a user