Per the full-refactor plan §9 (input-independent half of P0): - engine: canonical EditorStroke (lossless InkStroke round-trip) + stroke_geometry (single getStroke outline) + revision-gated StrokeStore - render: static/live ink painters + ink_picture_cache (revision-keyed) + annotation_layer (RepaintBoundary) - persistence: DB v6 (ink, notebook_pages) + editor_repository diff-write (UPSERT changed / DELETE removed in one txn; id-set after commit) + save_scheduler - pdf_service export now FILLS the getStroke outline (R7 hairline fix) Not yet wired into the live editor (input relocation pending pen-pressure diagnostic). 28 new tests pass.
99 lines
3.1 KiB
Dart
99 lines
3.1 KiB
Dart
// lib/editor/persistence/save_scheduler.dart
|
|
//
|
|
// Debounced save scheduler. The snapshot/JSON is captured SYNCHRONOUSLY by
|
|
// the schedule() call before any await, so callers do not need to worry about
|
|
// the in-memory state mutating between schedule() and the actual write.
|
|
|
|
import 'dart:async';
|
|
|
|
import '../engine/stroke_model.dart';
|
|
import 'editor_repository.dart';
|
|
|
|
/// Debounced save scheduler that batches rapid successive changes to a host
|
|
/// into a single [EditorRepository.saveHost] call.
|
|
///
|
|
/// Usage:
|
|
/// ```dart
|
|
/// final scheduler = SaveScheduler(repository);
|
|
/// scheduler.schedule('page', hostId, List.from(strokes));
|
|
/// // …later, on dispose / navigate away:
|
|
/// await scheduler.flush();
|
|
/// scheduler.dispose();
|
|
/// ```
|
|
class SaveScheduler {
|
|
SaveScheduler(
|
|
this._repository, {
|
|
Duration debounce = const Duration(milliseconds: 800),
|
|
}) : _debounce = debounce;
|
|
|
|
final EditorRepository _repository;
|
|
final Duration _debounce;
|
|
|
|
// One pending timer + captured snapshot per host.
|
|
final Map<String, Timer> _timers = {};
|
|
final Map<String, _PendingWrite> _pending = {};
|
|
|
|
bool _disposed = false;
|
|
|
|
// ── Public API ─────────────────────────────────────────────────────────
|
|
|
|
/// Schedule a save for ([hostKind], [hostId]).
|
|
///
|
|
/// [strokes] is captured synchronously (defensive copy via the caller's
|
|
/// `List.from(…)` convention or equivalent) so mutations after this call
|
|
/// do not affect what is written.
|
|
void schedule(
|
|
String hostKind,
|
|
String hostId,
|
|
List<EditorStroke> strokes,
|
|
) {
|
|
if (_disposed) return;
|
|
|
|
// Capture the snapshot synchronously before any async gap.
|
|
_pending[hostId] = _PendingWrite(hostKind: hostKind, strokes: strokes);
|
|
|
|
_timers[hostId]?.cancel();
|
|
_timers[hostId] = Timer(_debounce, () => _fire(hostId));
|
|
}
|
|
|
|
/// Force-write all pending saves immediately and wait for them to complete.
|
|
Future<void> flush() async {
|
|
final hosts = List<String>.from(_pending.keys);
|
|
for (final hostId in hosts) {
|
|
_timers[hostId]?.cancel();
|
|
_timers.remove(hostId);
|
|
await _fire(hostId);
|
|
}
|
|
}
|
|
|
|
/// Cancel all pending timers and release resources.
|
|
///
|
|
/// Call [flush] first if you need pending writes to complete.
|
|
void dispose() {
|
|
_disposed = true;
|
|
for (final timer in _timers.values) {
|
|
timer.cancel();
|
|
}
|
|
_timers.clear();
|
|
_pending.clear();
|
|
}
|
|
|
|
// ── Internal ───────────────────────────────────────────────────────────
|
|
|
|
Future<void> _fire(String hostId) async {
|
|
final write = _pending.remove(hostId);
|
|
_timers.remove(hostId);
|
|
if (write == null) return;
|
|
await _repository.saveHost(write.hostKind, hostId, write.strokes);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _PendingWrite {
|
|
const _PendingWrite({required this.hostKind, required this.strokes});
|
|
|
|
final String hostKind;
|
|
final List<EditorStroke> strokes;
|
|
}
|