Files
BadNote/lib/editor/canvas/pen_editor_screen.dart

859 lines
30 KiB
Dart
Raw Normal View History

// lib/editor/canvas/pen_editor_screen.dart
//
// Page-based pen-first PDF editor. Opens a PDF with pdfrx's document API,
// shows ONE page at a time as a bitmap (PdfPageView — a per-page widget that
// renders to an image and does NOT capture pan/zoom gestures), overlaid by the
// ink layer. Both share one transform via PenCanvas. Prev/Next + jump-to-page.
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import '../../services/database_service.dart';
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../engine/stroke_model.dart';
import '../engine/undo_stack.dart';
feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 — WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons were just read from the wrong field. Native now resolves the barrel from BOTH penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON) — many pens use the latter. It also emits the full raw set (pointerFlags, penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single session reveals exactly which field each button sets. Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"): new DiagnosticLogger emits through dart:developer log(name 'badnote.input') — capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file (path shown in the overlay) for the packaged GUI build that has no console. Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log on raw-field change; ZOOM lines log every scale frame + rebaselines. Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch misread). The full per-frame trace (raw scale, pointerCount, applied change, focal jump, drops) is now logged so the residual cause is unambiguous. InputDiagnostics singleton accumulates the stats; the overlay shows summary + last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max. Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:10:17 +08:00
import '../input/diagnostic_logger.dart';
import '../input/pen_config.dart';
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
import '../input/pen_input_service.dart';
import '../persistence/editor_repository.dart';
import '../persistence/save_scheduler.dart';
import '../ui/pen_settings_page.dart';
import '../ui/thumbnail_grid.dart';
feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 — WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons were just read from the wrong field. Native now resolves the barrel from BOTH penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON) — many pens use the latter. It also emits the full raw set (pointerFlags, penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single session reveals exactly which field each button sets. Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"): new DiagnosticLogger emits through dart:developer log(name 'badnote.input') — capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file (path shown in the overlay) for the packaged GUI build that has no console. Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log on raw-field change; ZOOM lines log every scale frame + rebaselines. Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch misread). The full per-frame trace (raw scale, pointerCount, applied change, focal jump, drops) is now logged so the residual cause is unambiguous. InputDiagnostics singleton accumulates the stats; the overlay shows summary + last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max. Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:10:17 +08:00
import 'input_diagnostics.dart';
import 'pen_canvas.dart';
import 'pen_stroke.dart';
/// Stable deterministic document-id for a file path (djb2 hash → hex).
///
/// Produces a fixed-length hex string from the path so the id is filesystem-
/// independent (no slashes, spaces, or non-ASCII characters) and stable across
/// restarts. Collisions are astronomically unlikely for a single-user app.
String _documentIdFromPath(String path) {
var hash = 5381;
for (final c in path.codeUnits) {
hash = ((hash << 5) + hash + c) & 0xFFFFFFFF;
}
return hash.toRadixString(16).padLeft(8, '0');
}
class PenEditorScreen extends StatefulWidget {
const PenEditorScreen({super.key, required this.pdfPath});
final String pdfPath;
@override
State<PenEditorScreen> createState() => _PenEditorScreenState();
}
class _PenEditorScreenState extends State<PenEditorScreen> {
PdfDocument? _document;
Object? _openError;
/// 0-based current page index.
int _pageIndex = 0;
/// Strokes per page, keyed by 0-based page index (normalized coords).
final Map<int, List<PenStroke>> _strokesByPage = {};
/// Per-page undo/redo history. Snapshot-before-change discipline: the
/// pre-mutation stroke list is recorded before each commit/erase.
final Map<int, UndoStack<List<PenStroke>>> _undo = {};
UndoStack<List<PenStroke>> _undoFor(int page) =>
_undo.putIfAbsent(page, () => UndoStack<List<PenStroke>>());
/// Pen input configuration (widths, finger drawing, button actions).
/// Loaded asynchronously in initState; null until ready.
///
/// NOTE: the side-button / eraser-end ACTION MAPPINGS (sideButton/eraserEnd)
/// are persisted via this controller but NOT yet consumed here — they wire
/// into the input arbiter in a later step. Only widths and fingerDrawing are
/// consumed for now.
PenConfigController? _penConfig;
// ── Persistence ────────────────────────────────────────────────────────────
/// Stable document-id derived from the PDF file path.
late final String _documentId;
SaveScheduler? _saveScheduler;
/// One shared transform for the current page; recentred on page change so
/// each page opens fit-to-view and centered.
final TransformationController _transform = TransformationController();
/// Set when the page must be (re)centered on the next layout pass.
bool _needsCenter = true;
/// Live page value while dragging the page slider (null when not dragging).
double? _scrub;
/// Whether the page-jump slider is expanded (NOT persistent — toggled by
/// tapping the page label; collapses after a jump).
bool _showSlider = false;
/// Latest pen-event debug readout (kind/pressure/min/max) — shown only when
/// the diagnostic toggle is on, to inspect what Windows delivers.
String _penDebug = '';
bool _showPenDebug = false;
// Tool state.
CanvasTool _tool = CanvasTool.pen;
Color _color = Colors.black;
bool _allowFingerDrawing = false;
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
/// Pen width as a fraction of page width (base; pressure thins it down).
static const double _penWidthFraction = 0.006;
static const double _highlighterWidthFraction = 0.02;
static const List<Color> _palette = [
Colors.black,
Colors.red,
Colors.blue,
Colors.green,
Colors.orange,
];
@override
void initState() {
super.initState();
_documentId = _documentIdFromPath(widget.pdfPath);
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
// Begin listening to the native Windows pen plugin (barrel/eraser/tilt).
// No-op on platforms without the plugin (W3).
PenInputService.instance.start();
_initPersistence();
_initPenConfig();
_open();
}
Future<void> _initPenConfig() async {
final controller = await PenConfigController.load();
if (!mounted) {
controller.dispose();
return;
}
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
// Rebuild the editor when pen settings change (width, pressure
// sensitivity, button mappings) so the live canvas reflects them.
controller.addListener(_onPenConfigChanged);
setState(() {
_penConfig = controller;
// Adopt the persisted finger-drawing preference as the initial local
// toggle state. The local 🖐 toggle keeps working and stays in sync with
// the controller (see _toggleFingerDrawing).
_allowFingerDrawing = controller.value.fingerDrawing;
});
}
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
void _onPenConfigChanged() {
if (mounted) setState(() {});
}
Future<void> _initPersistence() async {
final service = await DatabaseService.getInstance();
if (!mounted) return;
final repo = await EditorRepository.fromService(service);
final scheduler = SaveScheduler(repo);
if (!mounted) {
scheduler.dispose();
return;
}
_saveScheduler = scheduler;
// Load any previously persisted strokes for this document.
await _loadPersistedStrokes(repo);
}
/// Load all persisted strokes for [_documentId] and populate [_strokesByPage].
Future<void> _loadPersistedStrokes(EditorRepository repo) async {
final hosted = await repo.loadDocument(_documentId);
if (!mounted) return;
final loaded = <int, List<PenStroke>>{};
for (final entry in hosted.entries) {
final pageIndex = _pageIndexFromHostId(entry.key);
if (pageIndex == null) continue;
loaded[pageIndex] = entry.value
.map((es) => PenStroke(
points: es.points
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
.map((ep) => PenPoint(ep.x, ep.y, ep.pressure, tilt: ep.tilt))
.toList(),
color: es.color,
width: es.width,
kind: es.tool == EditorTool.highlighter
? PenStrokeKind.highlighter
: PenStrokeKind.pen,
))
.toList();
}
if (loaded.isNotEmpty) {
setState(() {
for (final entry in loaded.entries) {
_strokesByPage[entry.key] = entry.value;
}
});
}
}
/// Extract the page index from a host_id of the form
/// `"doc:<documentId>:page:<pageIndex>"`.
int? _pageIndexFromHostId(String hostId) {
const marker = ':page:';
final idx = hostId.lastIndexOf(marker);
if (idx == -1) return null;
return int.tryParse(hostId.substring(idx + marker.length));
}
Future<void> _open() async {
try {
final doc = await PdfDocument.openFile(widget.pdfPath);
if (!mounted) {
doc.dispose();
return;
}
setState(() => _document = doc);
} catch (e) {
if (mounted) setState(() => _openError = e);
}
}
@override
void dispose() {
// Flush any pending scheduled saves before tearing down.
final scheduler = _saveScheduler;
if (scheduler != null) {
scheduler.flush(); // fire-and-forget; DB write continues in isolate
scheduler.dispose();
}
_document?.dispose();
_transform.dispose();
_penConfig?.dispose();
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
PenInputService.instance.stop();
feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 — WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons were just read from the wrong field. Native now resolves the barrel from BOTH penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON) — many pens use the latter. It also emits the full raw set (pointerFlags, penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single session reveals exactly which field each button sets. Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"): new DiagnosticLogger emits through dart:developer log(name 'badnote.input') — capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file (path shown in the overlay) for the packaged GUI build that has no console. Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log on raw-field change; ZOOM lines log every scale frame + rebaselines. Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch misread). The full per-frame trace (raw scale, pointerCount, applied change, focal jump, drops) is now logged so the residual cause is unambiguous. InputDiagnostics singleton accumulates the stats; the overlay shows summary + last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max. Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:10:17 +08:00
DiagnosticLogger.instance.stop();
super.dispose();
}
List<PenStroke> get _currentStrokes =>
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]);
void _commitStroke(PenStroke stroke) {
// Snapshot-before-change: record the pre-mutation page state for undo.
_undoFor(_pageIndex).record(List<PenStroke>.of(_currentStrokes));
setState(() {
// Replace with a NEW list so StaticInkPainter sees a fresh identity and
// actually repaints (mutating in place would alias the old painter's list
// and shouldRepaint would see no change → committed strokes vanish).
_strokesByPage[_pageIndex] = [
...?_strokesByPage[_pageIndex],
stroke,
];
});
// Snapshot SYNCHRONOUSLY (before any await) then schedule persistence.
final snapshot = List<PenStroke>.of(_strokesByPage[_pageIndex]!);
_schedulePageSave(_pageIndex, snapshot);
}
/// Replace committed stroke [index] with its surviving pieces after a partial
/// (segment) erase. An empty [replacements] list removes the stroke entirely.
void _eraseStroke(int index, List<PenStroke> replacements) {
final list = _strokesByPage[_pageIndex];
final willMutate = list != null && index >= 0 && index < list.length;
if (willMutate) {
// Snapshot-before-change: record the pre-mutation page state for undo.
_undoFor(_pageIndex).record(List<PenStroke>.of(list));
}
setState(() {
if (list != null && index >= 0 && index < list.length) {
final next = List<PenStroke>.of(list)
..replaceRange(index, index + 1, replacements);
_strokesByPage[_pageIndex] = next;
}
});
// Snapshot SYNCHRONOUSLY after the mutation, then schedule persistence.
final current = _strokesByPage[_pageIndex];
final snapshot =
current != null ? List<PenStroke>.of(current) : <PenStroke>[];
_schedulePageSave(_pageIndex, snapshot);
}
/// Convert [strokes] to [EditorStroke]s and hand them to the save scheduler.
///
/// Must be called synchronously (no await between the snapshot and this call)
/// so the scheduler receives an immutable copy of the in-memory state.
void _schedulePageSave(int pageIndex, List<PenStroke> strokes) {
final scheduler = _saveScheduler;
if (scheduler == null) return;
final editorStrokes =
strokes.map((s) => EditorStroke.fromPenStroke(s)).toList();
scheduler.schedule(
'page',
EditorRepository.pageHostId(_documentId, pageIndex),
editorStrokes,
);
}
void _goToPage(int index) {
final doc = _document;
if (doc == null) return;
final clamped = index.clamp(0, doc.pages.length - 1);
if (clamped == _pageIndex) return;
setState(() {
_pageIndex = clamped;
_needsCenter = true; // recenter the new page on next layout
});
}
/// Undo the last draw/erase on the current page, restoring and persisting
/// the previous snapshot.
void _performUndo() {
final stack = _undoFor(_pageIndex);
if (!stack.canUndo) return;
final current = List<PenStroke>.of(_currentStrokes);
final snapshot = stack.undo(current);
if (snapshot == null) return;
setState(() {
// New list identity so StaticInkPainter repaints.
_strokesByPage[_pageIndex] = List<PenStroke>.of(snapshot);
});
_schedulePageSave(_pageIndex, List<PenStroke>.of(snapshot));
}
/// Redo the last undone draw/erase on the current page.
void _performRedo() {
final stack = _undoFor(_pageIndex);
if (!stack.canRedo) return;
final snapshot = stack.redo();
if (snapshot == null) return;
setState(() {
_strokesByPage[_pageIndex] = List<PenStroke>.of(snapshot);
});
_schedulePageSave(_pageIndex, List<PenStroke>.of(snapshot));
}
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
/// Cycle pen → highlighter → eraser → pen (for the toggleTool button action).
void _cycleTool() {
setState(() {
_tool = switch (_tool) {
CanvasTool.pen => CanvasTool.highlighter,
CanvasTool.highlighter => CanvasTool.eraser,
CanvasTool.eraser => CanvasTool.pen,
};
});
}
/// Handle a hardware pen-button action delivered by [PenCanvas] (W3).
/// `eraser` and `pan` are handled inside the canvas; here we map the
/// edge-triggered ones.
void _handlePenButtonAction(PenButtonAction action) {
switch (action) {
case PenButtonAction.undo:
_performUndo();
case PenButtonAction.toggleTool:
_cycleTool();
case PenButtonAction.eraser:
case PenButtonAction.pan:
case PenButtonAction.none:
break;
}
}
/// Toggle finger-drawing, keeping the local state and the persisted config
/// (when loaded) in sync.
void _toggleFingerDrawing() {
final next = !_allowFingerDrawing;
setState(() => _allowFingerDrawing = next);
_penConfig?.setFingerDrawing(next);
}
/// Open the page thumbnail grid; tapping a thumbnail navigates to that page.
void _openThumbnails() {
final doc = _document;
if (doc == null) return;
showPageThumbnailSheet(
context,
document: doc,
currentPage: _pageIndex,
onPageSelected: _goToPage,
);
}
/// Open the pen settings sheet (widths, pressure, finger drawing, etc.).
void _openPenSettings() {
final config = _penConfig;
if (config == null) return;
showPenSettingsSheet(context, config);
}
/// Centre [pageSize] within [viewport] via the shared transform.
void _centerPage(Size viewport, Size pageSize) {
final tx = (viewport.width - pageSize.width) / 2;
final ty = (viewport.height - pageSize.height) / 2;
_transform.value = Matrix4.identity()..setTranslationRaw(tx, ty, 0);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
Positioned.fill(child: _buildBody()),
// Floating Material You tool palette (top-center).
SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: _buildToolPalette(),
),
),
),
// Floating page-control pill (bottom-center).
if (_document != null)
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _buildPagePill(),
),
),
),
// Back button (top-left).
SafeArea(
child: Padding(
padding: const EdgeInsets.all(8),
child: _RoundIconButton(
icon: Icons.arrow_back,
tooltip: 'Back',
onPressed: () => Navigator.of(context).maybePop(),
),
),
),
// Pen diagnostic readout (top-right) — shows what Windows delivers.
if (_showPenDebug)
SafeArea(
child: Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8),
child: Material(
color: Theme.of(context).colorScheme.inverseSurface,
borderRadius: BorderRadius.circular(8),
feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 — WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons were just read from the wrong field. Native now resolves the barrel from BOTH penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON) — many pens use the latter. It also emits the full raw set (pointerFlags, penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single session reveals exactly which field each button sets. Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"): new DiagnosticLogger emits through dart:developer log(name 'badnote.input') — capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file (path shown in the overlay) for the packaged GUI build that has no console. Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log on raw-field change; ZOOM lines log every scale frame + rebaselines. Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch misread). The full per-frame trace (raw scale, pointerCount, applied change, focal jump, drops) is now logged so the residual cause is unambiguous. InputDiagnostics singleton accumulates the stats; the overlay shows summary + last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max. Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:10:17 +08:00
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
child: ListenableBuilder(
listenable: InputDiagnostics.instance,
builder: (context, _) {
final cs = Theme.of(context).colorScheme;
final d = InputDiagnostics.instance;
final tail = d.trace.length > 6
? d.trace.sublist(d.trace.length - 6)
: d.trace;
final mono = TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: cs.onInverseSurface);
final monoFaint = mono.copyWith(
fontSize: 10,
color:
cs.onInverseSurface.withValues(alpha: 0.75));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_penDebug.isEmpty
? 'hover / draw with the pen…'
: _penDebug,
style: mono),
const SizedBox(height: 4),
Text(d.summary(), style: mono),
if (tail.isNotEmpty) ...[
const SizedBox(height: 4),
Text(tail.join('\n'), style: monoFaint),
],
const SizedBox(height: 4),
Text(
'log: ${DiagnosticLogger.instance.path ?? "(developer.log only)"}',
style: monoFaint),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () =>
InputDiagnostics.instance.reset(),
child: Text('Reset stats',
style:
TextStyle(color: cs.inversePrimary)),
),
),
],
);
},
),
),
),
),
),
),
),
],
),
);
}
Widget _buildBody() {
if (_openError != null) {
return Center(child: Text('Failed to open PDF:\n$_openError'));
}
final doc = _document;
if (doc == null) {
return const Center(child: CircularProgressIndicator());
}
if (doc.pages.isEmpty) {
return const Center(child: Text('PDF has no pages.'));
}
final page = doc.pages[_pageIndex];
return LayoutBuilder(
builder: (context, constraints) {
// Fit the page rectangle into the available viewport at scale 1.0; the
// InteractiveViewer then zooms/pans from there. Ink normalized coords
// map onto this rectangle.
final fit = (constraints.maxWidth / page.width)
.clamp(0.0, double.infinity);
final fitH = constraints.maxHeight / page.height;
final scale = fit < fitH ? fit : fitH;
final pageSize = Size(page.width * scale, page.height * scale);
if (_needsCenter) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_centerPage(
Size(constraints.maxWidth, constraints.maxHeight), pageSize);
setState(() => _needsCenter = false);
});
}
return PenCanvas(
key: ValueKey(_pageIndex),
pageSize: pageSize,
strokes: _currentStrokes,
transformationController: _transform,
tool: _tool,
color: _color,
strokeWidth: _tool == CanvasTool.highlighter
? (_penConfig?.value.highlighterWidth ??
_highlighterWidthFraction)
: (_penConfig?.value.penWidth ?? _penWidthFraction),
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
thinning:
_penConfig?.value.pressureSensitivity ?? kDefaultPenThinning,
sideButtonAction:
_penConfig?.value.sideButton ?? PenButtonAction.eraser,
eraserEndAction:
_penConfig?.value.eraserEnd ?? PenButtonAction.eraser,
onPenButtonAction: _handlePenButtonAction,
allowFingerDrawing: _allowFingerDrawing,
onPenDebug: _showPenDebug
? (s) => setState(() => _penDebug = s)
: null,
onStrokeComplete: _commitStroke,
onEraseStroke: _eraseStroke,
pageWidget: PdfPageView(
document: doc,
pageNumber: _pageIndex + 1,
// Fill the SizedBox exactly so ink aligns to the page rect (no
// internal letterboxing offset).
pageSizeCallback: (biggest, page, rotation) => biggest,
decoration: const BoxDecoration(color: Colors.white),
backgroundColor: Colors.white,
),
);
},
);
}
/// Floating Material You tool palette: a tonal rounded surface holding the
/// tools, color dots, and finger-drawing toggle.
Widget _buildToolPalette() {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ToolButton(
icon: Icons.edit_outlined,
selected: _tool == CanvasTool.pen,
tooltip: 'Pen',
onPressed: () => setState(() => _tool = CanvasTool.pen),
),
_ToolButton(
icon: Icons.brush_outlined,
selected: _tool == CanvasTool.highlighter,
tooltip: 'Highlighter',
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
),
_ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _tool == CanvasTool.eraser,
tooltip: 'Eraser',
onPressed: () => setState(() => _tool = CanvasTool.eraser),
),
_Divider(cs: cs),
// Undo / redo (per page).
_ToolButton(
icon: Icons.undo,
selected: false,
tooltip: 'Undo',
onPressed: _undoFor(_pageIndex).canUndo ? _performUndo : null,
),
_ToolButton(
icon: Icons.redo,
selected: false,
tooltip: 'Redo',
onPressed: _undoFor(_pageIndex).canRedo ? _performRedo : null,
),
_Divider(cs: cs),
for (final c in _palette) _colorDot(c, cs),
_Divider(cs: cs),
_ToolButton(
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
selected: _allowFingerDrawing,
tooltip: _allowFingerDrawing
? 'Finger drawing ON'
: 'Finger drawing OFF (pen only)',
onPressed: _toggleFingerDrawing,
),
// Page thumbnail grid.
_ToolButton(
icon: Icons.grid_view,
selected: false,
tooltip: 'Pages',
onPressed: _document != null ? _openThumbnails : null,
),
// Pen settings.
_ToolButton(
icon: Icons.settings_outlined,
selected: false,
tooltip: 'Pen settings',
onPressed: _penConfig != null ? _openPenSettings : null,
),
_ToolButton(
icon: Icons.bug_report_outlined,
selected: _showPenDebug,
feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 — WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons were just read from the wrong field. Native now resolves the barrel from BOTH penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON) — many pens use the latter. It also emits the full raw set (pointerFlags, penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single session reveals exactly which field each button sets. Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"): new DiagnosticLogger emits through dart:developer log(name 'badnote.input') — capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file (path shown in the overlay) for the packaged GUI build that has no console. Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log on raw-field change; ZOOM lines log every scale frame + rebaselines. Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch misread). The full per-frame trace (raw scale, pointerCount, applied change, focal jump, drops) is now logged so the residual cause is unambiguous. InputDiagnostics singleton accumulates the stats; the overlay shows summary + last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max. Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:10:17 +08:00
tooltip: 'Input diagnostic (writes a log file)',
onPressed: () {
final on = !_showPenDebug;
setState(() => _showPenDebug = on);
if (on) {
InputDiagnostics.instance.reset();
DiagnosticLogger.instance.start();
} else {
DiagnosticLogger.instance.stop();
feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite boundary, no rotation — that machinery dropped as a no-op here). Two deliberate changes, grounded in the Rnote/Saber research: 1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via `supportedDevices`. The pen never reaches it, so a stylus stroke can no longer be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by stock IV's panEnabled updating a frame after the stroke began). Drawing is owned solely by the canvas Listener; no arena fight, no panEnabled lag. The prior _lastStylus hover hack is removed (superseded). 2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal jitter and guards the pan branch, but a single-frame multi-touch glitch could still spike details.scale, popping the zoom bigger/smaller and snapping back (the reported pinch flicker). Clamping swallows the spike; a real (gradual) pinch is unaffected since scale tracks absolutely from gesture start. Cap is far above any real pinch (~1.1-1.2x/frame), so no felt lag. Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is Flutter's proven logic verbatim. Also add an on-device input diagnostic (bug-report toggle): the existing pen readout already prints kind/pressure/buttons; now it also shows live zoom=now/min/max so the next device test captures (a) whether the side/eraser button arrives as buttons/invertedStylus, and (b) the value any residual pinch flash jumps to. 66/66 tests, analyze clean, linux build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:56 +08:00
}
feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 — WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons were just read from the wrong field. Native now resolves the barrel from BOTH penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON) — many pens use the latter. It also emits the full raw set (pointerFlags, penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single session reveals exactly which field each button sets. Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"): new DiagnosticLogger emits through dart:developer log(name 'badnote.input') — capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file (path shown in the overlay) for the packaged GUI build that has no console. Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log on raw-field change; ZOOM lines log every scale frame + rebaselines. Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch misread). The full per-frame trace (raw scale, pointerCount, applied change, focal jump, drops) is now logged so the residual cause is unambiguous. InputDiagnostics singleton accumulates the stats; the overlay shows summary + last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max. Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:10:17 +08:00
},
),
],
),
),
);
}
Widget _colorDot(Color c, ColorScheme cs) {
final selected = _color == c;
return GestureDetector(
onTap: () => setState(() => _color = c),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 28,
height: 28,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: selected ? cs.primary : cs.outlineVariant,
width: selected ? 3 : 1.5,
),
),
),
);
}
/// Floating page control: a COMPACT pill (prev / "n / total" / next). Tapping
/// the label reveals a drag-slider — which is NOT persistent (collapses again
/// on tap) so it doesn't block the page. No keyboard input (Windows IME is
/// unreliable).
Widget _buildPagePill() {
final doc = _document!;
final cs = Theme.of(context).colorScheme;
final total = doc.pages.length;
final shown = (_scrub ?? (_pageIndex + 1).toDouble()).round();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Slider — shown only when expanded (not persistent).
if (_showSlider && total > 1)
Container(
margin: const EdgeInsets.only(bottom: 8),
constraints: const BoxConstraints(maxWidth: 420),
child: Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Slider(
min: 1,
max: total.toDouble(),
value: (_scrub ?? (_pageIndex + 1).toDouble())
.clamp(1, total.toDouble()),
label: '$shown',
divisions: total - 1,
onChanged: (v) => setState(() => _scrub = v),
onChangeEnd: (v) {
setState(() => _scrub = null);
_goToPage(v.round() - 1);
},
),
),
),
),
// Compact pill — always; fits content (no big frame).
Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Previous page',
icon: const Icon(Icons.chevron_left),
onPressed:
_pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
),
TextButton(
onPressed: total > 1
? () => setState(() => _showSlider = !_showSlider)
: null,
child: Text(
'$shown / $total',
style: TextStyle(
color: cs.onSurface,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: 'Next page',
icon: const Icon(Icons.chevron_right),
onPressed: _pageIndex < total - 1
? () => _goToPage(_pageIndex + 1)
: null,
),
],
),
),
),
],
);
}
}
/// A Material You toggle-style icon button for the tool palette.
class _ToolButton extends StatelessWidget {
const _ToolButton({
required this.icon,
required this.selected,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final bool selected;
final String tooltip;
/// Tap handler. When null the button renders disabled (dimmed, no ripple).
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final enabled = onPressed != null;
final iconColor = !enabled
? cs.onSurfaceVariant.withValues(alpha: 0.38)
: selected
? cs.onSecondaryContainer
: cs.onSurfaceVariant;
return Tooltip(
message: tooltip,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: selected ? cs.secondaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
icon,
size: 22,
color: iconColor,
),
),
),
);
}
}
class _Divider extends StatelessWidget {
const _Divider({required this.cs});
final ColorScheme cs;
@override
Widget build(BuildContext context) => Container(
width: 1,
height: 24,
margin: const EdgeInsets.symmetric(horizontal: 6),
color: cs.outlineVariant,
);
}
/// A round, tonal icon button (used for the floating back button).
class _RoundIconButton extends StatelessWidget {
const _RoundIconButton({
required this.icon,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final String tooltip;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
shape: const CircleBorder(),
child: IconButton(
tooltip: tooltip,
icon: Icon(icon),
color: cs.onSurfaceVariant,
onPressed: onPressed,
),
);
}
}