feat(editor): persist strokes in live editor
Some checks failed
CI / Windows build (push) Has been cancelled
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:
@@ -8,9 +8,26 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:pdfrx/pdfrx.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_canvas.dart';
|
||||||
import 'pen_stroke.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 {
|
class PenEditorScreen extends StatefulWidget {
|
||||||
const PenEditorScreen({super.key, required this.pdfPath});
|
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).
|
/// Strokes per page, keyed by 0-based page index (normalized coords).
|
||||||
final Map<int, List<PenStroke>> _strokesByPage = {};
|
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
|
/// One shared transform for the current page; recentred on page change so
|
||||||
/// each page opens fit-to-view and centered.
|
/// each page opens fit-to-view and centered.
|
||||||
final TransformationController _transform = TransformationController();
|
final TransformationController _transform = TransformationController();
|
||||||
@@ -69,9 +93,64 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_documentId = _documentIdFromPath(widget.pdfPath);
|
||||||
|
_initPersistence();
|
||||||
_open();
|
_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 {
|
Future<void> _open() async {
|
||||||
try {
|
try {
|
||||||
final doc = await PdfDocument.openFile(widget.pdfPath);
|
final doc = await PdfDocument.openFile(widget.pdfPath);
|
||||||
@@ -87,6 +166,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
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();
|
_document?.dispose();
|
||||||
_transform.dispose();
|
_transform.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -105,6 +190,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
stroke,
|
stroke,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
// Snapshot SYNCHRONOUSLY (before any await) then schedule persistence.
|
||||||
|
final snapshot = List<PenStroke>.of(_strokesByPage[_pageIndex]!);
|
||||||
|
_schedulePageSave(_pageIndex, snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _eraseStroke(int index) {
|
void _eraseStroke(int index) {
|
||||||
@@ -115,6 +203,27 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
_strokesByPage[_pageIndex] = next;
|
_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) {
|
void _goToPage(int index) {
|
||||||
|
|||||||
Reference in New Issue
Block a user