// 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 createState() => SpikeEditorPaneState(); } class SpikeEditorPaneState extends State { late final PdfViewerController _controller = widget.controller ?? PdfViewerController(); /// Live pen strokes captured via PenCaptureRegion (viewer-level overlay). final List> _penStrokes = >[]; 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>? _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 setInkLoad(bool enabled) async { if (enabled && _syntheticStrokes == null) { await _loadSyntheticStrokes(); } if (mounted) setState(() => _inkLoadEnabled = enabled); } Future _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; final strokesJson = (decoded[widget.strokeCountKey] as List? ?? const []); final result = >[]; for (final s in strokesJson) { final points = (s as Map)['points'] as List; final poly = []; for (final p in points) { final pt = p as Map; 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> _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 = >[]; 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>? 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> 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; }