diff --git a/lib/editor/engine/stroke_geometry.dart b/lib/editor/engine/stroke_geometry.dart new file mode 100644 index 0000000..bc1b0ad --- /dev/null +++ b/lib/editor/engine/stroke_geometry.dart @@ -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; +} diff --git a/lib/editor/engine/stroke_model.dart b/lib/editor/engine/stroke_model.dart new file mode 100644 index 0000000..3ffb722 --- /dev/null +++ b/lib/editor/engine/stroke_model.dart @@ -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 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 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 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 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, + }; +} diff --git a/lib/editor/engine/stroke_model.freezed.dart b/lib/editor/engine/stroke_model.freezed.dart new file mode 100644 index 0000000..1a3e0d9 --- /dev/null +++ b/lib/editor/engine/stroke_model.freezed.dart @@ -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 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 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 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 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 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 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 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 json) { + return _EditorStroke.fromJson(json); +} + +/// @nodoc +mixin _$EditorStroke { + String get id => throw _privateConstructorUsedError; + List 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 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 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 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, + 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 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, + 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 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 json) => + _$$EditorStrokeImplFromJson(json); + + @override + final String id; + final List _points; + @override + List 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 toJson() { + return _$$EditorStrokeImplToJson(this); + } +} + +abstract class _EditorStroke extends EditorStroke { + factory _EditorStroke({ + required final String id, + required final List 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 json) = + _$EditorStrokeImpl.fromJson; + + @override + String get id; + @override + List 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; +} diff --git a/lib/editor/engine/stroke_model.g.dart b/lib/editor/engine/stroke_model.g.dart new file mode 100644 index 0000000..bcdec19 --- /dev/null +++ b/lib/editor/engine/stroke_model.g.dart @@ -0,0 +1,73 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'stroke_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$EditorPointImpl _$$EditorPointImplFromJson(Map 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 _$$EditorPointImplToJson(_$EditorPointImpl instance) => + { + '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 json) => + _$EditorStrokeImpl( + id: json['id'] as String, + points: (json['points'] as List) + .map((e) => EditorPoint.fromJson(e as Map)) + .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 _$$EditorStrokeImplToJson(_$EditorStrokeImpl instance) => + { + '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', +}; diff --git a/lib/editor/engine/stroke_store.dart b/lib/editor/engine/stroke_store.dart new file mode 100644 index 0000000..fb0ec65 --- /dev/null +++ b/lib/editor/engine/stroke_store.dart @@ -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 _strokes = []; + int _revision = 0; + + /// Monotonically increasing counter. Bumped on every mutation. + int get revision => _revision; + + /// Unmodifiable ordered list of committed strokes. + List 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 strokes) { + _strokes + ..clear() + ..addAll(strokes); + _revision++; + } + + /// Clears all strokes and bumps the revision. + void clear() { + _strokes.clear(); + _revision++; + } +} diff --git a/lib/editor/persistence/editor_repository.dart b/lib/editor/persistence/editor_repository.dart new file mode 100644 index 0000000..9c69236 --- /dev/null +++ b/lib/editor/persistence/editor_repository.dart @@ -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:"` 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> _persistedIds = {}; + + // ── Factory ──────────────────────────────────────────────────────────── + + /// Convenience constructor that initialises from [DatabaseService]. + static Future 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:"`. + Future>> 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::page:" + 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 = >{}; + + for (final row in rows) { + final hostId = row['host_id'] as String; + final strokeJson = + jsonDecode(row['stroke_json'] as String) as Map; + 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 saveHost( + String hostKind, + String hostId, + List 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.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'; +} diff --git a/lib/editor/persistence/save_scheduler.dart b/lib/editor/persistence/save_scheduler.dart new file mode 100644 index 0000000..efbac67 --- /dev/null +++ b/lib/editor/persistence/save_scheduler.dart @@ -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 _timers = {}; + final Map _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 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 flush() async { + final hosts = List.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 _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 strokes; +} diff --git a/lib/editor/render/annotation_layer.dart b/lib/editor/render/annotation_layer.dart new file mode 100644 index 0000000..bfaba89 --- /dev/null +++ b/lib/editor/render/annotation_layer.dart @@ -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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/editor/render/ink_picture_cache.dart b/lib/editor/render/ink_picture_cache.dart new file mode 100644 index 0000000..50f76cf --- /dev/null +++ b/lib/editor/render/ink_picture_cache.dart @@ -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 _cache = + LinkedHashMap(); + + /// 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.from(_cache.values); + _cache.clear(); + for (final pic in pictures) { + _disposeDeferred(pic); + } + } + + static void _disposeDeferred(ui.Picture picture) { + WidgetsBinding.instance.addPostFrameCallback((_) { + picture.dispose(); + }); + } +} diff --git a/lib/editor/render/live_ink_painter.dart b/lib/editor/render/live_ink_painter.dart new file mode 100644 index 0000000..ed6a2a1 --- /dev/null +++ b/lib/editor/render/live_ink_painter.dart @@ -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; +} diff --git a/lib/editor/render/static_ink_painter.dart b/lib/editor/render/static_ink_painter.dart new file mode 100644 index 0000000..30d17ff --- /dev/null +++ b/lib/editor/render/static_ink_painter.dart @@ -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; +} diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart index cd43624..8557ee6 100644 --- a/lib/services/database_service.dart +++ b/lib/services/database_service.dart @@ -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 _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 _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 _migrateV2toV3(Database db) async { diff --git a/lib/services/pdf_service.dart b/lib/services/pdf_service.dart index c9e58f3..cc6b615 100644 --- a/lib/services/pdf_service.dart +++ b/lib/services/pdf_service.dart @@ -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 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; + } } diff --git a/test/editor_render_test.dart b/test/editor_render_test.dart new file mode 100644 index 0000000..6b61204 --- /dev/null +++ b/test/editor_render_test.dart @@ -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); + }); + }); +} diff --git a/test/editor_repository_test.dart b/test/editor_repository_test.dart new file mode 100644 index 0000000..3a96429 --- /dev/null +++ b/test/editor_repository_test.dart @@ -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 _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 _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.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.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)); + }); + }); +} diff --git a/test/editor_stroke_model_test.dart b/test/editor_stroke_model_test.dart new file mode 100644 index 0000000..7172694 --- /dev/null +++ b/test/editor_stroke_model_test.dart @@ -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, + ); + + 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, + ); + + 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); + }); + }); +}