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
|
// "highlight selection" action turns the current selection into stored
|
||||||
// normalized highlight rects.
|
// normalized highlight rects.
|
||||||
//
|
//
|
||||||
// Persistence (strokes) reuses the existing EditorRepository / SaveScheduler
|
// Persistence (Phase 2): strokes, highlights and scratch-links all write to a
|
||||||
// wiring verbatim. Highlights are in-memory only for now — see
|
// per-file SIDECAR (`<pdfPath>.badnote.json`) via SidecarRepository — debounced,
|
||||||
// TODO(persist-highlights).
|
// 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'
|
import 'package:flutter/foundation.dart'
|
||||||
show ValueListenable, visibleForTesting;
|
show ValueListenable, visibleForTesting;
|
||||||
@@ -30,7 +32,7 @@ import 'package:uuid/uuid.dart';
|
|||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../models/scratch_link.dart';
|
import '../../models/scratch_link.dart';
|
||||||
import '../../screens/split_view_screen.dart';
|
import '../../screens/split_view_screen.dart';
|
||||||
import '../../services/database_service.dart';
|
import '../../storage/badnote_sidecar.dart';
|
||||||
import '../engine/brush.dart';
|
import '../engine/brush.dart';
|
||||||
import '../engine/shape_geometry.dart';
|
import '../engine/shape_geometry.dart';
|
||||||
import '../engine/stroke_eraser.dart';
|
import '../engine/stroke_eraser.dart';
|
||||||
@@ -44,8 +46,7 @@ import '../input/pen_input_service.dart';
|
|||||||
import '../input/pressure_curve.dart'
|
import '../input/pressure_curve.dart'
|
||||||
show PressureCurve, kNaturalPressureFloor;
|
show PressureCurve, kNaturalPressureFloor;
|
||||||
import '../pdf/pen_capture_region.dart';
|
import '../pdf/pen_capture_region.dart';
|
||||||
import '../persistence/editor_repository.dart';
|
import '../persistence/sidecar_repository.dart';
|
||||||
import '../persistence/save_scheduler.dart';
|
|
||||||
import '../ui/pen_settings_page.dart';
|
import '../ui/pen_settings_page.dart';
|
||||||
import '../ui/thumbnail_grid.dart';
|
import '../ui/thumbnail_grid.dart';
|
||||||
import 'editor_tool.dart';
|
import 'editor_tool.dart';
|
||||||
@@ -55,23 +56,10 @@ import 'pen_palette_widgets.dart';
|
|||||||
import 'pen_stroke.dart';
|
import 'pen_stroke.dart';
|
||||||
import 'pinch_scale_solver.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
|
/// 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.
|
/// (not scaled with zoom) so the tap target stays comfortably tappable.
|
||||||
const double _kMarkerSize = 36.0;
|
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 {
|
class PenEditorScreen extends StatefulWidget {
|
||||||
const PenEditorScreen({
|
const PenEditorScreen({
|
||||||
super.key,
|
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
|
/// 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
|
/// 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 = {};
|
final Map<int, List<Rect>> _highlightsByPage = {};
|
||||||
|
|
||||||
/// Per-page undo/redo history. Snapshot-before-change discipline: the
|
/// Per-page undo/redo history. Snapshot-before-change discipline: the
|
||||||
@@ -125,10 +114,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
|
|
||||||
// ── Persistence ────────────────────────────────────────────────────────────
|
// ── Persistence ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Stable document-id derived from the PDF file path.
|
/// Per-file sidecar persistence. Identity is the source file PATH (the
|
||||||
late final String _documentId;
|
/// sidecar IS the identity — no more djb2 path-hash document id). Null until
|
||||||
|
/// [_initPersistence] resolves.
|
||||||
SaveScheduler? _saveScheduler;
|
SidecarRepository? _repo;
|
||||||
|
|
||||||
/// Repaint signal for the page overlays. Bumped on every pen move/commit/erase
|
/// 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
|
/// 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.
|
/// so the tap is handled by the per-page GestureDetector overlay.
|
||||||
bool _placeLinkMode = false;
|
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
|
/// All scratch-link anchors for this document, loaded on open and updated on
|
||||||
/// add/delete. Rendered as tappable markers in [pageOverlaysBuilder].
|
/// add/delete. Rendered as tappable markers in [pageOverlaysBuilder].
|
||||||
final List<ScratchLink> _scratchLinks = [];
|
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 —
|
/// 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
|
/// 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).
|
/// 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.
|
/// True when the eraser tool is active.
|
||||||
bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode;
|
bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode;
|
||||||
@@ -294,7 +289,6 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_documentId = _documentIdFromPath(widget.pdfPath);
|
|
||||||
// Begin listening to the native Windows pen plugin (barrel/eraser/tilt).
|
// Begin listening to the native Windows pen plugin (barrel/eraser/tilt).
|
||||||
// No-op on platforms without the plugin (W3).
|
// No-op on platforms without the plugin (W3).
|
||||||
PenInputService.instance.start();
|
PenInputService.instance.start();
|
||||||
@@ -320,42 +314,25 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initPersistence() async {
|
Future<void> _initPersistence() async {
|
||||||
final service = await DatabaseService.getInstance();
|
final repo = await SidecarRepository.open(widget.pdfPath, docType: 'pdf');
|
||||||
if (!mounted) return;
|
|
||||||
final repo = await EditorRepository.fromService(service);
|
|
||||||
final scheduler = SaveScheduler(repo);
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
scheduler.dispose();
|
repo.dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_saveScheduler = scheduler;
|
_repo = repo;
|
||||||
await _loadPersistedStrokes(repo);
|
_loadFromSidecar(repo);
|
||||||
await _loadScratchLinks(service);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load this document's scratch-link anchors into [_scratchLinks].
|
/// Hydrate the in-memory editor state from the sidecar [repo] loaded on open:
|
||||||
Future<void> _loadScratchLinks(DatabaseService service) async {
|
/// per-page strokes, per-page highlights, and scratch-link anchors.
|
||||||
final links = await service.loadScratchLinks(_documentId);
|
void _loadFromSidecar(SidecarRepository repo) {
|
||||||
if (!mounted) return;
|
final loadedStrokes = <int, List<PenStroke>>{};
|
||||||
setState(() {
|
for (final entry in repo.loadedStrokes.entries) {
|
||||||
_scratchLinks
|
loadedStrokes[entry.key] = entry.value
|
||||||
..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
|
|
||||||
.map((es) => PenStroke(
|
.map((es) => PenStroke(
|
||||||
points: es.points
|
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(),
|
.toList(),
|
||||||
color: es.color,
|
color: es.color,
|
||||||
width: es.width,
|
width: es.width,
|
||||||
@@ -372,32 +349,34 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
if (loaded.isNotEmpty) {
|
|
||||||
setState(() {
|
final loadedHighlights = <int, List<Rect>>{};
|
||||||
for (final entry in loaded.entries) {
|
for (final entry in repo.loadedHighlights.entries) {
|
||||||
_strokesByPage[entry.key] = entry.value;
|
loadedHighlights[entry.key] =
|
||||||
}
|
entry.value.map((h) => h.toRect()).toList();
|
||||||
});
|
|
||||||
_bumpOverlay();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the page index from a host_id of the form
|
setState(() {
|
||||||
/// `"doc:<documentId>:page:<pageIndex>"`.
|
for (final entry in loadedStrokes.entries) {
|
||||||
int? _pageIndexFromHostId(String hostId) {
|
_strokesByPage[entry.key] = entry.value;
|
||||||
const marker = ':page:';
|
}
|
||||||
final idx = hostId.lastIndexOf(marker);
|
for (final entry in loadedHighlights.entries) {
|
||||||
if (idx == -1) return null;
|
_highlightsByPage[entry.key] = entry.value;
|
||||||
return int.tryParse(hostId.substring(idx + marker.length));
|
}
|
||||||
|
_scratchLinks
|
||||||
|
..clear()
|
||||||
|
..addAll(repo.loadedScratchLinks.map((s) => s.link));
|
||||||
|
});
|
||||||
|
_bumpOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// Flush any pending scheduled saves before tearing down.
|
// Flush any pending sidecar write before tearing down.
|
||||||
final scheduler = _saveScheduler;
|
final repo = _repo;
|
||||||
if (scheduler != null) {
|
if (repo != null) {
|
||||||
scheduler.flush(); // fire-and-forget; DB write continues in isolate
|
repo.flush(); // fire-and-forget; atomic write completes off the UI tree
|
||||||
scheduler.dispose();
|
repo.dispose();
|
||||||
}
|
}
|
||||||
_overlayRepaint.dispose();
|
_overlayRepaint.dispose();
|
||||||
_liveStrokeVN.dispose();
|
_liveStrokeVN.dispose();
|
||||||
@@ -446,18 +425,14 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
_bumpOverlay();
|
_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) {
|
void _schedulePageSave(int pageIndex, List<PenStroke> strokes) {
|
||||||
final scheduler = _saveScheduler;
|
final repo = _repo;
|
||||||
if (scheduler == null) return;
|
if (repo == null) return;
|
||||||
final editorStrokes = strokes
|
final editorStrokes = strokes
|
||||||
.map((s) => simplifyStroke(EditorStroke.fromPenStroke(s)))
|
.map((s) => simplifyStroke(EditorStroke.fromPenStroke(s)))
|
||||||
.toList();
|
.toList();
|
||||||
scheduler.schedule(
|
repo.scheduleStrokeSave(pageIndex, editorStrokes);
|
||||||
'page',
|
|
||||||
EditorRepository.pageHostId(_documentId, pageIndex),
|
|
||||||
editorStrokes,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _performUndo() {
|
void _performUndo() {
|
||||||
@@ -917,12 +892,52 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
}
|
}
|
||||||
_hasSelection = false;
|
_hasSelection = false;
|
||||||
});
|
});
|
||||||
// TODO(persist-highlights): highlights are in-memory only; wire them into
|
// Persist each touched page's highlights to the sidecar (Phase 2 — closes
|
||||||
// EditorRepository (a new host_kind) for cross-session persistence.
|
// the old in-memory-only limitation; highlights now survive reopen).
|
||||||
|
for (final pageIndex in added.keys) {
|
||||||
|
_scheduleHighlightSave(pageIndex);
|
||||||
|
}
|
||||||
await delegate.clearTextSelection();
|
await delegate.clearTextSelection();
|
||||||
_bumpOverlay();
|
_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 ─────────────────────────────────────────────────────
|
// ── Navigation / tools ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
void _goToPage(int index) {
|
void _goToPage(int index) {
|
||||||
@@ -936,6 +951,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
_tool = tool;
|
_tool = tool;
|
||||||
_selectTextMode = false;
|
_selectTextMode = false;
|
||||||
_placeLinkMode = false;
|
_placeLinkMode = false;
|
||||||
|
_removeHighlightMode = false;
|
||||||
if (tool != EditorToolKind.select) _selected = null;
|
if (tool != EditorToolKind.select) _selected = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -944,6 +960,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_selectTextMode = true;
|
_selectTextMode = true;
|
||||||
_placeLinkMode = false;
|
_placeLinkMode = false;
|
||||||
|
_removeHighlightMode = false;
|
||||||
_selected = null;
|
_selected = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -953,7 +970,23 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
void _togglePlaceLinkMode() {
|
void _togglePlaceLinkMode() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_placeLinkMode = !_placeLinkMode;
|
_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 {
|
Future<void> _placeScratchLink(int pageIndex, Offset normalized) async {
|
||||||
final link = ScratchLink(
|
final link = ScratchLink(
|
||||||
id: _uuid.v4(),
|
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,
|
pageIndex: pageIndex,
|
||||||
nx: normalized.dx.clamp(0.0, 1.0),
|
nx: normalized.dx.clamp(0.0, 1.0),
|
||||||
ny: normalized.dy.clamp(0.0, 1.0),
|
ny: normalized.dy.clamp(0.0, 1.0),
|
||||||
);
|
);
|
||||||
final service = await DatabaseService.getInstance();
|
_repo?.scheduleScratchLinkUpsert(link);
|
||||||
await service.saveScratchLink(link);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _scratchLinks.add(link));
|
setState(() => _scratchLinks.add(link));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the anchor's split view (left = this PDF at the anchor page, right =
|
/// Open the anchor's split view (left = this PDF at the anchor page, right =
|
||||||
/// the anchor's private infinite scratchpad).
|
/// the anchor's private infinite scratchpad, stored in the sidecar).
|
||||||
void _openScratchLink(ScratchLink link) {
|
///
|
||||||
Navigator.of(context).push(
|
/// 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(
|
MaterialPageRoute(
|
||||||
builder: (_) => SplitViewScreen(
|
builder: (_) => SplitViewScreen(
|
||||||
filePath: widget.pdfPath,
|
filePath: widget.pdfPath,
|
||||||
documentId: _documentId,
|
|
||||||
scratchLinkId: link.id,
|
scratchLinkId: link.id,
|
||||||
initialPage: link.pageIndex,
|
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).
|
/// Confirm + delete an anchor (and its private scratchpad).
|
||||||
@@ -1011,8 +1062,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (confirmed != true) return;
|
if (confirmed != true) return;
|
||||||
final service = await DatabaseService.getInstance();
|
_repo?.scheduleScratchLinkDelete(link.id);
|
||||||
await service.deleteScratchLink(link.id);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _scratchLinks.removeWhere((s) => s.id == link.id));
|
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 →
|
// Anchor markers (sticky-note tabs): tap → split view, long-press →
|
||||||
// delete. Sized in screen px so the tap target stays usable at any
|
// delete. Sized in screen px so the tap target stays usable at any
|
||||||
// zoom; positioned at (nx*pageW, ny*pageH).
|
// zoom; positioned at (nx*pageW, ny*pageH).
|
||||||
@@ -1297,6 +1363,14 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
tooltip: l.actionHighlightSelection,
|
tooltip: l.actionHighlightSelection,
|
||||||
onPressed: _hasSelection ? _highlightSelection : null,
|
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),
|
PaletteDivider(cs: cs),
|
||||||
// Place scratch link (sticky-note tab). Tap a page to drop an anchor.
|
// Place scratch link (sticky-note tab). Tap a page to drop an anchor.
|
||||||
ToolButton(
|
ToolButton(
|
||||||
|
|||||||
216
lib/editor/persistence/sidecar_repository.dart
Normal file
216
lib/editor/persistence/sidecar_repository.dart
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
// lib/editor/persistence/sidecar_repository.dart
|
||||||
|
//
|
||||||
|
// Phase 2 of the file-based storage plan (docs/plans/2026-06-24-file-based-
|
||||||
|
// storage.md §F): the PDF editor's persistence sink. Replaces the SQLite-backed
|
||||||
|
// EditorRepository/SaveScheduler/DatabaseService trio for the pen editor with a
|
||||||
|
// single per-file SIDECAR (`<sourceFile>.badnote.json`) living ALONGSIDE the
|
||||||
|
// source file, so annotations travel with the file ("跟着文件走").
|
||||||
|
//
|
||||||
|
// Design:
|
||||||
|
// * The repository owns the canonical in-memory [BadnoteSidecar]. Callers
|
||||||
|
// mutate it through the schedule* methods, which (1) update the in-memory
|
||||||
|
// model SYNCHRONOUSLY (so the snapshot can't be corrupted by a later edit
|
||||||
|
// mid-write — the SaveScheduler discipline, §F.2) and (2) arm a single
|
||||||
|
// debounce timer that writes the WHOLE sidecar atomically (§F.1, via
|
||||||
|
// SidecarStore.writeAtomic — temp + rename + .bak).
|
||||||
|
// * The unit of debounce is the whole document sidecar (sidecars are small —
|
||||||
|
// sparse normalized strokes), one atomic write per debounce window.
|
||||||
|
// * Identity is the SOURCE FILE PATH, not the old djb2 path-hash document id.
|
||||||
|
// The sidecar IS the identity.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../../models/scratch_link.dart';
|
||||||
|
import '../../storage/badnote_sidecar.dart';
|
||||||
|
import '../../storage/sidecar_store.dart';
|
||||||
|
import '../engine/stroke_model.dart';
|
||||||
|
|
||||||
|
/// Suffix appended to a source-file path to form its sidecar path.
|
||||||
|
const String kSidecarSuffix = '.badnote.json';
|
||||||
|
|
||||||
|
/// Per-file persistence for the pen editor. Loads the sidecar for a source file
|
||||||
|
/// path, holds it in memory, and debounces atomic writes back to disk.
|
||||||
|
class SidecarRepository {
|
||||||
|
SidecarRepository._({
|
||||||
|
required this.sourceFilePath,
|
||||||
|
required BadnoteSidecar sidecar,
|
||||||
|
Duration debounce = const Duration(milliseconds: 800),
|
||||||
|
}) : _sidecar = sidecar,
|
||||||
|
_debounce = debounce;
|
||||||
|
|
||||||
|
/// Absolute path to the annotated source file (e.g. the vault PDF copy).
|
||||||
|
final String sourceFilePath;
|
||||||
|
|
||||||
|
/// The sidecar file: `<sourceFilePath>.badnote.json`.
|
||||||
|
File get sidecarFile => File('$sourceFilePath$kSidecarSuffix');
|
||||||
|
|
||||||
|
final Duration _debounce;
|
||||||
|
|
||||||
|
BadnoteSidecar _sidecar;
|
||||||
|
Timer? _timer;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// Open (or create) the repository for [sourceFilePath]. Reads the existing
|
||||||
|
/// sidecar if present (falling back to its `.bak`), else starts empty.
|
||||||
|
static Future<SidecarRepository> open(
|
||||||
|
String sourceFilePath, {
|
||||||
|
String? docType,
|
||||||
|
Duration debounce = const Duration(milliseconds: 800),
|
||||||
|
}) async {
|
||||||
|
final file = File('$sourceFilePath$kSidecarSuffix');
|
||||||
|
final existing = await SidecarStore.read(file);
|
||||||
|
final sidecar = existing ??
|
||||||
|
BadnoteSidecar(
|
||||||
|
sourceFile: _basename(sourceFilePath),
|
||||||
|
docType: docType,
|
||||||
|
createdAt: DateTime.now().toUtc(),
|
||||||
|
);
|
||||||
|
return SidecarRepository._(
|
||||||
|
sourceFilePath: sourceFilePath,
|
||||||
|
sidecar: sidecar,
|
||||||
|
debounce: debounce,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loaded snapshot accessors (read at open) ───────────────────────────────
|
||||||
|
|
||||||
|
/// Page index → committed strokes loaded from the sidecar.
|
||||||
|
Map<int, List<EditorStroke>> get loadedStrokes => _sidecar.strokes;
|
||||||
|
|
||||||
|
/// Page index → highlight rects loaded from the sidecar.
|
||||||
|
Map<int, List<SidecarHighlight>> get loadedHighlights => _sidecar.highlights;
|
||||||
|
|
||||||
|
/// Scratch-link anchors loaded from the sidecar.
|
||||||
|
List<SidecarScratchLink> get loadedScratchLinks => _sidecar.scratchLinks;
|
||||||
|
|
||||||
|
/// The current in-memory sidecar (for tests / inspection).
|
||||||
|
BadnoteSidecar get sidecar => _sidecar;
|
||||||
|
|
||||||
|
// ── Mutations (synchronous in-memory update + debounced atomic write) ──────
|
||||||
|
|
||||||
|
/// Replace the committed strokes for [pageIndex] and schedule a save.
|
||||||
|
void scheduleStrokeSave(int pageIndex, List<EditorStroke> strokes) {
|
||||||
|
final next = Map<int, List<EditorStroke>>.from(_sidecar.strokes);
|
||||||
|
if (strokes.isEmpty) {
|
||||||
|
next.remove(pageIndex);
|
||||||
|
} else {
|
||||||
|
next[pageIndex] = List<EditorStroke>.of(strokes);
|
||||||
|
}
|
||||||
|
_replace(strokes: next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the highlight rects for [pageIndex] and schedule a save.
|
||||||
|
void scheduleHighlightSave(int pageIndex, List<SidecarHighlight> highlights) {
|
||||||
|
final next = Map<int, List<SidecarHighlight>>.from(_sidecar.highlights);
|
||||||
|
if (highlights.isEmpty) {
|
||||||
|
next.remove(pageIndex);
|
||||||
|
} else {
|
||||||
|
next[pageIndex] = List<SidecarHighlight>.of(highlights);
|
||||||
|
}
|
||||||
|
_replace(highlights: next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add (or update) a scratch-link anchor, preserving any existing scratchpad,
|
||||||
|
/// and schedule a save.
|
||||||
|
void scheduleScratchLinkUpsert(ScratchLink link) {
|
||||||
|
final next = List<SidecarScratchLink>.of(_sidecar.scratchLinks);
|
||||||
|
final idx = next.indexWhere((s) => s.link.id == link.id);
|
||||||
|
if (idx == -1) {
|
||||||
|
next.add(SidecarScratchLink(link: link));
|
||||||
|
} else {
|
||||||
|
next[idx] = SidecarScratchLink(
|
||||||
|
link: link,
|
||||||
|
scratchpad: next[idx].scratchpad,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_replace(scratchLinks: next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the scratch-link anchor (and its embedded scratchpad) by [linkId].
|
||||||
|
void scheduleScratchLinkDelete(String linkId) {
|
||||||
|
final next = _sidecar.scratchLinks
|
||||||
|
.where((s) => s.link.id != linkId)
|
||||||
|
.toList(growable: false);
|
||||||
|
_replace(scratchLinks: List<SidecarScratchLink>.of(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the embedded scratchpad of the anchor [linkId] and schedule a save.
|
||||||
|
/// No-op if the anchor isn't present.
|
||||||
|
void scheduleScratchpadSave(String linkId, SidecarScratchpad scratchpad) {
|
||||||
|
final next = List<SidecarScratchLink>.of(_sidecar.scratchLinks);
|
||||||
|
final idx = next.indexWhere((s) => s.link.id == linkId);
|
||||||
|
if (idx == -1) return;
|
||||||
|
next[idx] = SidecarScratchLink(link: next[idx].link, scratchpad: scratchpad);
|
||||||
|
_replace(scratchLinks: next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The embedded scratchpad for [linkId], or null if the anchor is unknown.
|
||||||
|
SidecarScratchpad? scratchpadFor(String linkId) {
|
||||||
|
for (final s in _sidecar.scratchLinks) {
|
||||||
|
if (s.link.id == linkId) return s.scratchpad;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flush / dispose ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Write any pending change immediately and wait for it to land.
|
||||||
|
Future<void> flush() async {
|
||||||
|
if (_timer == null) return;
|
||||||
|
_timer!.cancel();
|
||||||
|
_timer = null;
|
||||||
|
await _write();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel pending timers. Call [flush] first to persist pending writes.
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Build a new sidecar (touching `updatedAt`) from the current one with the
|
||||||
|
/// given fields replaced, then arm the debounce timer. Snapshot is captured
|
||||||
|
/// synchronously here so a later edit can't corrupt an in-flight write.
|
||||||
|
void _replace({
|
||||||
|
Map<int, List<EditorStroke>>? strokes,
|
||||||
|
Map<int, List<SidecarHighlight>>? highlights,
|
||||||
|
List<SidecarScratchLink>? scratchLinks,
|
||||||
|
}) {
|
||||||
|
if (_disposed) return;
|
||||||
|
_sidecar = BadnoteSidecar(
|
||||||
|
version: _sidecar.version,
|
||||||
|
sourceFile: _sidecar.sourceFile,
|
||||||
|
docType: _sidecar.docType,
|
||||||
|
pageCount: _sidecar.pageCount,
|
||||||
|
rotation: _sidecar.rotation,
|
||||||
|
createdAt: _sidecar.createdAt,
|
||||||
|
updatedAt: DateTime.now().toUtc(),
|
||||||
|
strokes: strokes ?? _sidecar.strokes,
|
||||||
|
highlights: highlights ?? _sidecar.highlights,
|
||||||
|
bookmarks: _sidecar.bookmarks,
|
||||||
|
scratchLinks: scratchLinks ?? _sidecar.scratchLinks,
|
||||||
|
);
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = Timer(_debounce, () {
|
||||||
|
_timer = null;
|
||||||
|
// Fire-and-forget; the next schedule simply re-arms the timer and the
|
||||||
|
// atomic write guarantees no torn file.
|
||||||
|
_write();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _write() async {
|
||||||
|
final snapshot = _sidecar;
|
||||||
|
await SidecarStore.writeAtomic(sidecarFile, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _basename(String path) {
|
||||||
|
final norm = path.replaceAll('\\', '/');
|
||||||
|
final i = norm.lastIndexOf('/');
|
||||||
|
return i == -1 ? norm : norm.substring(i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@
|
|||||||
"nextPage": "Next page",
|
"nextPage": "Next page",
|
||||||
"toolSelectText": "Select text",
|
"toolSelectText": "Select text",
|
||||||
"actionHighlightSelection": "Highlight selection",
|
"actionHighlightSelection": "Highlight selection",
|
||||||
|
"toolRemoveHighlight": "Remove highlight (tap a highlight)",
|
||||||
"toolPlaceScratchLink": "Place scratch link",
|
"toolPlaceScratchLink": "Place scratch link",
|
||||||
"scratchLinkDeleteTitle": "Delete scratch link?",
|
"scratchLinkDeleteTitle": "Delete scratch link?",
|
||||||
"scratchLinkDeleteBody": "This removes the anchor and its private scratchpad.",
|
"scratchLinkDeleteBody": "This removes the anchor and its private scratchpad.",
|
||||||
|
|||||||
@@ -470,6 +470,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Highlight selection'**
|
/// **'Highlight selection'**
|
||||||
String get actionHighlightSelection;
|
String get actionHighlightSelection;
|
||||||
|
|
||||||
|
/// No description provided for @toolRemoveHighlight.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remove highlight (tap a highlight)'**
|
||||||
|
String get toolRemoveHighlight;
|
||||||
|
|
||||||
/// No description provided for @toolPlaceScratchLink.
|
/// No description provided for @toolPlaceScratchLink.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -200,6 +200,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get actionHighlightSelection => 'Highlight selection';
|
String get actionHighlightSelection => 'Highlight selection';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolRemoveHighlight => 'Remove highlight (tap a highlight)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get toolPlaceScratchLink => 'Place scratch link';
|
String get toolPlaceScratchLink => 'Place scratch link';
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get actionHighlightSelection => '高亮所选';
|
String get actionHighlightSelection => '高亮所选';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolRemoveHighlight => '移除高亮(点按高亮处)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get toolPlaceScratchLink => '放置便签链接';
|
String get toolPlaceScratchLink => '放置便签链接';
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
"nextPage": "下一页",
|
"nextPage": "下一页",
|
||||||
"toolSelectText": "选择文字",
|
"toolSelectText": "选择文字",
|
||||||
"actionHighlightSelection": "高亮所选",
|
"actionHighlightSelection": "高亮所选",
|
||||||
|
"toolRemoveHighlight": "移除高亮(点按高亮处)",
|
||||||
"toolPlaceScratchLink": "放置便签链接",
|
"toolPlaceScratchLink": "放置便签链接",
|
||||||
"scratchLinkDeleteTitle": "删除便签链接?",
|
"scratchLinkDeleteTitle": "删除便签链接?",
|
||||||
"scratchLinkDeleteBody": "这会移除锚点及其专属草稿纸。",
|
"scratchLinkDeleteBody": "这会移除锚点及其专属草稿纸。",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
// ToolButton / color dots), replacing the old AnnotationToolbar.
|
// ToolButton / color dots), replacing the old AnnotationToolbar.
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
@@ -25,21 +24,22 @@ import '../editor/canvas/pen_stroke.dart';
|
|||||||
import '../editor/engine/brush.dart';
|
import '../editor/engine/brush.dart';
|
||||||
import '../editor/input/pen_config.dart' show kDefaultEraserRadius;
|
import '../editor/input/pen_config.dart' show kDefaultEraserRadius;
|
||||||
import '../editor/notebook/ink_stroke_adapter.dart';
|
import '../editor/notebook/ink_stroke_adapter.dart';
|
||||||
|
import '../editor/persistence/sidecar_repository.dart';
|
||||||
import '../models/ink_stroke.dart';
|
import '../models/ink_stroke.dart';
|
||||||
import '../services/database_service.dart';
|
|
||||||
import '../services/undo_manager.dart';
|
import '../services/undo_manager.dart';
|
||||||
|
import '../storage/badnote_sidecar.dart';
|
||||||
|
|
||||||
/// Split-view derivation surface for a single scratch-link anchor: left pane =
|
/// Split-view derivation surface for a single scratch-link anchor: left pane =
|
||||||
/// the reference PDF (at [initialPage]), right pane = the anchor's private
|
/// the reference PDF (at [initialPage]), right pane = the anchor's private
|
||||||
/// infinite scratchpad. Scratchpad strokes persist per ANCHOR via
|
/// infinite scratchpad. Scratchpad strokes persist inside the source file's
|
||||||
/// [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad], keyed by
|
/// SIDECAR (`<filePath>.badnote.json`), embedded in the anchor's
|
||||||
/// [scratchLinkId].
|
/// `scratchLinks[id].scratchpad` (keyed by [scratchLinkId]). Strokes keep the
|
||||||
|
/// absolute world-pixel [InkStroke] format unchanged.
|
||||||
class SplitViewScreen extends StatefulWidget {
|
class SplitViewScreen extends StatefulWidget {
|
||||||
final String filePath;
|
final String filePath;
|
||||||
final String documentId;
|
|
||||||
|
|
||||||
/// The owning anchor id. Doubles as the scratchpad storage key so this is the
|
/// The owning anchor id. Selects which `scratchLinks[].scratchpad` in the
|
||||||
/// anchor's private scratch space.
|
/// sidecar this is the private scratch space for.
|
||||||
final String scratchLinkId;
|
final String scratchLinkId;
|
||||||
|
|
||||||
/// 0-based page the anchor sits on; the left PDF opens here.
|
/// 0-based page the anchor sits on; the left PDF opens here.
|
||||||
@@ -48,7 +48,6 @@ class SplitViewScreen extends StatefulWidget {
|
|||||||
const SplitViewScreen({
|
const SplitViewScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.filePath,
|
required this.filePath,
|
||||||
required this.documentId,
|
|
||||||
required this.scratchLinkId,
|
required this.scratchLinkId,
|
||||||
this.initialPage = 0,
|
this.initialPage = 0,
|
||||||
});
|
});
|
||||||
@@ -102,6 +101,11 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
Timer? _saveTimer;
|
Timer? _saveTimer;
|
||||||
bool _dirty = false;
|
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 _edgeThreshold = 200.0;
|
||||||
static const double _expandAmount = 1000.0;
|
static const double _expandAmount = 1000.0;
|
||||||
|
|
||||||
@@ -116,18 +120,34 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_saveTimer?.cancel();
|
_saveTimer?.cancel();
|
||||||
_saveImmediate();
|
_saveImmediate();
|
||||||
|
final repo = _repo;
|
||||||
|
if (repo != null) {
|
||||||
|
repo.flush(); // fire-and-forget; atomic write finishes off the tree
|
||||||
|
repo.dispose();
|
||||||
|
}
|
||||||
// PdfViewerController (pdfrx) has no dispose(); it detaches with the viewer.
|
// PdfViewerController (pdfrx) has no dispose(); it detaches with the viewer.
|
||||||
_scratchTransform.dispose();
|
_scratchTransform.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Persistence (keyed by the ANCHOR id, not the documentId) --
|
// -- Persistence (sidecar's scratchLinks[id].scratchpad, keyed by anchor id) --
|
||||||
|
|
||||||
Future<void> _loadScratchpad() async {
|
Future<void> _loadScratchpad() async {
|
||||||
final db = await DatabaseService.getInstance();
|
final repo = await SidecarRepository.open(widget.filePath, docType: 'pdf');
|
||||||
final strokes = await db.loadScratchpad(widget.scratchLinkId);
|
if (!mounted) {
|
||||||
if (!mounted) return;
|
repo.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_repo = repo;
|
||||||
|
final pad = repo.scratchpadFor(widget.scratchLinkId);
|
||||||
setState(() {
|
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
|
// Keep only freehand strokes so the canvas list stays 1:1 with the undo
|
||||||
// manager (shapes/text have no pen-canvas representation).
|
// manager (shapes/text have no pen-canvas representation).
|
||||||
final freehand = strokes.where((s) => isFreehandTool(s.tool)).toList();
|
final freehand = strokes.where((s) => isFreehandTool(s.tool)).toList();
|
||||||
@@ -147,9 +167,17 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
Future<void> _saveImmediate() async {
|
Future<void> _saveImmediate() async {
|
||||||
if (!_dirty) return;
|
if (!_dirty) return;
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
final db = await DatabaseService.getInstance();
|
final repo = _repo;
|
||||||
final json = jsonEncode(_strokes.map((s) => s.toJson()).toList());
|
if (repo == null) return;
|
||||||
await db.saveScratchpad(widget.scratchLinkId, json);
|
repo.scheduleScratchpadSave(
|
||||||
|
widget.scratchLinkId,
|
||||||
|
SidecarScratchpad(
|
||||||
|
canvasWidth: _canvasWidth,
|
||||||
|
canvasHeight: _canvasHeight,
|
||||||
|
strokes: List<InkStroke>.of(_strokes),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await repo.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Scratchpad stroke callbacks --
|
// -- Scratchpad stroke callbacks --
|
||||||
|
|||||||
203
test/sidecar_repository_test.dart
Normal file
203
test/sidecar_repository_test.dart
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
// test/sidecar_repository_test.dart
|
||||||
|
//
|
||||||
|
// Phase 2: SidecarRepository is the pen editor's persistence sink (replacing the
|
||||||
|
// SQLite EditorRepository / DatabaseService scratch storage). These tests drive
|
||||||
|
// the repository directly with a tiny debounce + flush() so writes are
|
||||||
|
// deterministic, then RE-OPEN the same file and assert everything restored:
|
||||||
|
// * per-page strokes
|
||||||
|
// * per-page highlights (the previously in-memory-only data)
|
||||||
|
// * scratch-link anchors + their embedded scratchpad ink (absolute world px)
|
||||||
|
// * removing a highlight persists (the "un-highlight" action)
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||||
|
import 'package:badnote/editor/persistence/sidecar_repository.dart';
|
||||||
|
import 'package:badnote/models/ink_point.dart';
|
||||||
|
import 'package:badnote/models/ink_stroke.dart';
|
||||||
|
import 'package:badnote/models/pen_tool.dart';
|
||||||
|
import 'package:badnote/models/scratch_link.dart';
|
||||||
|
import 'package:badnote/storage/badnote_sidecar.dart';
|
||||||
|
|
||||||
|
const _fast = Duration(milliseconds: 1);
|
||||||
|
|
||||||
|
EditorStroke _stroke(String id, {EditorTool tool = EditorTool.pen}) =>
|
||||||
|
EditorStroke(
|
||||||
|
id: id,
|
||||||
|
points: const [
|
||||||
|
EditorPoint(x: 0.1, y: 0.2, pressure: 0.5),
|
||||||
|
EditorPoint(x: 0.3, y: 0.4, pressure: 0.7),
|
||||||
|
],
|
||||||
|
tool: tool,
|
||||||
|
color: 0xFF112233,
|
||||||
|
width: 0.005,
|
||||||
|
);
|
||||||
|
|
||||||
|
InkStroke _ink(String id, double x) => InkStroke(
|
||||||
|
id: id,
|
||||||
|
points: [InkPoint(x: x, y: x + 1, timestamp: 0)],
|
||||||
|
tool: PenTool.pen,
|
||||||
|
createdAt: DateTime.utc(2026, 1, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Directory tmpDir;
|
||||||
|
late String src;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
tmpDir = await Directory.systemTemp.createTemp('sidecar_repo_test');
|
||||||
|
src = '${tmpDir.path}/Lecture.pdf';
|
||||||
|
// The source file doesn't have to exist for the sidecar to work, but create
|
||||||
|
// it so the layout matches reality (sidecar lives alongside the file).
|
||||||
|
await File(src).writeAsString('%PDF-1.7 fake');
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
if (await tmpDir.exists()) await tmpDir.delete(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidecar path is <sourceFile>.badnote.json alongside the file', () async {
|
||||||
|
final repo = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
expect(repo.sidecarFile.path, '$src.badnote.json');
|
||||||
|
repo.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strokes + highlight + scratch-link + scratchpad restore on reopen',
|
||||||
|
() async {
|
||||||
|
final repo = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
|
||||||
|
// Page 0 strokes.
|
||||||
|
repo.scheduleStrokeSave(0, [_stroke('a'), _stroke('b')]);
|
||||||
|
// Page 0 highlight (normalized rect).
|
||||||
|
repo.scheduleHighlightSave(
|
||||||
|
0,
|
||||||
|
const [SidecarHighlight(l: 0.1, t: 0.15, r: 0.8, b: 0.2, color: 0xFFFFFF00)],
|
||||||
|
);
|
||||||
|
// A scratch-link anchor on page 3, then its private scratchpad ink.
|
||||||
|
const link = ScratchLink(
|
||||||
|
id: 'anchor-1',
|
||||||
|
documentId: 'ignored-uses-path',
|
||||||
|
pageIndex: 3,
|
||||||
|
nx: 0.5,
|
||||||
|
ny: 0.5,
|
||||||
|
);
|
||||||
|
repo.scheduleScratchLinkUpsert(link);
|
||||||
|
repo.scheduleScratchpadSave(
|
||||||
|
'anchor-1',
|
||||||
|
SidecarScratchpad(
|
||||||
|
canvasWidth: 5000,
|
||||||
|
canvasHeight: 6000,
|
||||||
|
strokes: [_ink('w0', 100), _ink('w1', 200)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await repo.flush();
|
||||||
|
repo.dispose();
|
||||||
|
|
||||||
|
// Re-open the SAME file: everything must come back.
|
||||||
|
final reopened = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
|
||||||
|
expect(reopened.loadedStrokes[0]?.map((s) => s.id), ['a', 'b']);
|
||||||
|
expect(reopened.loadedStrokes[0]!.first.color, 0xFF112233);
|
||||||
|
|
||||||
|
final hl = reopened.loadedHighlights[0]!.single;
|
||||||
|
expect(hl.l, 0.1);
|
||||||
|
expect(hl.r, 0.8);
|
||||||
|
expect(hl.color, 0xFFFFFF00);
|
||||||
|
|
||||||
|
final sl = reopened.loadedScratchLinks.single;
|
||||||
|
expect(sl.link.id, 'anchor-1');
|
||||||
|
expect(sl.link.pageIndex, 3);
|
||||||
|
expect(sl.scratchpad.canvasWidth, 5000);
|
||||||
|
expect(sl.scratchpad.canvasHeight, 6000);
|
||||||
|
expect(sl.scratchpad.strokes.map((s) => s.id), ['w0', 'w1']);
|
||||||
|
expect(sl.scratchpad.strokes.first.points.single.x, 100);
|
||||||
|
|
||||||
|
// The scratchpad is addressable by anchor id.
|
||||||
|
expect(reopened.scratchpadFor('anchor-1')!.strokes.length, 2);
|
||||||
|
expect(reopened.scratchpadFor('missing'), isNull);
|
||||||
|
|
||||||
|
reopened.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing a highlight persists (un-highlight)', () async {
|
||||||
|
final repo = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
repo.scheduleHighlightSave(0, const [
|
||||||
|
SidecarHighlight(l: 0.1, t: 0.1, r: 0.4, b: 0.2),
|
||||||
|
SidecarHighlight(l: 0.5, t: 0.5, r: 0.9, b: 0.6),
|
||||||
|
]);
|
||||||
|
await repo.flush();
|
||||||
|
repo.dispose();
|
||||||
|
|
||||||
|
// Re-open, drop one highlight (mirrors _removeHighlightAt → save), reopen.
|
||||||
|
final mid = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
expect(mid.loadedHighlights[0]!.length, 2);
|
||||||
|
final remaining = mid.loadedHighlights[0]!
|
||||||
|
.where((h) => h.l != 0.1) // remove the first
|
||||||
|
.toList();
|
||||||
|
mid.scheduleHighlightSave(0, remaining);
|
||||||
|
await mid.flush();
|
||||||
|
mid.dispose();
|
||||||
|
|
||||||
|
final after = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
expect(after.loadedHighlights[0]!.length, 1);
|
||||||
|
expect(after.loadedHighlights[0]!.single.l, 0.5);
|
||||||
|
after.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing the last highlight on a page clears the page entry', () async {
|
||||||
|
final repo = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
repo.scheduleHighlightSave(
|
||||||
|
0, const [SidecarHighlight(l: 0.1, t: 0.1, r: 0.4, b: 0.2)]);
|
||||||
|
await repo.flush();
|
||||||
|
repo.scheduleHighlightSave(0, const []);
|
||||||
|
await repo.flush();
|
||||||
|
repo.dispose();
|
||||||
|
|
||||||
|
final after = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
expect(after.loadedHighlights.containsKey(0), isFalse);
|
||||||
|
after.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting a scratch link removes it and its scratchpad', () async {
|
||||||
|
final repo = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
repo.scheduleScratchLinkUpsert(const ScratchLink(
|
||||||
|
id: 'x',
|
||||||
|
documentId: 'd',
|
||||||
|
pageIndex: 0,
|
||||||
|
nx: 0.2,
|
||||||
|
ny: 0.2,
|
||||||
|
));
|
||||||
|
repo.scheduleScratchpadSave(
|
||||||
|
'x',
|
||||||
|
SidecarScratchpad(strokes: [_ink('s', 1)]),
|
||||||
|
);
|
||||||
|
await repo.flush();
|
||||||
|
repo.scheduleScratchLinkDelete('x');
|
||||||
|
await repo.flush();
|
||||||
|
repo.dispose();
|
||||||
|
|
||||||
|
final after = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
expect(after.loadedScratchLinks, isEmpty);
|
||||||
|
after.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upserting a scratch link preserves its existing scratchpad', () async {
|
||||||
|
final repo = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
const link =
|
||||||
|
ScratchLink(id: 'k', documentId: 'd', pageIndex: 1, nx: 0.1, ny: 0.1);
|
||||||
|
repo.scheduleScratchLinkUpsert(link);
|
||||||
|
repo.scheduleScratchpadSave('k', SidecarScratchpad(strokes: [_ink('s', 7)]));
|
||||||
|
// Re-upsert the same anchor (e.g. moved) — scratchpad must survive.
|
||||||
|
repo.scheduleScratchLinkUpsert(link.copyWith(nx: 0.9));
|
||||||
|
await repo.flush();
|
||||||
|
repo.dispose();
|
||||||
|
|
||||||
|
final after = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
final sl = after.loadedScratchLinks.single;
|
||||||
|
expect(sl.link.nx, 0.9);
|
||||||
|
expect(sl.scratchpad.strokes.single.id, 's');
|
||||||
|
after.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user