feat(engine): P0 stroke engine + persistence

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.
This commit is contained in:
2026-06-21 23:41:01 +08:00
parent 1e2a83b0b9
commit 914951afb7
16 changed files with 2267 additions and 23 deletions

View File

@@ -0,0 +1,71 @@
// lib/editor/render/annotation_layer.dart
//
// Composites the static committed-stroke layer and the live in-progress layer
// into a single widget. Wrap the page widget with this to get ink rendering.
//
// Layout:
// RepaintBoundary
// └─ Stack
// ├─ CustomPaint(StaticInkPainter) ← repaints only on revision bump
// └─ CustomPaint(LiveInkPainter) ← repaints on every pointer move
import 'package:flutter/material.dart';
import '../engine/stroke_model.dart';
import '../engine/stroke_store.dart';
import 'ink_picture_cache.dart';
import 'live_ink_painter.dart';
import 'static_ink_painter.dart';
/// A [StatelessWidget] that renders committed and live ink strokes over a
/// [pageSize]-sized area.
///
/// Place it as an overlay on top of the page content; it is fully transparent
/// where no strokes are drawn.
///
/// [hostId] identifies the ink host (e.g. page id) and is used as the cache
/// key prefix so multiple pages can share an [InkPictureCache] instance.
class AnnotationLayer extends StatelessWidget {
const AnnotationLayer({
super.key,
required this.hostId,
required this.store,
required this.liveStroke,
required this.pageSize,
required this.cache,
});
final String hostId;
final StrokeStore store;
/// The stroke currently being drawn, or null when idle.
final EditorStroke? liveStroke;
final Size pageSize;
final InkPictureCache cache;
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: Stack(
children: [
CustomPaint(
size: pageSize,
painter: StaticInkPainter(
hostId: hostId,
store: store,
pageSize: pageSize,
cache: cache,
),
),
CustomPaint(
size: pageSize,
painter: LiveInkPainter(
live: liveStroke,
pageSize: pageSize,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,87 @@
// lib/editor/render/ink_picture_cache.dart
//
// Bounded LRU cache of ui.Picture objects keyed by "hostId:revision".
//
// Resolution-independent: ink is vector, so a single Picture is valid at any
// zoom level. There are NO DPI buckets.
//
// Evicted Pictures are disposed via a post-frame callback so Flutter's raster
// thread is never asked to delete a Picture it may still be reading.
import 'dart:collection';
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
/// Bounded LRU cache of [ui.Picture]s keyed by a string (typically
/// `"$hostId:$revision"`).
///
/// Usage:
/// ```dart
/// final picture = cache.getOrBuild(hostId, store.revision, size, () {
/// final recorder = ui.PictureRecorder();
/// final canvas = ui.Canvas(recorder);
/// // … draw …
/// return recorder.endRecording();
/// });
/// canvas.drawPicture(picture);
/// ```
class InkPictureCache {
InkPictureCache({int maxSize = 12}) : _maxSize = maxSize;
final int _maxSize;
// LinkedHashMap preserves insertion order; we move accessed entries to the
// back so the front is always the least-recently used.
final LinkedHashMap<String, ui.Picture> _cache =
LinkedHashMap<String, ui.Picture>();
/// Returns a cached [ui.Picture] for [key], or calls [build] to create one.
///
/// The [key] should encode all inputs that affect the picture content (host
/// id + revision, at minimum). [size] and [build] are only used on a cache
/// miss.
ui.Picture getOrBuild(
String hostId,
int revision,
ui.Size size,
ui.Picture Function() build,
) {
final key = '$hostId:$revision';
if (_cache.containsKey(key)) {
// Promote to most-recently-used by reinserting at the back.
final pic = _cache.remove(key)!;
_cache[key] = pic;
return pic;
}
final picture = build();
_cache[key] = picture;
// Evict least-recently-used entries beyond the cap.
while (_cache.length > _maxSize) {
final lruKey = _cache.keys.first;
final evicted = _cache.remove(lruKey)!;
_disposeDeferred(evicted);
}
return picture;
}
/// Disposes all cached Pictures, deferring the actual disposal to a
/// post-frame callback so any in-flight raster work can complete.
void dispose() {
final pictures = List<ui.Picture>.from(_cache.values);
_cache.clear();
for (final pic in pictures) {
_disposeDeferred(pic);
}
}
static void _disposeDeferred(ui.Picture picture) {
WidgetsBinding.instance.addPostFrameCallback((_) {
picture.dispose();
});
}
}

View File

@@ -0,0 +1,47 @@
// lib/editor/render/live_ink_painter.dart
//
// CustomPainter for the in-progress stroke (live) layer.
//
// Paints only the single EditorStroke? currently being drawn, with
// isComplete:false so perfect_freehand tapers the trailing end correctly.
// Kept in a separate RepaintBoundary so committed strokes are never
// re-rasterized on pointer-move events.
import 'package:flutter/material.dart';
import '../engine/stroke_geometry.dart';
import '../engine/stroke_model.dart';
/// Paints the single in-progress [EditorStroke] (or nothing when [live] is
/// null / empty). Use alongside [StaticInkPainter] in stacked [CustomPaint]s.
class LiveInkPainter extends CustomPainter {
const LiveInkPainter({
required this.live,
required this.pageSize,
});
/// The stroke currently being drawn, or null when idle.
final EditorStroke? live;
final Size pageSize;
@override
void paint(Canvas canvas, Size size) {
final stroke = live;
if (stroke == null || stroke.points.isEmpty) return;
final path = buildStrokeOutline(stroke, pageSize, isComplete: false);
if (path.getBounds().isEmpty) return;
canvas.drawPath(
path,
Paint()
..color = Color(stroke.color)
..style = PaintingStyle.fill
..isAntiAlias = true,
);
}
@override
bool shouldRepaint(LiveInkPainter old) =>
!identical(old.live, live) || old.pageSize != pageSize;
}

View File

@@ -0,0 +1,69 @@
// lib/editor/render/static_ink_painter.dart
//
// CustomPainter for the committed-stroke (static) layer.
//
// paint() gets-or-builds a ui.Picture of all committed strokes keyed by
// store.revision, then delegates to canvas.drawPicture — so as long as the
// revision is unchanged the raster thread replays the same display list at
// zero CPU cost.
//
// shouldRepaint() is O(1): it compares the revision int and pageSize only.
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import '../engine/stroke_geometry.dart';
import '../engine/stroke_store.dart';
import 'ink_picture_cache.dart';
/// Paints the committed ink layer by recording strokes into a [ui.Picture]
/// once per [StrokeStore.revision] and caching it in [InkPictureCache].
///
/// Place this inside a [RepaintBoundary] / [CustomPaint] pair. The sibling
/// [LiveInkPainter] handles the in-progress stroke in a separate layer.
class StaticInkPainter extends CustomPainter {
StaticInkPainter({
required this.hostId,
required this.store,
required this.pageSize,
required this.cache,
}) : revision = store.revision;
final String hostId;
final StrokeStore store;
final Size pageSize;
final InkPictureCache cache;
/// Revision snapshot captured at construction time. Used by [shouldRepaint]
/// so two painters built at different revisions compare correctly even when
/// they share the same [StrokeStore] instance.
final int revision;
@override
void paint(Canvas canvas, Size size) {
final picture = cache.getOrBuild(hostId, store.revision, pageSize, () {
final recorder = ui.PictureRecorder();
final rec = Canvas(recorder);
for (final stroke in store.committed) {
final path =
buildStrokeOutline(stroke, pageSize, isComplete: true);
if (path.getBounds().isEmpty) continue;
rec.drawPath(
path,
Paint()
..color = Color(stroke.color)
..style = PaintingStyle.fill
..isAntiAlias = true,
);
}
return recorder.endRecording();
});
canvas.drawPicture(picture);
}
@override
bool shouldRepaint(StaticInkPainter old) =>
old.revision != store.revision || old.pageSize != pageSize;
}