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,68 @@
// lib/editor/engine/stroke_geometry.dart
//
// Single source of stroke outline geometry for both screen render and export.
// The recipe is lifted verbatim from the proven live
// `ink_painters.buildStrokePath` (lib/editor/canvas/ink_painters.dart): points
// are scaled from normalized page coords to pixels, perfect_freehand produces
// the outline, and a closed fill Path is built. Keeping ONE implementation here
// kills the hairline-export divergence (R7).
import 'dart:ui';
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
import 'stroke_model.dart';
/// Builds a closed, fillable outline [Path] for one [stroke], scaled into the
/// pixel space of [pageSize] (which maps normalized [0,1] coords to pixels).
///
/// [isComplete] should be false for the in-progress live stroke so freehand
/// tapers the trailing end correctly, and true for committed strokes.
///
/// Returns an empty [Path] when the stroke has no points (or freehand produces
/// no outline).
Path buildStrokeOutline(
EditorStroke stroke,
Size pageSize, {
required bool isComplete,
}) {
final path = Path();
if (stroke.points.isEmpty) return path;
final pixelWidth = stroke.width * pageSize.width;
final hasRealPressure = stroke.points.any((p) => p.pressure != null);
final isHighlighter = stroke.tool == EditorTool.highlighter;
final pfPoints = stroke.points
.map(
(p) => pf.Point(
p.x * pageSize.width,
p.y * pageSize.height,
p.pressure ?? 0.5,
),
)
.toList();
final outline = pf.getStroke(
pfPoints,
size: pixelWidth,
// Highlighter keeps a constant width (no thinning); pen thins (0.7),
// matching the live recipe.
thinning: isHighlighter ? 0.0 : 0.7,
smoothing: 0.5,
streamline: 0.5,
// Real stylus pressure -> don't simulate; no pressure -> let freehand fake
// it based on velocity (highlighter never simulates).
simulatePressure: !hasRealPressure && !isHighlighter,
isComplete: isComplete,
);
if (outline.isEmpty) return path;
path.moveTo(outline.first.x, outline.first.y);
for (var i = 1; i < outline.length; i++) {
path.lineTo(outline[i].x, outline[i].y);
}
path.close();
return path;
}

View File

@@ -0,0 +1,182 @@
// lib/editor/engine/stroke_model.dart
//
// Canonical, persistable stroke model for the BadNote editor engine.
//
// This is the single source of truth for ink strokes across the new own-canvas
// engine (screen render + export + persistence). It is a deliberate SUPERSET of
// both the in-memory live `PenStroke`/`PenPoint` (lib/editor/canvas/pen_stroke.dart)
// and the freezed/JSON `InkStroke`/`InkPoint` (lib/models/ink_stroke.dart) so the
// adapters below round-trip losslessly with `InkStroke` (SF1): `tilt`,
// `timestamp` and `pointerDeviceKind` are preserved, never dropped.
//
// Coordinate semantics (matching the live conventions):
// * Point x/y are NORMALIZED to the page rectangle, i.e. in [0,1].
// * Stroke `width` is a FRACTION of the page width, so it scales with zoom.
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:uuid/uuid.dart';
import '../../models/ink_point.dart';
import '../../models/ink_stroke.dart';
import '../../models/pen_tool.dart';
import '../../models/pointer_device_kind.dart';
import '../canvas/pen_stroke.dart';
part 'stroke_model.freezed.dart';
part 'stroke_model.g.dart';
const _uuid = Uuid();
/// The drawing tools the engine knows about. Extensible; P0 uses these three.
enum EditorTool {
@JsonValue('pen')
pen,
@JsonValue('highlighter')
highlighter,
@JsonValue('eraser')
eraser,
}
/// A single captured sample of a stroke.
///
/// [x]/[y] are normalized to the page rectangle ([0,1]). The remaining fields
/// are a superset of [InkPoint] (nullable here so the live capture path can
/// leave them unset, while [InkStroke] data round-trips intact through the
/// adapters below).
@freezed
abstract class EditorPoint with _$EditorPoint {
const factory EditorPoint({
required double x,
required double y,
double? pressure,
double? tilt,
int? timestamp,
InputDeviceKind? pointerDeviceKind,
}) = _EditorPoint;
factory EditorPoint.fromJson(Map<String, dynamic> json) =>
_$EditorPointFromJson(json);
}
/// A committed stroke in normalized page coordinates.
///
/// [width] is a fraction of page width (matches live `PenStroke.width`).
@freezed
abstract class EditorStroke with _$EditorStroke {
const EditorStroke._();
factory EditorStroke({
required String id,
required List<EditorPoint> points,
@Default(EditorTool.pen) EditorTool tool,
@Default(0xFF000000) int color,
@Default(0.003) double width,
@Default(false) bool filled,
String? textContent,
@Default(14.0) double fontSize,
}) = _EditorStroke;
/// Convenience constructor that generates a uuid [id] when none is supplied.
factory EditorStroke.create({
String? id,
required List<EditorPoint> points,
EditorTool tool = EditorTool.pen,
int color = 0xFF000000,
double width = 0.003,
bool filled = false,
String? textContent,
double fontSize = 14.0,
}) =>
EditorStroke(
id: id ?? _uuid.v4(),
points: points,
tool: tool,
color: color,
width: width,
filled: filled,
textContent: textContent,
fontSize: fontSize,
);
factory EditorStroke.fromJson(Map<String, dynamic> json) =>
_$EditorStrokeFromJson(json);
// ---- Adapters -----------------------------------------------------------
/// Adapts an in-memory live [PenStroke] (normalized, no tilt/timestamp/kind).
factory EditorStroke.fromPenStroke(PenStroke stroke, {String? id}) =>
EditorStroke(
id: id ?? _uuid.v4(),
points: stroke.points
.map((p) => EditorPoint(x: p.x, y: p.y, pressure: p.pressure))
.toList(),
tool: switch (stroke.kind) {
PenStrokeKind.pen => EditorTool.pen,
PenStrokeKind.highlighter => EditorTool.highlighter,
},
color: stroke.color,
width: stroke.width,
);
/// Lossless adapter from the freezed/JSON [InkStroke] model.
factory EditorStroke.fromInkStroke(InkStroke stroke) => EditorStroke(
id: stroke.id,
points: stroke.points
.map(
(p) => EditorPoint(
x: p.x,
y: p.y,
pressure: p.pressure,
tilt: p.tilt,
timestamp: p.timestamp,
pointerDeviceKind: p.pointerDeviceKind,
),
)
.toList(),
tool: _toolFromPenTool(stroke.tool),
color: stroke.color,
width: stroke.strokeWidth,
filled: stroke.filled,
textContent: stroke.textContent,
fontSize: stroke.fontSize,
);
/// Lossless adapter to the freezed/JSON [InkStroke] model. Null superset
/// fields fall back to [InkPoint]'s own defaults so the InkStroke round-trip
/// (fromInkStroke → toInkStroke) reproduces the original exactly.
InkStroke toInkStroke({DateTime? createdAt}) => InkStroke(
id: id,
points: points
.map(
(p) => InkPoint(
x: p.x,
y: p.y,
pressure: p.pressure ?? 0.5,
tilt: p.tilt ?? 0.0,
timestamp: p.timestamp ?? 0,
pointerDeviceKind:
p.pointerDeviceKind ?? InputDeviceKind.unknown,
),
)
.toList(),
tool: _toolToPenTool(tool),
color: color,
strokeWidth: width,
createdAt: createdAt ?? DateTime.fromMillisecondsSinceEpoch(0),
filled: filled,
textContent: textContent,
fontSize: fontSize,
);
static EditorTool _toolFromPenTool(PenTool tool) => switch (tool) {
PenTool.highlighter => EditorTool.highlighter,
PenTool.eraser => EditorTool.eraser,
_ => EditorTool.pen,
};
static PenTool _toolToPenTool(EditorTool tool) => switch (tool) {
EditorTool.pen => PenTool.pen,
EditorTool.highlighter => PenTool.highlighter,
EditorTool.eraser => PenTool.eraser,
};
}

View File

@@ -0,0 +1,618 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'stroke_model.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
EditorPoint _$EditorPointFromJson(Map<String, dynamic> json) {
return _EditorPoint.fromJson(json);
}
/// @nodoc
mixin _$EditorPoint {
double get x => throw _privateConstructorUsedError;
double get y => throw _privateConstructorUsedError;
double? get pressure => throw _privateConstructorUsedError;
double? get tilt => throw _privateConstructorUsedError;
int? get timestamp => throw _privateConstructorUsedError;
InputDeviceKind? get pointerDeviceKind => throw _privateConstructorUsedError;
/// Serializes this EditorPoint to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of EditorPoint
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$EditorPointCopyWith<EditorPoint> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $EditorPointCopyWith<$Res> {
factory $EditorPointCopyWith(
EditorPoint value,
$Res Function(EditorPoint) then,
) = _$EditorPointCopyWithImpl<$Res, EditorPoint>;
@useResult
$Res call({
double x,
double y,
double? pressure,
double? tilt,
int? timestamp,
InputDeviceKind? pointerDeviceKind,
});
}
/// @nodoc
class _$EditorPointCopyWithImpl<$Res, $Val extends EditorPoint>
implements $EditorPointCopyWith<$Res> {
_$EditorPointCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of EditorPoint
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? x = null,
Object? y = null,
Object? pressure = freezed,
Object? tilt = freezed,
Object? timestamp = freezed,
Object? pointerDeviceKind = freezed,
}) {
return _then(
_value.copyWith(
x: null == x
? _value.x
: x // ignore: cast_nullable_to_non_nullable
as double,
y: null == y
? _value.y
: y // ignore: cast_nullable_to_non_nullable
as double,
pressure: freezed == pressure
? _value.pressure
: pressure // ignore: cast_nullable_to_non_nullable
as double?,
tilt: freezed == tilt
? _value.tilt
: tilt // ignore: cast_nullable_to_non_nullable
as double?,
timestamp: freezed == timestamp
? _value.timestamp
: timestamp // ignore: cast_nullable_to_non_nullable
as int?,
pointerDeviceKind: freezed == pointerDeviceKind
? _value.pointerDeviceKind
: pointerDeviceKind // ignore: cast_nullable_to_non_nullable
as InputDeviceKind?,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$EditorPointImplCopyWith<$Res>
implements $EditorPointCopyWith<$Res> {
factory _$$EditorPointImplCopyWith(
_$EditorPointImpl value,
$Res Function(_$EditorPointImpl) then,
) = __$$EditorPointImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
double x,
double y,
double? pressure,
double? tilt,
int? timestamp,
InputDeviceKind? pointerDeviceKind,
});
}
/// @nodoc
class __$$EditorPointImplCopyWithImpl<$Res>
extends _$EditorPointCopyWithImpl<$Res, _$EditorPointImpl>
implements _$$EditorPointImplCopyWith<$Res> {
__$$EditorPointImplCopyWithImpl(
_$EditorPointImpl _value,
$Res Function(_$EditorPointImpl) _then,
) : super(_value, _then);
/// Create a copy of EditorPoint
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? x = null,
Object? y = null,
Object? pressure = freezed,
Object? tilt = freezed,
Object? timestamp = freezed,
Object? pointerDeviceKind = freezed,
}) {
return _then(
_$EditorPointImpl(
x: null == x
? _value.x
: x // ignore: cast_nullable_to_non_nullable
as double,
y: null == y
? _value.y
: y // ignore: cast_nullable_to_non_nullable
as double,
pressure: freezed == pressure
? _value.pressure
: pressure // ignore: cast_nullable_to_non_nullable
as double?,
tilt: freezed == tilt
? _value.tilt
: tilt // ignore: cast_nullable_to_non_nullable
as double?,
timestamp: freezed == timestamp
? _value.timestamp
: timestamp // ignore: cast_nullable_to_non_nullable
as int?,
pointerDeviceKind: freezed == pointerDeviceKind
? _value.pointerDeviceKind
: pointerDeviceKind // ignore: cast_nullable_to_non_nullable
as InputDeviceKind?,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$EditorPointImpl implements _EditorPoint {
const _$EditorPointImpl({
required this.x,
required this.y,
this.pressure,
this.tilt,
this.timestamp,
this.pointerDeviceKind,
});
factory _$EditorPointImpl.fromJson(Map<String, dynamic> json) =>
_$$EditorPointImplFromJson(json);
@override
final double x;
@override
final double y;
@override
final double? pressure;
@override
final double? tilt;
@override
final int? timestamp;
@override
final InputDeviceKind? pointerDeviceKind;
@override
String toString() {
return 'EditorPoint(x: $x, y: $y, pressure: $pressure, tilt: $tilt, timestamp: $timestamp, pointerDeviceKind: $pointerDeviceKind)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$EditorPointImpl &&
(identical(other.x, x) || other.x == x) &&
(identical(other.y, y) || other.y == y) &&
(identical(other.pressure, pressure) ||
other.pressure == pressure) &&
(identical(other.tilt, tilt) || other.tilt == tilt) &&
(identical(other.timestamp, timestamp) ||
other.timestamp == timestamp) &&
(identical(other.pointerDeviceKind, pointerDeviceKind) ||
other.pointerDeviceKind == pointerDeviceKind));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
x,
y,
pressure,
tilt,
timestamp,
pointerDeviceKind,
);
/// Create a copy of EditorPoint
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$EditorPointImplCopyWith<_$EditorPointImpl> get copyWith =>
__$$EditorPointImplCopyWithImpl<_$EditorPointImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$EditorPointImplToJson(this);
}
}
abstract class _EditorPoint implements EditorPoint {
const factory _EditorPoint({
required final double x,
required final double y,
final double? pressure,
final double? tilt,
final int? timestamp,
final InputDeviceKind? pointerDeviceKind,
}) = _$EditorPointImpl;
factory _EditorPoint.fromJson(Map<String, dynamic> json) =
_$EditorPointImpl.fromJson;
@override
double get x;
@override
double get y;
@override
double? get pressure;
@override
double? get tilt;
@override
int? get timestamp;
@override
InputDeviceKind? get pointerDeviceKind;
/// Create a copy of EditorPoint
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$EditorPointImplCopyWith<_$EditorPointImpl> get copyWith =>
throw _privateConstructorUsedError;
}
EditorStroke _$EditorStrokeFromJson(Map<String, dynamic> json) {
return _EditorStroke.fromJson(json);
}
/// @nodoc
mixin _$EditorStroke {
String get id => throw _privateConstructorUsedError;
List<EditorPoint> get points => throw _privateConstructorUsedError;
EditorTool get tool => throw _privateConstructorUsedError;
int get color => throw _privateConstructorUsedError;
double get width => throw _privateConstructorUsedError;
bool get filled => throw _privateConstructorUsedError;
String? get textContent => throw _privateConstructorUsedError;
double get fontSize => throw _privateConstructorUsedError;
/// Serializes this EditorStroke to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of EditorStroke
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$EditorStrokeCopyWith<EditorStroke> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $EditorStrokeCopyWith<$Res> {
factory $EditorStrokeCopyWith(
EditorStroke value,
$Res Function(EditorStroke) then,
) = _$EditorStrokeCopyWithImpl<$Res, EditorStroke>;
@useResult
$Res call({
String id,
List<EditorPoint> points,
EditorTool tool,
int color,
double width,
bool filled,
String? textContent,
double fontSize,
});
}
/// @nodoc
class _$EditorStrokeCopyWithImpl<$Res, $Val extends EditorStroke>
implements $EditorStrokeCopyWith<$Res> {
_$EditorStrokeCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of EditorStroke
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? points = null,
Object? tool = null,
Object? color = null,
Object? width = null,
Object? filled = null,
Object? textContent = freezed,
Object? fontSize = null,
}) {
return _then(
_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
points: null == points
? _value.points
: points // ignore: cast_nullable_to_non_nullable
as List<EditorPoint>,
tool: null == tool
? _value.tool
: tool // ignore: cast_nullable_to_non_nullable
as EditorTool,
color: null == color
? _value.color
: color // ignore: cast_nullable_to_non_nullable
as int,
width: null == width
? _value.width
: width // ignore: cast_nullable_to_non_nullable
as double,
filled: null == filled
? _value.filled
: filled // ignore: cast_nullable_to_non_nullable
as bool,
textContent: freezed == textContent
? _value.textContent
: textContent // ignore: cast_nullable_to_non_nullable
as String?,
fontSize: null == fontSize
? _value.fontSize
: fontSize // ignore: cast_nullable_to_non_nullable
as double,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$EditorStrokeImplCopyWith<$Res>
implements $EditorStrokeCopyWith<$Res> {
factory _$$EditorStrokeImplCopyWith(
_$EditorStrokeImpl value,
$Res Function(_$EditorStrokeImpl) then,
) = __$$EditorStrokeImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
String id,
List<EditorPoint> points,
EditorTool tool,
int color,
double width,
bool filled,
String? textContent,
double fontSize,
});
}
/// @nodoc
class __$$EditorStrokeImplCopyWithImpl<$Res>
extends _$EditorStrokeCopyWithImpl<$Res, _$EditorStrokeImpl>
implements _$$EditorStrokeImplCopyWith<$Res> {
__$$EditorStrokeImplCopyWithImpl(
_$EditorStrokeImpl _value,
$Res Function(_$EditorStrokeImpl) _then,
) : super(_value, _then);
/// Create a copy of EditorStroke
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? points = null,
Object? tool = null,
Object? color = null,
Object? width = null,
Object? filled = null,
Object? textContent = freezed,
Object? fontSize = null,
}) {
return _then(
_$EditorStrokeImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
points: null == points
? _value._points
: points // ignore: cast_nullable_to_non_nullable
as List<EditorPoint>,
tool: null == tool
? _value.tool
: tool // ignore: cast_nullable_to_non_nullable
as EditorTool,
color: null == color
? _value.color
: color // ignore: cast_nullable_to_non_nullable
as int,
width: null == width
? _value.width
: width // ignore: cast_nullable_to_non_nullable
as double,
filled: null == filled
? _value.filled
: filled // ignore: cast_nullable_to_non_nullable
as bool,
textContent: freezed == textContent
? _value.textContent
: textContent // ignore: cast_nullable_to_non_nullable
as String?,
fontSize: null == fontSize
? _value.fontSize
: fontSize // ignore: cast_nullable_to_non_nullable
as double,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$EditorStrokeImpl extends _EditorStroke {
_$EditorStrokeImpl({
required this.id,
required final List<EditorPoint> points,
this.tool = EditorTool.pen,
this.color = 0xFF000000,
this.width = 0.003,
this.filled = false,
this.textContent,
this.fontSize = 14.0,
}) : _points = points,
super._();
factory _$EditorStrokeImpl.fromJson(Map<String, dynamic> json) =>
_$$EditorStrokeImplFromJson(json);
@override
final String id;
final List<EditorPoint> _points;
@override
List<EditorPoint> get points {
if (_points is EqualUnmodifiableListView) return _points;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_points);
}
@override
@JsonKey()
final EditorTool tool;
@override
@JsonKey()
final int color;
@override
@JsonKey()
final double width;
@override
@JsonKey()
final bool filled;
@override
final String? textContent;
@override
@JsonKey()
final double fontSize;
@override
String toString() {
return 'EditorStroke(id: $id, points: $points, tool: $tool, color: $color, width: $width, filled: $filled, textContent: $textContent, fontSize: $fontSize)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$EditorStrokeImpl &&
(identical(other.id, id) || other.id == id) &&
const DeepCollectionEquality().equals(other._points, _points) &&
(identical(other.tool, tool) || other.tool == tool) &&
(identical(other.color, color) || other.color == color) &&
(identical(other.width, width) || other.width == width) &&
(identical(other.filled, filled) || other.filled == filled) &&
(identical(other.textContent, textContent) ||
other.textContent == textContent) &&
(identical(other.fontSize, fontSize) ||
other.fontSize == fontSize));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
const DeepCollectionEquality().hash(_points),
tool,
color,
width,
filled,
textContent,
fontSize,
);
/// Create a copy of EditorStroke
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$EditorStrokeImplCopyWith<_$EditorStrokeImpl> get copyWith =>
__$$EditorStrokeImplCopyWithImpl<_$EditorStrokeImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$EditorStrokeImplToJson(this);
}
}
abstract class _EditorStroke extends EditorStroke {
factory _EditorStroke({
required final String id,
required final List<EditorPoint> points,
final EditorTool tool,
final int color,
final double width,
final bool filled,
final String? textContent,
final double fontSize,
}) = _$EditorStrokeImpl;
_EditorStroke._() : super._();
factory _EditorStroke.fromJson(Map<String, dynamic> json) =
_$EditorStrokeImpl.fromJson;
@override
String get id;
@override
List<EditorPoint> get points;
@override
EditorTool get tool;
@override
int get color;
@override
double get width;
@override
bool get filled;
@override
String? get textContent;
@override
double get fontSize;
/// Create a copy of EditorStroke
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$EditorStrokeImplCopyWith<_$EditorStrokeImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,73 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'stroke_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$EditorPointImpl _$$EditorPointImplFromJson(Map<String, dynamic> json) =>
_$EditorPointImpl(
x: (json['x'] as num).toDouble(),
y: (json['y'] as num).toDouble(),
pressure: (json['pressure'] as num?)?.toDouble(),
tilt: (json['tilt'] as num?)?.toDouble(),
timestamp: (json['timestamp'] as num?)?.toInt(),
pointerDeviceKind: $enumDecodeNullable(
_$InputDeviceKindEnumMap,
json['pointerDeviceKind'],
),
);
Map<String, dynamic> _$$EditorPointImplToJson(_$EditorPointImpl instance) =>
<String, dynamic>{
'x': instance.x,
'y': instance.y,
'pressure': instance.pressure,
'tilt': instance.tilt,
'timestamp': instance.timestamp,
'pointerDeviceKind': _$InputDeviceKindEnumMap[instance.pointerDeviceKind],
};
const _$InputDeviceKindEnumMap = {
InputDeviceKind.touch: 'touch',
InputDeviceKind.mouse: 'mouse',
InputDeviceKind.stylus: 'stylus',
InputDeviceKind.invertedStylus: 'invertedStylus',
InputDeviceKind.trackpad: 'trackpad',
InputDeviceKind.unknown: 'unknown',
};
_$EditorStrokeImpl _$$EditorStrokeImplFromJson(Map<String, dynamic> json) =>
_$EditorStrokeImpl(
id: json['id'] as String,
points: (json['points'] as List<dynamic>)
.map((e) => EditorPoint.fromJson(e as Map<String, dynamic>))
.toList(),
tool:
$enumDecodeNullable(_$EditorToolEnumMap, json['tool']) ??
EditorTool.pen,
color: (json['color'] as num?)?.toInt() ?? 0xFF000000,
width: (json['width'] as num?)?.toDouble() ?? 0.003,
filled: json['filled'] as bool? ?? false,
textContent: json['textContent'] as String?,
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 14.0,
);
Map<String, dynamic> _$$EditorStrokeImplToJson(_$EditorStrokeImpl instance) =>
<String, dynamic>{
'id': instance.id,
'points': instance.points,
'tool': _$EditorToolEnumMap[instance.tool]!,
'color': instance.color,
'width': instance.width,
'filled': instance.filled,
'textContent': instance.textContent,
'fontSize': instance.fontSize,
};
const _$EditorToolEnumMap = {
EditorTool.pen: 'pen',
EditorTool.highlighter: 'highlighter',
EditorTool.eraser: 'eraser',
};

View File

@@ -0,0 +1,56 @@
// lib/editor/engine/stroke_store.dart
//
// Mutable, revision-tracked store for committed EditorStrokes.
//
// Every mutation bumps [revision] (monotonic int). Consumers use the revision
// as an O(1) repaint gate: if revision has not changed since the last paint,
// nothing needs to be redrawn (StaticInkPainter.shouldRepaint).
import 'stroke_model.dart';
/// Holds the ordered list of committed [EditorStroke]s for one ink host (e.g.
/// a page or annotation layer). Every mutating operation bumps [revision].
///
/// This class is intentionally NOT a ChangeNotifier / Listenable — callers
/// poll the revision number from within CustomPainter.shouldRepaint, so no
/// subscription machinery is needed here.
class StrokeStore {
final List<EditorStroke> _strokes = [];
int _revision = 0;
/// Monotonically increasing counter. Bumped on every mutation.
int get revision => _revision;
/// Unmodifiable ordered list of committed strokes.
List<EditorStroke> get committed => List.unmodifiable(_strokes);
/// Appends [stroke] and bumps the revision.
void add(EditorStroke stroke) {
_strokes.add(stroke);
_revision++;
}
/// Removes the stroke with the given [id] (no-op if not found) and bumps
/// the revision only when a stroke was actually removed.
void removeById(String id) {
final before = _strokes.length;
_strokes.removeWhere((s) => s.id == id);
if (_strokes.length != before) {
_revision++;
}
}
/// Replaces the entire stroke list and bumps the revision.
void replaceAll(List<EditorStroke> strokes) {
_strokes
..clear()
..addAll(strokes);
_revision++;
}
/// Clears all strokes and bumps the revision.
void clear() {
_strokes.clear();
_revision++;
}
}