feat(editor): persist strokes in live editor
Some checks failed
CI / Windows build (push) Has been cancelled

Wire the new EditorRepository + SaveScheduler into PenEditorScreen:
load strokes on open (keyed by a stable djb2 doc-id from the path),
save per page on commit/erase via the debounced diff-write scheduler
(synchronous snapshot before await), flush on dispose. Strokes now
survive close/reopen. PenStroke<->EditorStroke conversion at the
boundary.
This commit is contained in:
2026-06-21 23:45:47 +08:00
parent 914951afb7
commit 28b7af7e72

View File

@@ -8,9 +8,26 @@
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import '../../services/database_service.dart';
import '../engine/stroke_model.dart';
import '../persistence/editor_repository.dart';
import '../persistence/save_scheduler.dart';
import 'pen_canvas.dart';
import 'pen_stroke.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.
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, required this.pdfPath});
@@ -30,6 +47,13 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
/// Strokes per page, keyed by 0-based page index (normalized coords).
final Map<int, List<PenStroke>> _strokesByPage = {};
// ── Persistence ────────────────────────────────────────────────────────────
/// Stable document-id derived from the PDF file path.
late final String _documentId;
SaveScheduler? _saveScheduler;
/// One shared transform for the current page; recentred on page change so
/// each page opens fit-to-view and centered.
final TransformationController _transform = TransformationController();
@@ -69,9 +93,64 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
@override
void initState() {
super.initState();
_documentId = _documentIdFromPath(widget.pdfPath);
_initPersistence();
_open();
}
Future<void> _initPersistence() async {
final service = await DatabaseService.getInstance();
if (!mounted) return;
final repo = await EditorRepository.fromService(service);
final scheduler = SaveScheduler(repo);
if (!mounted) {
scheduler.dispose();
return;
}
_saveScheduler = scheduler;
// Load any previously persisted strokes for this document.
await _loadPersistedStrokes(repo);
}
/// 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(
points: es.points
.map((ep) => PenPoint(ep.x, ep.y, ep.pressure))
.toList(),
color: es.color,
width: es.width,
kind: es.tool == EditorTool.highlighter
? PenStrokeKind.highlighter
: PenStrokeKind.pen,
))
.toList();
}
if (loaded.isNotEmpty) {
setState(() {
for (final entry in loaded.entries) {
_strokesByPage[entry.key] = entry.value;
}
});
}
}
/// Extract the page index from a host_id of the form
/// `"doc:<documentId>:page:<pageIndex>"`.
int? _pageIndexFromHostId(String hostId) {
const marker = ':page:';
final idx = hostId.lastIndexOf(marker);
if (idx == -1) return null;
return int.tryParse(hostId.substring(idx + marker.length));
}
Future<void> _open() async {
try {
final doc = await PdfDocument.openFile(widget.pdfPath);
@@ -87,6 +166,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
@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();
}
_document?.dispose();
_transform.dispose();
super.dispose();
@@ -105,6 +190,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
stroke,
];
});
// Snapshot SYNCHRONOUSLY (before any await) then schedule persistence.
final snapshot = List<PenStroke>.of(_strokesByPage[_pageIndex]!);
_schedulePageSave(_pageIndex, snapshot);
}
void _eraseStroke(int index) {
@@ -115,6 +203,27 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
_strokesByPage[_pageIndex] = next;
}
});
// Snapshot SYNCHRONOUSLY after the mutation, then schedule persistence.
final current = _strokesByPage[_pageIndex];
final snapshot =
current != null ? List<PenStroke>.of(current) : <PenStroke>[];
_schedulePageSave(_pageIndex, snapshot);
}
/// Convert [strokes] to [EditorStroke]s and hand them to the save scheduler.
///
/// Must be called synchronously (no await between the snapshot and this call)
/// so the scheduler receives an immutable copy of the in-memory state.
void _schedulePageSave(int pageIndex, List<PenStroke> strokes) {
final scheduler = _saveScheduler;
if (scheduler == null) return;
final editorStrokes =
strokes.map((s) => EditorStroke.fromPenStroke(s)).toList();
scheduler.schedule(
'page',
EditorRepository.pageHostId(_documentId, pageIndex),
editorStrokes,
);
}
void _goToPage(int index) {