diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index a3238d1..13187e7 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -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 (`.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 { /// 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> _highlightsByPage = {}; /// Per-page undo/redo history. Snapshot-before-change discipline: the @@ -125,10 +114,10 @@ class _PenEditorScreenState extends State { // ── 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 { /// 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 _scratchLinks = []; @@ -279,7 +273,8 @@ class _PenEditorScreenState extends State { /// 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 { @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 { } Future _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 _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 _loadPersistedStrokes(EditorRepository repo) async { - final hosted = await repo.loadDocument(_documentId); - if (!mounted) return; - final loaded = >{}; - 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 = >{}; + 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 { )) .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::page:"`. - 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 = >{}; + 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 { _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 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 { } _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 []; + 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.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 { _tool = tool; _selectTextMode = false; _placeLinkMode = false; + _removeHighlightMode = false; if (tool != EditorToolKind.select) _selected = null; }); } @@ -944,6 +960,7 @@ class _PenEditorScreenState extends State { setState(() { _selectTextMode = true; _placeLinkMode = false; + _removeHighlightMode = false; _selected = null; }); } @@ -953,7 +970,23 @@ class _PenEditorScreenState extends State { 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 { Future _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 _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 { ), ); 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 { }, ), ), + // 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 { 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( diff --git a/lib/editor/persistence/sidecar_repository.dart b/lib/editor/persistence/sidecar_repository.dart new file mode 100644 index 0000000..66b2632 --- /dev/null +++ b/lib/editor/persistence/sidecar_repository.dart @@ -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 (`.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: `.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 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> get loadedStrokes => _sidecar.strokes; + + /// Page index → highlight rects loaded from the sidecar. + Map> get loadedHighlights => _sidecar.highlights; + + /// Scratch-link anchors loaded from the sidecar. + List 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 strokes) { + final next = Map>.from(_sidecar.strokes); + if (strokes.isEmpty) { + next.remove(pageIndex); + } else { + next[pageIndex] = List.of(strokes); + } + _replace(strokes: next); + } + + /// Replace the highlight rects for [pageIndex] and schedule a save. + void scheduleHighlightSave(int pageIndex, List highlights) { + final next = Map>.from(_sidecar.highlights); + if (highlights.isEmpty) { + next.remove(pageIndex); + } else { + next[pageIndex] = List.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.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.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.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 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>? strokes, + Map>? highlights, + List? 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 _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); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e01f140..0ce2959 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -71,6 +71,7 @@ "nextPage": "Next page", "toolSelectText": "Select text", "actionHighlightSelection": "Highlight selection", + "toolRemoveHighlight": "Remove highlight (tap a highlight)", "toolPlaceScratchLink": "Place scratch link", "scratchLinkDeleteTitle": "Delete scratch link?", "scratchLinkDeleteBody": "This removes the anchor and its private scratchpad.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 9277fc8..56e5739 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -470,6 +470,12 @@ abstract class AppLocalizations { /// **'Highlight selection'** 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. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9fb5e58..32e89dd 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -200,6 +200,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get actionHighlightSelection => 'Highlight selection'; + @override + String get toolRemoveHighlight => 'Remove highlight (tap a highlight)'; + @override String get toolPlaceScratchLink => 'Place scratch link'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 1398787..af50612 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -200,6 +200,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get actionHighlightSelection => '高亮所选'; + @override + String get toolRemoveHighlight => '移除高亮(点按高亮处)'; + @override String get toolPlaceScratchLink => '放置便签链接'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 5b19546..f303d16 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -62,6 +62,7 @@ "nextPage": "下一页", "toolSelectText": "选择文字", "actionHighlightSelection": "高亮所选", + "toolRemoveHighlight": "移除高亮(点按高亮处)", "toolPlaceScratchLink": "放置便签链接", "scratchLinkDeleteTitle": "删除便签链接?", "scratchLinkDeleteBody": "这会移除锚点及其专属草稿纸。", diff --git a/lib/screens/split_view_screen.dart b/lib/screens/split_view_screen.dart index 986e159..76152c1 100644 --- a/lib/screens/split_view_screen.dart +++ b/lib/screens/split_view_screen.dart @@ -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 (`.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 { 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 { 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 _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 []; // 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 { Future _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.of(_strokes), + ), + ); + await repo.flush(); } // -- Scratchpad stroke callbacks -- diff --git a/test/sidecar_repository_test.dart b/test/sidecar_repository_test.dart new file mode 100644 index 0000000..be7c344 --- /dev/null +++ b/test/sidecar_repository_test.dart @@ -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 .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(); + }); +}