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:
68
lib/editor/engine/stroke_geometry.dart
Normal file
68
lib/editor/engine/stroke_geometry.dart
Normal 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;
|
||||
}
|
||||
182
lib/editor/engine/stroke_model.dart
Normal file
182
lib/editor/engine/stroke_model.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
618
lib/editor/engine/stroke_model.freezed.dart
Normal file
618
lib/editor/engine/stroke_model.freezed.dart
Normal 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;
|
||||
}
|
||||
73
lib/editor/engine/stroke_model.g.dart
Normal file
73
lib/editor/engine/stroke_model.g.dart
Normal 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',
|
||||
};
|
||||
56
lib/editor/engine/stroke_store.dart
Normal file
56
lib/editor/engine/stroke_store.dart
Normal 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++;
|
||||
}
|
||||
}
|
||||
149
lib/editor/persistence/editor_repository.dart
Normal file
149
lib/editor/persistence/editor_repository.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
// lib/editor/persistence/editor_repository.dart
|
||||
//
|
||||
// MF3 diff-write contract: per-host diff of stroke ids against the last
|
||||
// persisted set. Only changed/new rows are upserted; only removed rows are
|
||||
// deleted. All mutations run in ONE transaction per saveHost call.
|
||||
// The in-memory _persistedIds map is updated only after the transaction
|
||||
// commits successfully.
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
import '../engine/stroke_model.dart';
|
||||
import '../../services/database_service.dart';
|
||||
|
||||
/// Repository for persisting [EditorStroke]s to the `ink` table.
|
||||
///
|
||||
/// Host-id scheme: `"page:<pageIndex>"` where pageIndex is the zero-based
|
||||
/// index of the page within its document. For example, page 0 of a document
|
||||
/// uses host_id `"page:0"`.
|
||||
///
|
||||
/// [loadDocument] uses a single batched query over all ink rows whose
|
||||
/// host_id begins with `"page:"` for the document, grouped by host_id.
|
||||
/// [saveHost] implements the MF3 diff-write contract.
|
||||
class EditorRepository {
|
||||
EditorRepository(this._db);
|
||||
|
||||
final Database _db;
|
||||
|
||||
/// Per host_id, the set of stroke ids that were last persisted to the DB.
|
||||
/// Updated only after a successful transaction commit.
|
||||
final Map<String, Set<String>> _persistedIds = {};
|
||||
|
||||
// ── Factory ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Convenience constructor that initialises from [DatabaseService].
|
||||
static Future<EditorRepository> fromService(DatabaseService service) async {
|
||||
return EditorRepository(service.database);
|
||||
}
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Load all ink rows for [documentId] in a single batched query.
|
||||
///
|
||||
/// Returns a map keyed by host_id (e.g. `"page:0"`) whose values are
|
||||
/// the strokes for that host in ascending ordinal order.
|
||||
///
|
||||
/// The host_id scheme is: host_kind = `"page"`, host_id = `"page:<i>"`.
|
||||
Future<Map<String, List<EditorStroke>>> loadDocument(
|
||||
String documentId,
|
||||
) async {
|
||||
// All page hosts for a document share the prefix "page:" inside host_id.
|
||||
// We tag them with document_id via the host_id prefix convention:
|
||||
// host_id = "doc:<documentId>:page:<pageIndex>"
|
||||
final rows = await _db.query(
|
||||
'ink',
|
||||
where: 'host_kind = ? AND host_id LIKE ?',
|
||||
whereArgs: ['page', 'doc:$documentId:page:%'],
|
||||
orderBy: 'host_id ASC, ordinal ASC',
|
||||
);
|
||||
|
||||
final result = <String, List<EditorStroke>>{};
|
||||
|
||||
for (final row in rows) {
|
||||
final hostId = row['host_id'] as String;
|
||||
final strokeJson =
|
||||
jsonDecode(row['stroke_json'] as String) as Map<String, dynamic>;
|
||||
final stroke = EditorStroke.fromJson(strokeJson);
|
||||
|
||||
result.putIfAbsent(hostId, () => []).add(stroke);
|
||||
}
|
||||
|
||||
// Populate _persistedIds from what we just read so that subsequent
|
||||
// saveHost calls can diff correctly even on a fresh repository instance.
|
||||
for (final entry in result.entries) {
|
||||
_persistedIds[entry.key] = entry.value.map((s) => s.id).toSet();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Save (MF3 diff-write contract) ────────────────────────────────────
|
||||
|
||||
/// Persist [strokes] for the given host ([hostKind], [hostId]).
|
||||
///
|
||||
/// Diff against the last-known persisted id-set:
|
||||
/// - NEW / CHANGED rows → INSERT OR REPLACE (upsert)
|
||||
/// - REMOVED rows → DELETE
|
||||
///
|
||||
/// All mutations execute in a single transaction. [_persistedIds] is
|
||||
/// updated only after the transaction commits.
|
||||
Future<void> saveHost(
|
||||
String hostKind,
|
||||
String hostId,
|
||||
List<EditorStroke> strokes,
|
||||
) async {
|
||||
final incoming = strokes;
|
||||
final incomingIds = incoming.map((s) => s.id).toSet();
|
||||
final persisted = _persistedIds[hostId] ?? {};
|
||||
|
||||
final toDelete = persisted.difference(incomingIds);
|
||||
final toUpsert =
|
||||
incoming.where((s) => !persisted.contains(s.id)).toList();
|
||||
|
||||
// Fast path: nothing to do.
|
||||
if (toDelete.isEmpty && toUpsert.isEmpty) return;
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
await _db.transaction((txn) async {
|
||||
// Upsert new/changed rows.
|
||||
for (var i = 0; i < incoming.length; i++) {
|
||||
final stroke = incoming[i];
|
||||
if (!persisted.contains(stroke.id)) {
|
||||
await txn.rawInsert(
|
||||
'''INSERT INTO ink (id, host_kind, host_id, stroke_json, ordinal, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
stroke_json = excluded.stroke_json,
|
||||
ordinal = excluded.ordinal,
|
||||
updated_at = excluded.updated_at''',
|
||||
[
|
||||
stroke.id,
|
||||
hostKind,
|
||||
hostId,
|
||||
jsonEncode(stroke.toJson()),
|
||||
i,
|
||||
now,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removed rows.
|
||||
for (final id in toDelete) {
|
||||
await txn.delete('ink', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
});
|
||||
|
||||
// Update persisted id-set only after successful commit.
|
||||
_persistedIds[hostId] = Set<String>.from(incomingIds);
|
||||
}
|
||||
|
||||
// ── Host-id helpers ───────────────────────────────────────────────────
|
||||
|
||||
/// Build the canonical host_id for a document page.
|
||||
static String pageHostId(String documentId, int pageIndex) =>
|
||||
'doc:$documentId:page:$pageIndex';
|
||||
}
|
||||
98
lib/editor/persistence/save_scheduler.dart
Normal file
98
lib/editor/persistence/save_scheduler.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
// 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -41,7 +41,7 @@ class DatabaseService {
|
||||
|
||||
_database = await openDatabase(
|
||||
dbPath,
|
||||
version: 5,
|
||||
version: 6,
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade,
|
||||
);
|
||||
@@ -156,12 +156,71 @@ class DatabaseService {
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_scratchpads_doc ON scratchpads(document_id)',
|
||||
);
|
||||
|
||||
// Editor ink strokes (v6)
|
||||
await db.execute('''
|
||||
CREATE TABLE ink (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_kind TEXT NOT NULL,
|
||||
host_id TEXT NOT NULL,
|
||||
stroke_json TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_ink_host ON ink(host_kind, host_id)',
|
||||
);
|
||||
|
||||
// Notebook pages (v6)
|
||||
await db.execute('''
|
||||
CREATE TABLE notebook_pages (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
source_page_index INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||
if (oldVersion < 3) await _migrateV2toV3(db);
|
||||
if (oldVersion < 4) {} // v3->v4: version boundary (no-op schema)
|
||||
if (oldVersion < 5) await _migrateV4toV5(db);
|
||||
if (oldVersion < 6) await _migrateV5toV6(db);
|
||||
}
|
||||
|
||||
Future<void> _migrateV5toV6(Database db) async {
|
||||
await db.transaction((txn) async {
|
||||
await txn.execute('''
|
||||
CREATE TABLE ink (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_kind TEXT NOT NULL,
|
||||
host_id TEXT NOT NULL,
|
||||
stroke_json TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
await txn.execute(
|
||||
'CREATE INDEX idx_ink_host ON ink(host_kind, host_id)',
|
||||
);
|
||||
|
||||
await txn.execute('''
|
||||
CREATE TABLE notebook_pages (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
source_page_index INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _migrateV2toV3(Database db) async {
|
||||
|
||||
@@ -5,9 +5,11 @@ import 'dart:ui';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||
import 'package:syncfusion_flutter_pdf/pdf.dart';
|
||||
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
|
||||
/// Service for PDF operations: file picking, info extraction, and annotation export.
|
||||
class PdfService {
|
||||
@@ -201,6 +203,11 @@ class PdfService {
|
||||
|
||||
/// Renders [strokes] onto a PDF [page] using normalized [0, 1] coordinates
|
||||
/// scaled to the actual page dimensions.
|
||||
///
|
||||
/// Freehand tools (pen, marker, highlighter) are rendered as filled outline
|
||||
/// polygons produced by perfect_freehand's [getStroke], matching the
|
||||
/// on-screen filled-nib appearance. Shape tools and text keep their existing
|
||||
/// pen-stroke semantics.
|
||||
void _renderStrokes(PdfPage page, List<InkStroke> strokes) {
|
||||
final graphics = page.graphics;
|
||||
final pageSize = page.getClientSize();
|
||||
@@ -213,33 +220,107 @@ class PdfService {
|
||||
final g = (color >> 8) & 0xFF;
|
||||
final b = color & 0xFF;
|
||||
final a = (color >> 24) & 0xFF;
|
||||
final pdfColor = PdfColor(r, g, b, a);
|
||||
|
||||
final pen = PdfPen(PdfColor(r, g, b, a));
|
||||
pen.width = stroke.strokeWidth.clamp(1.0, 8.0);
|
||||
final isFreehand = stroke.tool == PenTool.pen ||
|
||||
stroke.tool == PenTool.marker ||
|
||||
stroke.tool == PenTool.highlighter;
|
||||
|
||||
if (stroke.points.length == 1) {
|
||||
// Single point — draw a dot
|
||||
final pt = stroke.points.first;
|
||||
graphics.drawEllipse(
|
||||
Rect.fromCenter(
|
||||
center: Offset(pt.x * pageSize.width, pt.y * pageSize.height),
|
||||
width: stroke.strokeWidth,
|
||||
height: stroke.strokeWidth,
|
||||
),
|
||||
pen: pen,
|
||||
);
|
||||
} else {
|
||||
// Draw line segments between consecutive points
|
||||
for (int i = 0; i < stroke.points.length - 1; i++) {
|
||||
final p1 = stroke.points[i];
|
||||
final p2 = stroke.points[i + 1];
|
||||
graphics.drawLine(
|
||||
pen,
|
||||
Offset(p1.x * pageSize.width, p1.y * pageSize.height),
|
||||
Offset(p2.x * pageSize.width, p2.y * pageSize.height),
|
||||
if (isFreehand) {
|
||||
final brush = PdfSolidBrush(pdfColor);
|
||||
|
||||
if (stroke.points.length == 1) {
|
||||
// Single point — filled dot matching stroke width.
|
||||
final pt = stroke.points.first;
|
||||
final radius = stroke.strokeWidth / 2;
|
||||
graphics.drawEllipse(
|
||||
Rect.fromCenter(
|
||||
center: Offset(pt.x * pageSize.width, pt.y * pageSize.height),
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
),
|
||||
brush: brush,
|
||||
);
|
||||
} else {
|
||||
final pdfPath = _buildFreehandPdfPath(stroke, pageSize);
|
||||
if (pdfPath != null) {
|
||||
graphics.drawPath(pdfPath, brush: brush);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Shape tools and text: keep existing pen-segment semantics.
|
||||
final pen = PdfPen(pdfColor);
|
||||
pen.width = stroke.strokeWidth.clamp(1.0, 8.0);
|
||||
|
||||
if (stroke.points.length == 1) {
|
||||
final pt = stroke.points.first;
|
||||
graphics.drawEllipse(
|
||||
Rect.fromCenter(
|
||||
center: Offset(pt.x * pageSize.width, pt.y * pageSize.height),
|
||||
width: stroke.strokeWidth,
|
||||
height: stroke.strokeWidth,
|
||||
),
|
||||
pen: pen,
|
||||
);
|
||||
} else {
|
||||
for (int i = 0; i < stroke.points.length - 1; i++) {
|
||||
final p1 = stroke.points[i];
|
||||
final p2 = stroke.points[i + 1];
|
||||
graphics.drawLine(
|
||||
pen,
|
||||
Offset(p1.x * pageSize.width, p1.y * pageSize.height),
|
||||
Offset(p2.x * pageSize.width, p2.y * pageSize.height),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a [PdfPath] filled outline polygon for a freehand [stroke] using
|
||||
/// perfect_freehand's [getStroke], matching the on-screen recipe from
|
||||
/// [lib/editor/engine/stroke_geometry.dart].
|
||||
///
|
||||
/// Points are scaled from normalized [0,1] coords into PDF-point space
|
||||
/// defined by [pageSize] before being passed to [getStroke], so the
|
||||
/// resulting outline is already in PDF coordinates.
|
||||
///
|
||||
/// Returns null when [getStroke] produces an empty outline.
|
||||
PdfPath? _buildFreehandPdfPath(InkStroke stroke, Size pageSize) {
|
||||
final isHighlighter = stroke.tool == PenTool.highlighter;
|
||||
final pixelWidth = stroke.strokeWidth * pageSize.width;
|
||||
|
||||
// Detect real stylus pressure: the InkPoint default is 0.5, so any point
|
||||
// that differs from the default indicates actual device pressure data.
|
||||
final hasRealPressure = stroke.points.any((pt) => pt.pressure != 0.5);
|
||||
|
||||
final pfPoints = stroke.points
|
||||
.map(
|
||||
(pt) => pf.Point(
|
||||
pt.x * pageSize.width,
|
||||
pt.y * pageSize.height,
|
||||
pt.pressure,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final outline = pf.getStroke(
|
||||
pfPoints,
|
||||
size: pixelWidth,
|
||||
// Highlighter keeps constant width; pen/marker taper via thinning=0.7.
|
||||
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: true,
|
||||
);
|
||||
|
||||
if (outline.isEmpty) return null;
|
||||
|
||||
final path = PdfPath();
|
||||
path.addPolygon(outline.map((pt) => Offset(pt.x, pt.y)).toList());
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
160
test/editor_render_test.dart
Normal file
160
test/editor_render_test.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
// test/editor_render_test.dart
|
||||
//
|
||||
// Unit tests for the P0 RENDER layer:
|
||||
// (a) StrokeStore: add/removeById/replaceAll/clear all bump revision.
|
||||
// (b) StaticInkPainter.shouldRepaint: FALSE when revision+pageSize unchanged,
|
||||
// TRUE when revision changes. (The key perf invariant.)
|
||||
//
|
||||
// Tests are intentionally pure-logic: no widget pump, no GPU, no image
|
||||
// comparisons. StaticInkPainter.shouldRepaint only reads store.revision and
|
||||
// pageSize so we can exercise it without a real ui.Picture or Canvas.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||
import 'package:badnote/editor/engine/stroke_store.dart';
|
||||
import 'package:badnote/editor/render/ink_picture_cache.dart';
|
||||
import 'package:badnote/editor/render/static_ink_painter.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
EditorStroke _stroke(String id) => EditorStroke.create(
|
||||
id: id,
|
||||
points: const [
|
||||
EditorPoint(x: 0.1, y: 0.1, pressure: 0.5),
|
||||
EditorPoint(x: 0.5, y: 0.5, pressure: 0.5),
|
||||
],
|
||||
);
|
||||
|
||||
/// Builds a [StaticInkPainter] that can be interrogated via [shouldRepaint]
|
||||
/// without ever calling [paint] (avoids needing a real Canvas / Picture).
|
||||
StaticInkPainter _painter(StrokeStore store, Size pageSize) =>
|
||||
StaticInkPainter(
|
||||
hostId: 'test-host',
|
||||
store: store,
|
||||
pageSize: pageSize,
|
||||
cache: InkPictureCache(),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
const pageSize = Size(800.0, 600.0);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
group('StrokeStore revision', () {
|
||||
test('starts at 0', () {
|
||||
final store = StrokeStore();
|
||||
expect(store.revision, 0);
|
||||
});
|
||||
|
||||
test('add bumps revision', () {
|
||||
final store = StrokeStore();
|
||||
store.add(_stroke('s1'));
|
||||
expect(store.revision, 1);
|
||||
store.add(_stroke('s2'));
|
||||
expect(store.revision, 2);
|
||||
expect(store.committed.length, 2);
|
||||
});
|
||||
|
||||
test('removeById bumps revision when stroke is found', () {
|
||||
final store = StrokeStore()..add(_stroke('s1'));
|
||||
final revBefore = store.revision;
|
||||
store.removeById('s1');
|
||||
expect(store.revision, greaterThan(revBefore));
|
||||
expect(store.committed, isEmpty);
|
||||
});
|
||||
|
||||
test('removeById does NOT bump revision when id is absent', () {
|
||||
final store = StrokeStore()..add(_stroke('s1'));
|
||||
final revBefore = store.revision;
|
||||
store.removeById('nonexistent');
|
||||
expect(store.revision, revBefore);
|
||||
});
|
||||
|
||||
test('replaceAll bumps revision', () {
|
||||
final store = StrokeStore();
|
||||
store.replaceAll([_stroke('a'), _stroke('b')]);
|
||||
expect(store.revision, 1);
|
||||
expect(store.committed.length, 2);
|
||||
|
||||
store.replaceAll([_stroke('c')]);
|
||||
expect(store.revision, 2);
|
||||
expect(store.committed.length, 1);
|
||||
});
|
||||
|
||||
test('clear bumps revision', () {
|
||||
final store = StrokeStore()..add(_stroke('x'));
|
||||
final revBefore = store.revision;
|
||||
store.clear();
|
||||
expect(store.revision, greaterThan(revBefore));
|
||||
expect(store.committed, isEmpty);
|
||||
});
|
||||
|
||||
test('committed returns unmodifiable list', () {
|
||||
final store = StrokeStore()..add(_stroke('s'));
|
||||
expect(() => store.committed.add(_stroke('bad')), throwsUnsupportedError);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
group('StaticInkPainter.shouldRepaint', () {
|
||||
test('returns false when revision and pageSize are unchanged', () {
|
||||
final store = StrokeStore()..add(_stroke('s1'));
|
||||
final p1 = _painter(store, pageSize);
|
||||
final p2 = _painter(store, pageSize);
|
||||
|
||||
// Both painters wrap the same store at the same revision.
|
||||
expect(p2.shouldRepaint(p1), isFalse);
|
||||
});
|
||||
|
||||
test('returns true when revision changes', () {
|
||||
final store = StrokeStore()..add(_stroke('s1'));
|
||||
final p1 = _painter(store, pageSize);
|
||||
|
||||
// Mutate the store — revision bumps.
|
||||
store.add(_stroke('s2'));
|
||||
final p2 = _painter(store, pageSize);
|
||||
|
||||
expect(p2.shouldRepaint(p1), isTrue);
|
||||
});
|
||||
|
||||
test('returns true when only pageSize changes', () {
|
||||
final store = StrokeStore()..add(_stroke('s1'));
|
||||
final p1 = _painter(store, pageSize);
|
||||
final p2 = _painter(store, const Size(1024.0, 768.0));
|
||||
|
||||
expect(p2.shouldRepaint(p1), isTrue);
|
||||
});
|
||||
|
||||
test('returns false after replaceAll with same content (revision differs '
|
||||
'but separate store instances — uses store.revision not identity)', () {
|
||||
// This test confirms shouldRepaint uses the revision INTEGER, not object
|
||||
// identity, so a brand-new store at revision 0 == another at revision 0.
|
||||
final storeA = StrokeStore(); // revision 0
|
||||
final storeB = StrokeStore(); // revision 0
|
||||
final pA = _painter(storeA, pageSize);
|
||||
final pB = _painter(storeB, pageSize);
|
||||
expect(pB.shouldRepaint(pA), isFalse);
|
||||
});
|
||||
|
||||
test('returns true when old painter had higher revision than new '
|
||||
'(regression: revision comparison is not directional guard)', () {
|
||||
final store = StrokeStore()
|
||||
..add(_stroke('a'))
|
||||
..add(_stroke('b')); // revision == 2
|
||||
final pOld = _painter(store, pageSize);
|
||||
|
||||
store.clear(); // revision == 3
|
||||
final pNew = _painter(store, pageSize);
|
||||
|
||||
// pNew.revision (3) != pOld.revision (2) → should repaint.
|
||||
expect(pNew.shouldRepaint(pOld), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
246
test/editor_repository_test.dart
Normal file
246
test/editor_repository_test.dart
Normal file
@@ -0,0 +1,246 @@
|
||||
// test/editor_repository_test.dart
|
||||
//
|
||||
// Tests for EditorRepository (MF3 diff-write contract + round-trip).
|
||||
//
|
||||
// Run via:
|
||||
// bash tool/test.sh test/editor_repository_test.dart
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqflite_common/sqlite_api.dart';
|
||||
import 'package:sqflite_common/utils/utils.dart' as sqflite_utils;
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||
import 'package:badnote/editor/persistence/editor_repository.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Open an in-memory sqflite database with the ink + notebook_pages schema.
|
||||
Future<Database> _openTestDb() async {
|
||||
sqfliteFfiInit();
|
||||
final factory = databaseFactoryFfi;
|
||||
|
||||
// Use a temp-file DB so the test is isolated but still exercises real I/O.
|
||||
final dir = await Directory.systemTemp.createTemp('editor_repo_test_');
|
||||
final path = p.join(dir.path, 'test.db');
|
||||
|
||||
return factory.openDatabase(
|
||||
path,
|
||||
options: OpenDatabaseOptions(
|
||||
version: 1,
|
||||
onCreate: (db, version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE ink (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_kind TEXT NOT NULL,
|
||||
host_id TEXT NOT NULL,
|
||||
stroke_json TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_ink_host ON ink(host_kind, host_id)',
|
||||
);
|
||||
await db.execute('''
|
||||
CREATE TABLE notebook_pages (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
source_page_index INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a minimal [EditorStroke] with a given [id].
|
||||
EditorStroke _stroke(String id) => EditorStroke.create(
|
||||
id: id,
|
||||
points: [
|
||||
const EditorPoint(x: 0.1, y: 0.2),
|
||||
const EditorPoint(x: 0.3, y: 0.4),
|
||||
],
|
||||
);
|
||||
|
||||
/// Build [n] distinct strokes.
|
||||
List<EditorStroke> _strokes(int n) =>
|
||||
List.generate(n, (i) => _stroke('stroke-$i'));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
group('EditorRepository', () {
|
||||
late Database db;
|
||||
late EditorRepository repo;
|
||||
|
||||
setUp(() async {
|
||||
db = await _openTestDb();
|
||||
repo = EditorRepository(db);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
// ── Round-trip ─────────────────────────────────────────────────────
|
||||
|
||||
test('round-trip: saveHost then loadDocument returns same strokes', () async {
|
||||
const docId = 'doc-rt';
|
||||
final hostId = EditorRepository.pageHostId(docId, 0);
|
||||
final original = _strokes(5);
|
||||
|
||||
await repo.saveHost('page', hostId, original);
|
||||
|
||||
final loaded = await repo.loadDocument(docId);
|
||||
|
||||
expect(loaded.containsKey(hostId), isTrue);
|
||||
final returned = loaded[hostId]!;
|
||||
expect(returned.length, equals(original.length));
|
||||
for (var i = 0; i < original.length; i++) {
|
||||
expect(returned[i].id, equals(original[i].id));
|
||||
expect(returned[i].points.length, equals(original[i].points.length));
|
||||
expect(returned[i].color, equals(original[i].color));
|
||||
expect(returned[i].width, equals(original[i].width));
|
||||
}
|
||||
});
|
||||
|
||||
// ── 2000-stroke seed + 1-stroke delete ────────────────────────────
|
||||
|
||||
test(
|
||||
'seed 2000 strokes, delete 1: second save issues exactly 1 DELETE and 0 INSERTs',
|
||||
() async {
|
||||
const docId = 'doc-2000';
|
||||
final hostId = EditorRepository.pageHostId(docId, 0);
|
||||
final all = _strokes(2000);
|
||||
|
||||
// First save: all 2000 strokes inserted (not under test here).
|
||||
await repo.saveHost('page', hostId, all);
|
||||
|
||||
// Verify row count is 2000.
|
||||
final countBefore = sqflite_utils.firstIntValue(
|
||||
await db.rawQuery(
|
||||
'SELECT COUNT(*) FROM ink WHERE host_id = ?',
|
||||
[hostId],
|
||||
),
|
||||
)!;
|
||||
expect(countBefore, equals(2000));
|
||||
|
||||
// Record which ids existed before the deletion.
|
||||
final idsBefore = (await db.query(
|
||||
'ink',
|
||||
columns: ['id'],
|
||||
where: 'host_id = ?',
|
||||
whereArgs: [hostId],
|
||||
))
|
||||
.map((r) => r['id'] as String)
|
||||
.toSet();
|
||||
|
||||
// Remove stroke at index 500 (arbitrary) — simulate 1 erasure.
|
||||
final strokeToRemove = all[500];
|
||||
final reduced = List<EditorStroke>.from(all)..removeAt(500);
|
||||
|
||||
// Second save: diff should produce exactly 1 DELETE, 0 INSERTs.
|
||||
await repo.saveHost('page', hostId, reduced);
|
||||
|
||||
final countAfter = sqflite_utils.firstIntValue(
|
||||
await db.rawQuery(
|
||||
'SELECT COUNT(*) FROM ink WHERE host_id = ?',
|
||||
[hostId],
|
||||
),
|
||||
)!;
|
||||
|
||||
// Row count must drop by exactly 1.
|
||||
expect(countAfter, equals(1999));
|
||||
|
||||
// The removed stroke must no longer exist.
|
||||
final removedRows = await db.query(
|
||||
'ink',
|
||||
where: 'id = ?',
|
||||
whereArgs: [strokeToRemove.id],
|
||||
);
|
||||
expect(removedRows, isEmpty);
|
||||
|
||||
// All 1999 surviving ids must be unchanged.
|
||||
final idsAfter = (await db.query(
|
||||
'ink',
|
||||
columns: ['id'],
|
||||
where: 'host_id = ?',
|
||||
whereArgs: [hostId],
|
||||
))
|
||||
.map((r) => r['id'] as String)
|
||||
.toSet();
|
||||
|
||||
final expectedSurvivors = Set<String>.from(idsBefore)
|
||||
..remove(strokeToRemove.id);
|
||||
expect(idsAfter, equals(expectedSurvivors));
|
||||
|
||||
// No new ids were created (zero INSERTs for the second save).
|
||||
final newIds = idsAfter.difference(idsBefore);
|
||||
expect(newIds, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Multiple hosts in same document ───────────────────────────────
|
||||
|
||||
test('loadDocument returns strokes for multiple pages', () async {
|
||||
const docId = 'doc-multi';
|
||||
final host0 = EditorRepository.pageHostId(docId, 0);
|
||||
final host1 = EditorRepository.pageHostId(docId, 1);
|
||||
|
||||
final strokes0 = _strokes(3);
|
||||
final strokes1 = _strokes(4).map((s) => _stroke('pg1-${s.id}')).toList();
|
||||
|
||||
await repo.saveHost('page', host0, strokes0);
|
||||
await repo.saveHost('page', host1, strokes1);
|
||||
|
||||
final loaded = await repo.loadDocument(docId);
|
||||
expect(loaded[host0]!.length, equals(3));
|
||||
expect(loaded[host1]!.length, equals(4));
|
||||
});
|
||||
|
||||
// ── Idempotency ────────────────────────────────────────────────────
|
||||
|
||||
test('saving the same strokes twice is a no-op (0 DB mutations)', () async {
|
||||
const docId = 'doc-idem';
|
||||
final hostId = EditorRepository.pageHostId(docId, 0);
|
||||
final strokes = _strokes(10);
|
||||
|
||||
await repo.saveHost('page', hostId, strokes);
|
||||
|
||||
final countBefore = sqflite_utils.firstIntValue(
|
||||
await db.rawQuery(
|
||||
'SELECT COUNT(*) FROM ink WHERE host_id = ?',
|
||||
[hostId],
|
||||
),
|
||||
)!;
|
||||
|
||||
// Second save with identical strokes: should be a no-op.
|
||||
await repo.saveHost('page', hostId, strokes);
|
||||
|
||||
final countAfter = sqflite_utils.firstIntValue(
|
||||
await db.rawQuery(
|
||||
'SELECT COUNT(*) FROM ink WHERE host_id = ?',
|
||||
[hostId],
|
||||
),
|
||||
)!;
|
||||
|
||||
expect(countAfter, equals(countBefore));
|
||||
});
|
||||
});
|
||||
}
|
||||
180
test/editor_stroke_model_test.dart
Normal file
180
test/editor_stroke_model_test.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
// test/editor_stroke_model_test.dart
|
||||
//
|
||||
// Pure unit tests for the engine foundation (NO DB, NO widgets):
|
||||
// (a) EditorStroke -> json -> EditorStroke round-trips losslessly,
|
||||
// including tilt/timestamp/pointerDeviceKind.
|
||||
// (b) EditorStroke <-> InkStroke round-trips losslessly.
|
||||
// (c) buildStrokeOutline returns a non-empty Path for a >=2-point stroke and
|
||||
// an empty Path for 0 points.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:badnote/editor/engine/stroke_geometry.dart';
|
||||
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||
import 'package:badnote/models/ink_point.dart';
|
||||
import 'package:badnote/models/ink_stroke.dart';
|
||||
import 'package:badnote/models/pen_tool.dart';
|
||||
import 'package:badnote/models/pointer_device_kind.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('EditorStroke JSON round-trip', () {
|
||||
test('round-trips losslessly incl. tilt/timestamp/pointerDeviceKind', () {
|
||||
final stroke = EditorStroke(
|
||||
id: 'stroke-1',
|
||||
points: const [
|
||||
EditorPoint(
|
||||
x: 0.1,
|
||||
y: 0.2,
|
||||
pressure: 0.75,
|
||||
tilt: 0.33,
|
||||
timestamp: 1234567,
|
||||
pointerDeviceKind: InputDeviceKind.stylus,
|
||||
),
|
||||
EditorPoint(
|
||||
x: 0.4,
|
||||
y: 0.5,
|
||||
pressure: 0.5,
|
||||
tilt: 0.0,
|
||||
timestamp: 1234600,
|
||||
pointerDeviceKind: InputDeviceKind.invertedStylus,
|
||||
),
|
||||
],
|
||||
tool: EditorTool.highlighter,
|
||||
color: 0xFFAABBCC,
|
||||
width: 0.0123,
|
||||
filled: true,
|
||||
textContent: 'hello',
|
||||
fontSize: 18.0,
|
||||
);
|
||||
|
||||
// Persisted as a JSON string (DB TEXT column); jsonEncode invokes nested
|
||||
// toJson, jsonDecode rebuilds the maps.
|
||||
final decoded = EditorStroke.fromJson(
|
||||
jsonDecode(jsonEncode(stroke.toJson())) as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
expect(decoded, stroke);
|
||||
// Spot-check the superset fields explicitly.
|
||||
expect(decoded.points.first.tilt, 0.33);
|
||||
expect(decoded.points.first.timestamp, 1234567);
|
||||
expect(
|
||||
decoded.points.first.pointerDeviceKind,
|
||||
InputDeviceKind.stylus,
|
||||
);
|
||||
expect(
|
||||
decoded.points.last.pointerDeviceKind,
|
||||
InputDeviceKind.invertedStylus,
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves null superset fields', () {
|
||||
final stroke = EditorStroke(
|
||||
id: 'stroke-null',
|
||||
points: const [
|
||||
EditorPoint(x: 0.0, y: 0.0),
|
||||
EditorPoint(x: 1.0, y: 1.0, pressure: 0.9),
|
||||
],
|
||||
);
|
||||
|
||||
final decoded = EditorStroke.fromJson(
|
||||
jsonDecode(jsonEncode(stroke.toJson())) as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
expect(decoded, stroke);
|
||||
expect(decoded.points.first.pressure, isNull);
|
||||
expect(decoded.points.first.tilt, isNull);
|
||||
expect(decoded.points.first.timestamp, isNull);
|
||||
expect(decoded.points.first.pointerDeviceKind, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('EditorStroke <-> InkStroke round-trip', () {
|
||||
test('InkStroke -> EditorStroke -> InkStroke is lossless', () {
|
||||
final ink = InkStroke(
|
||||
id: 'ink-1',
|
||||
points: const [
|
||||
InkPoint(
|
||||
x: 0.2,
|
||||
y: 0.3,
|
||||
pressure: 0.8,
|
||||
tilt: 0.1,
|
||||
timestamp: 999,
|
||||
pointerDeviceKind: InputDeviceKind.stylus,
|
||||
),
|
||||
InkPoint(
|
||||
x: 0.6,
|
||||
y: 0.7,
|
||||
pressure: 0.4,
|
||||
tilt: 0.2,
|
||||
timestamp: 1050,
|
||||
pointerDeviceKind: InputDeviceKind.touch,
|
||||
),
|
||||
],
|
||||
tool: PenTool.highlighter,
|
||||
color: 0xFF112233,
|
||||
strokeWidth: 0.02,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(42),
|
||||
filled: true,
|
||||
textContent: 'note',
|
||||
fontSize: 22.0,
|
||||
);
|
||||
|
||||
final editor = EditorStroke.fromInkStroke(ink);
|
||||
final back = editor.toInkStroke(createdAt: ink.createdAt);
|
||||
|
||||
expect(back, ink);
|
||||
});
|
||||
|
||||
test('EditorStroke (non-null fields) -> InkStroke -> EditorStroke', () {
|
||||
final editor = EditorStroke(
|
||||
id: 'ink-2',
|
||||
points: const [
|
||||
EditorPoint(
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
pressure: 0.5,
|
||||
tilt: 0.0,
|
||||
timestamp: 7,
|
||||
pointerDeviceKind: InputDeviceKind.mouse,
|
||||
),
|
||||
],
|
||||
tool: EditorTool.eraser,
|
||||
color: 0xFF000000,
|
||||
width: 0.004,
|
||||
);
|
||||
|
||||
final ink = editor.toInkStroke();
|
||||
final back = EditorStroke.fromInkStroke(ink);
|
||||
|
||||
expect(back, editor);
|
||||
expect(ink.tool, PenTool.eraser);
|
||||
});
|
||||
});
|
||||
|
||||
group('buildStrokeOutline', () {
|
||||
const pageSize = Size(800, 600);
|
||||
|
||||
test('returns a non-empty Path for a >=2-point stroke', () {
|
||||
final stroke = EditorStroke(
|
||||
id: 's',
|
||||
points: const [
|
||||
EditorPoint(x: 0.1, y: 0.1, pressure: 0.6),
|
||||
EditorPoint(x: 0.5, y: 0.4, pressure: 0.6),
|
||||
EditorPoint(x: 0.8, y: 0.9, pressure: 0.6),
|
||||
],
|
||||
width: 0.01,
|
||||
);
|
||||
|
||||
final path = buildStrokeOutline(stroke, pageSize, isComplete: true);
|
||||
expect(path.getBounds().isEmpty, isFalse);
|
||||
});
|
||||
|
||||
test('returns an empty Path for 0 points', () {
|
||||
final stroke = EditorStroke(id: 'empty', points: const []);
|
||||
final path = buildStrokeOutline(stroke, pageSize, isComplete: true);
|
||||
expect(path.getBounds().isEmpty, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user