feat(storage): PDF editor persists to per-file sidecar
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:
2026-06-24 21:03:28 +08:00
parent 953c7b700f
commit 978111eeff
9 changed files with 647 additions and 112 deletions

View File

@@ -13,7 +13,6 @@
// ToolButton / color dots), replacing the old AnnotationToolbar.
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
@@ -25,21 +24,22 @@ import '../editor/canvas/pen_stroke.dart';
import '../editor/engine/brush.dart';
import '../editor/input/pen_config.dart' show kDefaultEraserRadius;
import '../editor/notebook/ink_stroke_adapter.dart';
import '../editor/persistence/sidecar_repository.dart';
import '../models/ink_stroke.dart';
import '../services/database_service.dart';
import '../services/undo_manager.dart';
import '../storage/badnote_sidecar.dart';
/// Split-view derivation surface for a single scratch-link anchor: left pane =
/// the reference PDF (at [initialPage]), right pane = the anchor's private
/// infinite scratchpad. Scratchpad strokes persist per ANCHOR via
/// [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad], keyed by
/// [scratchLinkId].
/// infinite scratchpad. Scratchpad strokes persist inside the source file's
/// SIDECAR (`<filePath>.badnote.json`), embedded in the anchor's
/// `scratchLinks[id].scratchpad` (keyed by [scratchLinkId]). Strokes keep the
/// absolute world-pixel [InkStroke] format unchanged.
class SplitViewScreen extends StatefulWidget {
final String filePath;
final String documentId;
/// The owning anchor id. Doubles as the scratchpad storage key so this is the
/// anchor's private scratch space.
/// The owning anchor id. Selects which `scratchLinks[].scratchpad` in the
/// sidecar this is the private scratch space for.
final String scratchLinkId;
/// 0-based page the anchor sits on; the left PDF opens here.
@@ -48,7 +48,6 @@ class SplitViewScreen extends StatefulWidget {
const SplitViewScreen({
super.key,
required this.filePath,
required this.documentId,
required this.scratchLinkId,
this.initialPage = 0,
});
@@ -102,6 +101,11 @@ class _SplitViewState extends State<SplitViewScreen> {
Timer? _saveTimer;
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 _expandAmount = 1000.0;
@@ -116,18 +120,34 @@ class _SplitViewState extends State<SplitViewScreen> {
void dispose() {
_saveTimer?.cancel();
_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.
_scratchTransform.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 {
final db = await DatabaseService.getInstance();
final strokes = await db.loadScratchpad(widget.scratchLinkId);
if (!mounted) return;
final repo = await SidecarRepository.open(widget.filePath, docType: 'pdf');
if (!mounted) {
repo.dispose();
return;
}
_repo = repo;
final pad = repo.scratchpadFor(widget.scratchLinkId);
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
// manager (shapes/text have no pen-canvas representation).
final freehand = strokes.where((s) => isFreehandTool(s.tool)).toList();
@@ -147,9 +167,17 @@ class _SplitViewState extends State<SplitViewScreen> {
Future<void> _saveImmediate() async {
if (!_dirty) return;
_dirty = false;
final db = await DatabaseService.getInstance();
final json = jsonEncode(_strokes.map((s) => s.toJson()).toList());
await db.saveScratchpad(widget.scratchLinkId, json);
final repo = _repo;
if (repo == null) return;
repo.scheduleScratchpadSave(
widget.scratchLinkId,
SidecarScratchpad(
canvasWidth: _canvasWidth,
canvasHeight: _canvasHeight,
strokes: List<InkStroke>.of(_strokes),
),
);
await repo.flush();
}
// -- Scratchpad stroke callbacks --