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:
71
lib/editor/render/annotation_layer.dart
Normal file
71
lib/editor/render/annotation_layer.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
87
lib/editor/render/ink_picture_cache.dart
Normal file
87
lib/editor/render/ink_picture_cache.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
47
lib/editor/render/live_ink_painter.dart
Normal file
47
lib/editor/render/live_ink_painter.dart
Normal 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;
|
||||
}
|
||||
69
lib/editor/render/static_ink_painter.dart
Normal file
69
lib/editor/render/static_ink_painter.dart
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user