345 lines
12 KiB
Dart
345 lines
12 KiB
Dart
|
|
// lib/editor/pdf/spike_editor_pane.dart
|
|||
|
|
//
|
|||
|
|
// THROWAWAY M1 spike widget (plan §10 / MUST #2, #4, #5). Hosts a pdfrx
|
|||
|
|
// PdfViewer.file and exercises the three things the M1 gate must prove:
|
|||
|
|
//
|
|||
|
|
// 1. Coordinate correctness (MUST #2): a `pageOverlaysBuilder` paints a
|
|||
|
|
// diagnostic crosshair at normalized (0.5, 0.5) using
|
|||
|
|
// `canvas.scale(size.width, size.height)`, with the CustomPaint sized to
|
|||
|
|
// `pageRect.size` (plan §2.1). This dot MUST sit at the visual page center
|
|||
|
|
// at every zoom level. `coordinate_assertion_test.dart` asserts this.
|
|||
|
|
//
|
|||
|
|
// 2. Pen/touch arbitration (MUST #3): a `viewerOverlayBuilder` wraps a
|
|||
|
|
// `PenCaptureRegion` so pen events draw a live viewer-level stroke while
|
|||
|
|
// touch scrolls and pinch zooms — same overlay, no mode switch.
|
|||
|
|
//
|
|||
|
|
// 3. Ink-overlay build cost (MUST #5): a toggle injects ~N synthetic strokes
|
|||
|
|
// per page (from dense_strokes.json) into the page overlay so the perf
|
|||
|
|
// bench can measure BUILD time with a non-trivial ui.Picture per page.
|
|||
|
|
//
|
|||
|
|
// This file is NOT production code and is excluded from the real editor.
|
|||
|
|
|
|||
|
|
import 'dart:convert';
|
|||
|
|
import 'dart:io';
|
|||
|
|
|
|||
|
|
import 'package:flutter/material.dart';
|
|||
|
|
import 'package:pdfrx/pdfrx.dart';
|
|||
|
|
|
|||
|
|
import 'pen_capture_region.dart';
|
|||
|
|
|
|||
|
|
/// Normalized page-space point the diagnostic marker is painted at. The M1
|
|||
|
|
/// coordinate assertion checks this maps to the page-center pixel at all zooms.
|
|||
|
|
const Offset kMarkerNormalized = Offset(0.5, 0.5);
|
|||
|
|
|
|||
|
|
/// A single captured pen sample in normalized page space, tagged with its page.
|
|||
|
|
class _PenSample {
|
|||
|
|
const _PenSample(this.pageIndex, this.normalized);
|
|||
|
|
final int pageIndex;
|
|||
|
|
final Offset normalized;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Spike editor pane. Provide a [pdfPath] to a local PDF (e.g.
|
|||
|
|
/// test/assets/large_300p.pdf). [denseStrokesAsset] is a filesystem PATH to the
|
|||
|
|
/// synthetic ink load (MUST #5); if null the ink-load toggle is inert.
|
|||
|
|
class SpikeEditorPane extends StatefulWidget {
|
|||
|
|
const SpikeEditorPane({
|
|||
|
|
super.key,
|
|||
|
|
required this.pdfPath,
|
|||
|
|
this.denseStrokesAsset,
|
|||
|
|
this.strokesPerPage = 300,
|
|||
|
|
this.strokeCountKey = '2000',
|
|||
|
|
this.onViewerReady,
|
|||
|
|
this.controller,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
final String pdfPath;
|
|||
|
|
final String? denseStrokesAsset;
|
|||
|
|
final int strokesPerPage;
|
|||
|
|
|
|||
|
|
/// Which top-level array in dense_strokes.json to draw from ("2000"/"5000").
|
|||
|
|
final String strokeCountKey;
|
|||
|
|
|
|||
|
|
/// Forwarded from pdfrx once the document is laid out and interactive.
|
|||
|
|
final void Function(PdfDocument document, PdfViewerController controller)?
|
|||
|
|
onViewerReady;
|
|||
|
|
|
|||
|
|
/// Optional externally-owned controller (tests drive zoom through this).
|
|||
|
|
final PdfViewerController? controller;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
State<SpikeEditorPane> createState() => SpikeEditorPaneState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class SpikeEditorPaneState extends State<SpikeEditorPane> {
|
|||
|
|
late final PdfViewerController _controller =
|
|||
|
|
widget.controller ?? PdfViewerController();
|
|||
|
|
|
|||
|
|
/// Live pen strokes captured via PenCaptureRegion (viewer-level overlay).
|
|||
|
|
final List<List<_PenSample>> _penStrokes = <List<_PenSample>>[];
|
|||
|
|
List<_PenSample>? _activeStroke;
|
|||
|
|
|
|||
|
|
/// Synthetic strokes for the ink-load gate, lazily loaded. Each entry is a
|
|||
|
|
/// list of normalized polylines (one stroke = list of points).
|
|||
|
|
List<List<Offset>>? _syntheticStrokes;
|
|||
|
|
bool _inkLoadEnabled = false;
|
|||
|
|
bool _loadingSynthetic = false;
|
|||
|
|
|
|||
|
|
bool get inkLoadEnabled => _inkLoadEnabled;
|
|||
|
|
|
|||
|
|
/// Toggle the dense synthetic-ink overlay (MUST #5). Loads the asset on first
|
|||
|
|
/// enable. Public so the perf bench can drive it programmatically.
|
|||
|
|
Future<void> setInkLoad(bool enabled) async {
|
|||
|
|
if (enabled && _syntheticStrokes == null) {
|
|||
|
|
await _loadSyntheticStrokes();
|
|||
|
|
}
|
|||
|
|
if (mounted) setState(() => _inkLoadEnabled = enabled);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _loadSyntheticStrokes() async {
|
|||
|
|
final asset = widget.denseStrokesAsset;
|
|||
|
|
if (asset == null || _loadingSynthetic) return;
|
|||
|
|
_loadingSynthetic = true;
|
|||
|
|
try {
|
|||
|
|
// [asset] is a filesystem path (e.g. test/assets/dense_strokes.json),
|
|||
|
|
// not a bundled rootBundle key — regenerate via tool/gen_dense_strokes.dart.
|
|||
|
|
final raw = await File(asset).readAsString();
|
|||
|
|
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
|||
|
|
final strokesJson =
|
|||
|
|
(decoded[widget.strokeCountKey] as List<dynamic>? ?? const []);
|
|||
|
|
final result = <List<Offset>>[];
|
|||
|
|
for (final s in strokesJson) {
|
|||
|
|
final points = (s as Map<String, dynamic>)['points'] as List<dynamic>;
|
|||
|
|
final poly = <Offset>[];
|
|||
|
|
for (final p in points) {
|
|||
|
|
final pt = p as Map<String, dynamic>;
|
|||
|
|
poly.add(Offset(
|
|||
|
|
(pt['x'] as num).toDouble(),
|
|||
|
|
(pt['y'] as num).toDouble(),
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
if (poly.length >= 2) result.add(poly);
|
|||
|
|
}
|
|||
|
|
_syntheticStrokes = result;
|
|||
|
|
} finally {
|
|||
|
|
_loadingSynthetic = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Pen capture (viewer-level) ---------------------------------------
|
|||
|
|
|
|||
|
|
void _onPenEvent(PointerEvent event) {
|
|||
|
|
// Convert global → document → which page + normalized page coords.
|
|||
|
|
final doc = _controller.globalToDocument(event.position);
|
|||
|
|
if (doc == null) return;
|
|||
|
|
final hit = _documentToPage(doc);
|
|||
|
|
if (hit == null) return;
|
|||
|
|
|
|||
|
|
if (event is PointerDownEvent) {
|
|||
|
|
_activeStroke = <_PenSample>[hit];
|
|||
|
|
_penStrokes.add(_activeStroke!);
|
|||
|
|
setState(() {});
|
|||
|
|
} else if (event is PointerMoveEvent) {
|
|||
|
|
_activeStroke?.add(hit);
|
|||
|
|
setState(() {});
|
|||
|
|
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
|
|||
|
|
_activeStroke = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Maps a document-space point to (pageIndex, normalized-in-page) using the
|
|||
|
|
/// controller's page layout rects (document coordinates). Returns null if the
|
|||
|
|
/// point is outside every page box.
|
|||
|
|
_PenSample? _documentToPage(Offset doc) {
|
|||
|
|
if (!_controller.isReady) return null;
|
|||
|
|
final rects = _controller.layout.pageLayouts;
|
|||
|
|
for (var i = 0; i < rects.length; i++) {
|
|||
|
|
final r = rects[i];
|
|||
|
|
if (r.contains(doc)) {
|
|||
|
|
final nx = ((doc.dx - r.left) / r.width).clamp(0.0, 1.0);
|
|||
|
|
final ny = ((doc.dy - r.top) / r.height).clamp(0.0, 1.0);
|
|||
|
|
return _PenSample(i, Offset(nx, ny));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
return Stack(
|
|||
|
|
children: [
|
|||
|
|
PdfViewer.file(
|
|||
|
|
widget.pdfPath,
|
|||
|
|
controller: _controller,
|
|||
|
|
params: PdfViewerParams(
|
|||
|
|
onViewerReady: widget.onViewerReady,
|
|||
|
|
// (1) Per-page overlay: diagnostic center marker + optional synthetic
|
|||
|
|
// ink. CustomPaint is sized to pageRect.size so canvas.scale maps
|
|||
|
|
// normalized [0,1] → zoomed pixels (plan §2.1).
|
|||
|
|
pageOverlaysBuilder: (context, pageRectInViewer, page) {
|
|||
|
|
final pageIndex = page.pageNumber - 1;
|
|||
|
|
return [
|
|||
|
|
SizedBox.fromSize(
|
|||
|
|
size: pageRectInViewer.size,
|
|||
|
|
child: CustomPaint(
|
|||
|
|
painter: _SpikeInkPainter(
|
|||
|
|
synthetic:
|
|||
|
|
_inkLoadEnabled ? _strokesForPage(pageIndex) : null,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
];
|
|||
|
|
},
|
|||
|
|
// (2) Viewer-level overlay: pen capture + live pen rendering. Touch
|
|||
|
|
// falls through to pdfrx for scroll/zoom (per-kind hit-test split).
|
|||
|
|
viewerOverlayBuilder: (context, size, handleLinkTap) {
|
|||
|
|
return [
|
|||
|
|
Positioned.fill(
|
|||
|
|
child: PenCaptureRegion(
|
|||
|
|
onPenEvent: _onPenEvent,
|
|||
|
|
child: IgnorePointer(
|
|||
|
|
child: CustomPaint(
|
|||
|
|
size: size,
|
|||
|
|
painter: _LivePenPainter(
|
|||
|
|
strokes: _penStrokes,
|
|||
|
|
controller: _controller,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
];
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Deterministic per-page slice of the synthetic stroke pool so each page
|
|||
|
|
/// shows ~[widget.strokesPerPage] strokes without loading 300× the data.
|
|||
|
|
List<List<Offset>> _strokesForPage(int pageIndex) {
|
|||
|
|
final pool = _syntheticStrokes;
|
|||
|
|
if (pool == null || pool.isEmpty) return const [];
|
|||
|
|
final n = widget.strokesPerPage.clamp(0, pool.length);
|
|||
|
|
final start = (pageIndex * n) % pool.length;
|
|||
|
|
final out = <List<Offset>>[];
|
|||
|
|
for (var i = 0; i < n; i++) {
|
|||
|
|
out.add(pool[(start + i) % pool.length]);
|
|||
|
|
}
|
|||
|
|
return out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Note: PdfViewerController is not a Listenable/ChangeNotifier we own a
|
|||
|
|
// lifecycle for; pdfrx attaches/detaches it via the PdfViewer. No dispose().
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Paints the diagnostic center marker (always) plus synthetic ink (when the
|
|||
|
|
/// MUST #5 load is enabled), in normalized [0,1] page space scaled to the
|
|||
|
|
/// CustomPaint size (== zoomed page box). This is what the coordinate assertion
|
|||
|
|
/// inspects.
|
|||
|
|
class _SpikeInkPainter extends CustomPainter {
|
|||
|
|
_SpikeInkPainter({this.synthetic});
|
|||
|
|
|
|||
|
|
final List<List<Offset>>? synthetic;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void paint(Canvas canvas, Size size) {
|
|||
|
|
canvas.save();
|
|||
|
|
// Map normalized [0,1] → zoomed pixels (plan §2.1).
|
|||
|
|
canvas.scale(size.width, size.height);
|
|||
|
|
|
|||
|
|
// Synthetic ink load (MUST #5): a non-trivial set of polylines per page.
|
|||
|
|
final syn = synthetic;
|
|||
|
|
if (syn != null && syn.isNotEmpty) {
|
|||
|
|
final inkPaint = Paint()
|
|||
|
|
..color = const Color(0x5500AAFF)
|
|||
|
|
..style = PaintingStyle.stroke
|
|||
|
|
// Stroke width is in normalized units post-scale; keep it page-relative
|
|||
|
|
// and hairline-ish so 300 strokes are visible but cheap.
|
|||
|
|
..strokeWidth = 0.002
|
|||
|
|
..strokeCap = StrokeCap.round;
|
|||
|
|
for (final poly in syn) {
|
|||
|
|
if (poly.length < 2) continue;
|
|||
|
|
final path = Path()..moveTo(poly.first.dx, poly.first.dy);
|
|||
|
|
for (var i = 1; i < poly.length; i++) {
|
|||
|
|
path.lineTo(poly[i].dx, poly[i].dy);
|
|||
|
|
}
|
|||
|
|
canvas.drawPath(path, inkPaint);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
canvas.restore();
|
|||
|
|
|
|||
|
|
// Diagnostic crosshair at normalized (0.5,0.5) — drawn in PIXEL space (after
|
|||
|
|
// restore) so its line thickness is constant on screen and its CENTER is at
|
|||
|
|
// exactly size.width*0.5, size.height*0.5. The coordinate assertion checks
|
|||
|
|
// this pixel.
|
|||
|
|
final center = Offset(
|
|||
|
|
size.width * kMarkerNormalized.dx,
|
|||
|
|
size.height * kMarkerNormalized.dy,
|
|||
|
|
);
|
|||
|
|
final markerPaint = Paint()
|
|||
|
|
..color = const Color(0xFFFF0066)
|
|||
|
|
..strokeWidth = 2.0
|
|||
|
|
..style = PaintingStyle.stroke;
|
|||
|
|
const arm = 16.0;
|
|||
|
|
canvas.drawLine(
|
|||
|
|
center.translate(-arm, 0), center.translate(arm, 0), markerPaint);
|
|||
|
|
canvas.drawLine(
|
|||
|
|
center.translate(0, -arm), center.translate(0, arm), markerPaint);
|
|||
|
|
canvas.drawCircle(center, 3.0, Paint()..color = const Color(0xFFFF0066));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
bool shouldRepaint(covariant _SpikeInkPainter oldDelegate) =>
|
|||
|
|
oldDelegate.synthetic != synthetic;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Paints live pen strokes captured by the PenCaptureRegion. Strokes are stored
|
|||
|
|
/// in normalized page space, so for each sample we re-project page→document→
|
|||
|
|
/// local each paint via the controller (keeps strokes glued to pages under
|
|||
|
|
/// scroll/zoom — the §2.1 property, exercised at the viewer level here).
|
|||
|
|
class _LivePenPainter extends CustomPainter {
|
|||
|
|
_LivePenPainter({required this.strokes, required this.controller})
|
|||
|
|
: super(repaint: controller);
|
|||
|
|
|
|||
|
|
final List<List<_PenSample>> strokes;
|
|||
|
|
final PdfViewerController controller;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void paint(Canvas canvas, Size size) {
|
|||
|
|
if (!controller.isReady) return;
|
|||
|
|
final rects = controller.layout.pageLayouts;
|
|||
|
|
final paint = Paint()
|
|||
|
|
..color = const Color(0xFF1565C0)
|
|||
|
|
..style = PaintingStyle.stroke
|
|||
|
|
..strokeWidth = 3.0
|
|||
|
|
..strokeCap = StrokeCap.round
|
|||
|
|
..strokeJoin = StrokeJoin.round;
|
|||
|
|
|
|||
|
|
for (final stroke in strokes) {
|
|||
|
|
Path? path;
|
|||
|
|
for (final s in stroke) {
|
|||
|
|
if (s.pageIndex >= rects.length) continue;
|
|||
|
|
final r = rects[s.pageIndex];
|
|||
|
|
// normalized page → document
|
|||
|
|
final docPt = Offset(
|
|||
|
|
r.left + s.normalized.dx * r.width,
|
|||
|
|
r.top + s.normalized.dy * r.height,
|
|||
|
|
);
|
|||
|
|
// document → local (viewer) coords
|
|||
|
|
final local = controller.documentToLocal(docPt);
|
|||
|
|
if (path == null) {
|
|||
|
|
path = Path()..moveTo(local.dx, local.dy);
|
|||
|
|
} else {
|
|||
|
|
path.lineTo(local.dx, local.dy);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (path != null) canvas.drawPath(path, paint);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
bool shouldRepaint(covariant _LivePenPainter oldDelegate) => true;
|
|||
|
|
}
|