2026-06-21 22:04:49 +08:00
|
|
|
|
// lib/editor/canvas/pen_editor_screen.dart
|
|
|
|
|
|
//
|
2026-06-24 02:32:41 +08:00
|
|
|
|
// Pen-first PDF editor built on pdfrx's REAL vector PdfViewer (continuous
|
|
|
|
|
|
// scroll, native pan/zoom, selectable text), NOT the old single-page bitmap
|
|
|
|
|
|
// (PdfPageView). Three glued-to-page layers ride on top of the viewer:
|
|
|
|
|
|
//
|
|
|
|
|
|
// 1. Per-page ink overlay (pageOverlaysBuilder): committed strokes + the
|
|
|
|
|
|
// live in-progress stroke + stored text highlights, all in NORMALIZED
|
|
|
|
|
|
// page coords so they stay pinned under scroll/zoom.
|
|
|
|
|
|
// 2. A viewer-level PenCaptureRegion (viewerOverlayBuilder) that captures
|
|
|
|
|
|
// stylus events ONLY when a pen tool is active; touch/mouse fall through
|
|
|
|
|
|
// to pdfrx for scroll/zoom/text-selection. Pen samples are mapped
|
|
|
|
|
|
// global → document → (pageIndex, normalized) via the controller.
|
|
|
|
|
|
// 3. The PdfViewer itself owns text selection; a "select text" tool disables
|
|
|
|
|
|
// pen capture so the pen drives pdfrx text selection, and a
|
|
|
|
|
|
// "highlight selection" action turns the current selection into stored
|
|
|
|
|
|
// normalized highlight rects.
|
|
|
|
|
|
//
|
2026-06-24 21:03:28 +08:00
|
|
|
|
// Persistence (Phase 2): strokes, highlights and scratch-links all write to a
|
|
|
|
|
|
// per-file SIDECAR (`<pdfPath>.badnote.json`) via SidecarRepository — debounced,
|
|
|
|
|
|
// atomic (temp+rename+.bak). The source file PATH is the identity (the sidecar
|
|
|
|
|
|
// IS the identity; the old djb2 path-hash document id is gone). Highlights now
|
|
|
|
|
|
// survive reopen, and a stored highlight can be removed (the un-highlight tool).
|
2026-06-21 22:04:49 +08:00
|
|
|
|
|
2026-06-24 20:18:22 +08:00
|
|
|
|
import 'package:flutter/foundation.dart'
|
|
|
|
|
|
show ValueListenable, visibleForTesting;
|
|
|
|
|
|
import 'package:flutter/gestures.dart';
|
2026-06-21 22:04:49 +08:00
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
import 'package:pdfrx/pdfrx.dart';
|
2026-06-24 20:02:12 +08:00
|
|
|
|
import 'package:uuid/uuid.dart';
|
2026-06-21 22:04:49 +08:00
|
|
|
|
|
2026-06-23 09:35:26 +08:00
|
|
|
|
import '../../l10n/app_localizations.dart';
|
2026-06-25 00:00:16 +08:00
|
|
|
|
import '../../models/bookmark.dart';
|
2026-06-24 20:02:12 +08:00
|
|
|
|
import '../../models/scratch_link.dart';
|
2026-06-24 21:03:28 +08:00
|
|
|
|
import '../../storage/badnote_sidecar.dart';
|
2026-08-05 20:27:35 +08:00
|
|
|
|
import '../../storage/notebook_manifest.dart' show kAnnotationFontFamily;
|
2026-06-24 11:13:43 +08:00
|
|
|
|
import '../engine/brush.dart';
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
import '../engine/shape_geometry.dart';
|
2026-06-24 02:32:41 +08:00
|
|
|
|
import '../engine/stroke_eraser.dart';
|
2026-06-22 02:10:05 +08:00
|
|
|
|
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
2026-06-21 23:45:47 +08:00
|
|
|
|
import '../engine/stroke_model.dart';
|
2026-06-23 03:51:44 +08:00
|
|
|
|
import '../engine/stroke_simplify.dart';
|
2026-06-21 23:56:38 +08:00
|
|
|
|
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';
|
2026-06-21 23:56:38 +08:00
|
|
|
|
import '../input/pen_config.dart';
|
2026-06-22 02:10:05 +08:00
|
|
|
|
import '../input/pen_input_service.dart';
|
2026-06-24 02:32:41 +08:00
|
|
|
|
import '../input/pressure_curve.dart'
|
2026-06-24 11:13:43 +08:00
|
|
|
|
show PressureCurve, kNaturalPressureFloor;
|
2026-06-24 02:32:41 +08:00
|
|
|
|
import '../pdf/pen_capture_region.dart';
|
2026-06-24 21:03:28 +08:00
|
|
|
|
import '../persistence/sidecar_repository.dart';
|
2026-08-05 20:27:35 +08:00
|
|
|
|
import '../ui/page_nav_shortcuts.dart';
|
2026-06-21 23:56:38 +08:00
|
|
|
|
import '../ui/pen_settings_page.dart';
|
|
|
|
|
|
import '../ui/thumbnail_grid.dart';
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
import 'editor_tool.dart';
|
2026-06-24 23:27:11 +08:00
|
|
|
|
import 'ink_painters.dart' show buildStrokePath, paintForStroke;
|
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';
|
2026-06-23 10:21:40 +08:00
|
|
|
|
import 'pen_palette_widgets.dart';
|
2026-06-21 22:04:49 +08:00
|
|
|
|
import 'pen_stroke.dart';
|
2026-06-24 20:18:22 +08:00
|
|
|
|
import 'pinch_scale_solver.dart';
|
2026-08-05 19:52:15 +08:00
|
|
|
|
import 'sticky_note_overlay.dart';
|
2026-06-21 22:04:49 +08:00
|
|
|
|
|
2026-06-24 20:02:12 +08:00
|
|
|
|
/// On-screen size (px) of a scratch-link anchor marker. Fixed in screen space
|
|
|
|
|
|
/// (not scaled with zoom) so the tap target stays comfortably tappable.
|
|
|
|
|
|
const double _kMarkerSize = 36.0;
|
|
|
|
|
|
|
2026-06-25 00:11:45 +08:00
|
|
|
|
/// Default font size for a new text box, as a fraction of page WIDTH (so it
|
|
|
|
|
|
/// scales with zoom). ~3% of page width ≈ comfortable body text on a portrait
|
|
|
|
|
|
/// page.
|
|
|
|
|
|
const double _kDefaultTextFontFraction = 0.03;
|
|
|
|
|
|
|
2026-08-05 20:27:35 +08:00
|
|
|
|
/// Convert a CSS-like numeric weight (100–900) to a [FontWeight], clamped to
|
|
|
|
|
|
/// the nearest of the 9 standard weights ([FontWeight.values] is w100..w900).
|
|
|
|
|
|
FontWeight _fontWeightFromValue(int weight) {
|
|
|
|
|
|
final idx = ((weight ~/ 100) - 1).clamp(0, FontWeight.values.length - 1);
|
|
|
|
|
|
return FontWeight.values[idx];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 22:04:49 +08:00
|
|
|
|
class PenEditorScreen extends StatefulWidget {
|
2026-06-23 10:04:38 +08:00
|
|
|
|
const PenEditorScreen({
|
|
|
|
|
|
super.key,
|
|
|
|
|
|
required this.pdfPath,
|
|
|
|
|
|
this.initialPage = 0,
|
|
|
|
|
|
});
|
2026-06-21 22:04:49 +08:00
|
|
|
|
|
|
|
|
|
|
final String pdfPath;
|
|
|
|
|
|
|
2026-06-23 10:04:38 +08:00
|
|
|
|
/// 0-based page to open on (e.g. a search-result jump). Clamped to the
|
|
|
|
|
|
/// document's page range once it loads.
|
|
|
|
|
|
final int initialPage;
|
|
|
|
|
|
|
2026-06-21 22:04:49 +08:00
|
|
|
|
@override
|
|
|
|
|
|
State<PenEditorScreen> createState() => _PenEditorScreenState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class _PenEditorScreenState extends State<PenEditorScreen> {
|
2026-06-24 02:32:41 +08:00
|
|
|
|
/// pdfrx viewer controller. Owns native pan/zoom and gives us the page
|
|
|
|
|
|
/// layout rects + coordinate conversions used to map pen events and to
|
|
|
|
|
|
/// re-project normalized strokes back to viewer pixels.
|
|
|
|
|
|
final PdfViewerController _controller = PdfViewerController();
|
|
|
|
|
|
|
|
|
|
|
|
bool _viewerReady = false;
|
2026-06-21 22:04:49 +08:00
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
/// Total page count (0 until the document is laid out).
|
|
|
|
|
|
int _pageCount = 0;
|
|
|
|
|
|
|
|
|
|
|
|
/// 0-based current page index (the page pdfrx reports as current). Drives the
|
|
|
|
|
|
/// page pill + thumbnail highlight.
|
2026-06-21 22:04:49 +08:00
|
|
|
|
int _pageIndex = 0;
|
|
|
|
|
|
|
2026-08-05 20:27:35 +08:00
|
|
|
|
/// Live 1-based value while the page pill's scrubber slider is being
|
|
|
|
|
|
/// dragged (like the slide editor's scrubber); null when not scrubbing.
|
|
|
|
|
|
double? _pageScrub;
|
|
|
|
|
|
|
2026-06-21 22:04:49 +08:00
|
|
|
|
/// Strokes per page, keyed by 0-based page index (normalized coords).
|
|
|
|
|
|
final Map<int, List<PenStroke>> _strokesByPage = {};
|
|
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
/// Text highlights per page, keyed by 0-based page index. Each Rect is in
|
|
|
|
|
|
/// NORMALIZED page coords (left/top/right/bottom in [0,1]) so it stays glued
|
2026-06-24 21:03:28 +08:00
|
|
|
|
/// under zoom. Persisted to the sidecar (Phase 2 — highlights now survive
|
|
|
|
|
|
/// reopen and can be removed via the un-highlight tool).
|
2026-06-24 02:32:41 +08:00
|
|
|
|
final Map<int, List<Rect>> _highlightsByPage = {};
|
|
|
|
|
|
|
2026-06-25 00:11:45 +08:00
|
|
|
|
/// Typed-text annotations per page, keyed by 0-based page index. Normalized
|
|
|
|
|
|
/// position + page-relative font size so they stay glued under zoom.
|
|
|
|
|
|
/// Persisted to the sidecar via [scheduleTextsSave]. PDF editor only for now
|
|
|
|
|
|
/// (note text is a later increment).
|
|
|
|
|
|
final Map<int, List<SidecarText>> _textsByPage = {};
|
|
|
|
|
|
|
|
|
|
|
|
/// The text box currently being edited (page + id), or null. While set a real
|
|
|
|
|
|
/// Flutter [TextField] is rendered over the box at its normalized position —
|
|
|
|
|
|
/// on Windows this receives IME + the Windows-Ink handwriting panel.
|
|
|
|
|
|
({int page, String id})? _editingText;
|
|
|
|
|
|
|
2026-06-21 23:56:38 +08:00
|
|
|
|
/// 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.
|
|
|
|
|
|
PenConfigController? _penConfig;
|
|
|
|
|
|
|
2026-06-21 23:45:47 +08:00
|
|
|
|
// ── Persistence ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-06-24 21:03:28 +08:00
|
|
|
|
/// Per-file sidecar persistence. Identity is the source file PATH (the
|
|
|
|
|
|
/// sidecar IS the identity — no more djb2 path-hash document id). Null until
|
|
|
|
|
|
/// [_initPersistence] resolves.
|
|
|
|
|
|
SidecarRepository? _repo;
|
2026-06-21 23:45:47 +08:00
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
/// Repaint signal for the page overlays. Bumped on every pen move/commit/erase
|
|
|
|
|
|
/// and on every viewer transform so the per-page CustomPaint re-projects the
|
|
|
|
|
|
/// live stroke + committed ink to the current zoom.
|
|
|
|
|
|
final ValueNotifier<int> _overlayRepaint = ValueNotifier<int>(0);
|
|
|
|
|
|
void _bumpOverlay() => _overlayRepaint.value++;
|
2026-06-21 22:04:49 +08:00
|
|
|
|
|
2026-06-24 20:18:22 +08:00
|
|
|
|
// ── Glitch-guarded pinch zoom (we own scale; pdfrx owns scroll) ─────────────
|
|
|
|
|
|
// pdfrx's scaleEnabled is off, so we drive 2-finger zoom through the public
|
|
|
|
|
|
// controller (zoomOnLocalPosition) with the SAME guards as the note canvas's
|
|
|
|
|
|
// PenInteractiveViewer: per-frame scale-ratio clamp, pointer-count re-baseline
|
|
|
|
|
|
// to the last APPLIED scale, and a focal-jump guard. Tracking is absolute from
|
|
|
|
|
|
// a gesture-start snapshot via absolutePinchScale() — never a live read-back.
|
|
|
|
|
|
|
|
|
|
|
|
static const double _kPinchMinScale = 0.5;
|
|
|
|
|
|
static const double _kPinchMaxScale = 8.0;
|
|
|
|
|
|
|
|
|
|
|
|
/// A real pinch changes scale modestly per frame; a frame demanding far more
|
|
|
|
|
|
/// is a Windows multi-touch glitch and is dropped (so the zoom can't pop).
|
2026-08-05 19:52:15 +08:00
|
|
|
|
/// Logs showed ~1.30 spikes — keep the band below that.
|
|
|
|
|
|
static const double _kScaleGlitchHi = 1.18;
|
2026-06-24 20:18:22 +08:00
|
|
|
|
static const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
|
|
|
|
|
|
|
|
|
|
|
|
/// A single-frame focal-midpoint jump beyond this is a touch misread → drop.
|
2026-08-05 19:52:15 +08:00
|
|
|
|
static const double _kFocalGlitchPx = 100.0;
|
2026-06-24 20:18:22 +08:00
|
|
|
|
|
|
|
|
|
|
/// Matrix scale captured at the current baseline (gesture start or the last
|
|
|
|
|
|
/// pointer-count re-baseline). Null when no pinch is active.
|
|
|
|
|
|
double? _pinchScaleStart;
|
|
|
|
|
|
|
|
|
|
|
|
/// Pointer count of the previous accepted pinch frame; a change re-baselines.
|
|
|
|
|
|
int _pinchPointerCount = 0;
|
|
|
|
|
|
|
|
|
|
|
|
/// The recognizer's cumulative `details.scale` on the previous accepted frame.
|
|
|
|
|
|
double _pinchLastRawScale = 1.0;
|
|
|
|
|
|
|
|
|
|
|
|
/// The absolute scale we last APPLIED. Re-baseline anchors to THIS (not a live
|
|
|
|
|
|
/// matrix read) so the displayed scale stays continuous across a finger blip.
|
|
|
|
|
|
double _pinchLastAppliedScale = 1.0;
|
|
|
|
|
|
|
|
|
|
|
|
/// The recognizer's cumulative `details.scale` at the current baseline; the
|
|
|
|
|
|
/// absolute target normalizes against it (see [absolutePinchScale]).
|
|
|
|
|
|
double _pinchRawScaleAtBaseline = 1.0;
|
|
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
// ── Live stroke state (viewer-level pen capture) ────────────────────────────
|
2026-06-21 22:33:18 +08:00
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
/// The page index the in-progress stroke belongs to (the page of its first
|
|
|
|
|
|
/// point). A stroke lives on exactly ONE page; samples on other pages are
|
|
|
|
|
|
/// ignored. Null when idle.
|
|
|
|
|
|
int? _liveStrokePage;
|
2026-06-21 22:33:18 +08:00
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
/// In-progress stroke points (normalized to [_liveStrokePage]).
|
|
|
|
|
|
final List<PenPoint> _livePoints = [];
|
2026-06-21 23:13:05 +08:00
|
|
|
|
|
2026-06-24 20:18:22 +08:00
|
|
|
|
/// CURRENT live stroke, published to the page overlay painters. The painter
|
|
|
|
|
|
/// reads this at PAINT time (not as a build-time snapshot), so a mid-stroke
|
|
|
|
|
|
/// update repaints immediately — the ink follows the pen instead of only
|
|
|
|
|
|
/// appearing on pointer-up. Null when idle.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// (The previous design passed a snapshot into the painter's constructor from
|
|
|
|
|
|
/// pageOverlaysBuilder, which only re-runs on setState; _bumpOverlay repainted
|
|
|
|
|
|
/// the painter but it still read the stale build-time snapshot → invisible ink
|
|
|
|
|
|
/// until commit. See `_LiveStrokeData` / `_PageOverlayPainter`.)
|
|
|
|
|
|
final ValueNotifier<_LiveStrokeData?> _liveStrokeVN = ValueNotifier(null);
|
2026-06-24 02:32:41 +08:00
|
|
|
|
|
|
|
|
|
|
/// Latest pen-event debug readout — shown only when the diagnostic toggle is
|
|
|
|
|
|
/// on, to inspect what Windows delivers.
|
2026-06-21 23:13:05 +08:00
|
|
|
|
String _penDebug = '';
|
|
|
|
|
|
bool _showPenDebug = false;
|
2026-06-24 02:32:41 +08:00
|
|
|
|
double _peakNorm = 0;
|
2026-06-21 23:13:05 +08:00
|
|
|
|
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
// Tool state. The single shared active-tool enum; the page-anchored
|
|
|
|
|
|
// select-text / place-link tools (below) are PDF-only and ride a different
|
|
|
|
|
|
// path (they disable pen capture), so they stay as their own booleans.
|
|
|
|
|
|
EditorToolKind _tool = EditorToolKind.brush;
|
2026-06-24 02:32:41 +08:00
|
|
|
|
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
/// Selected brush for the BRUSH tool (fountain/ballpoint/pencil). The
|
2026-06-24 11:13:43 +08:00
|
|
|
|
/// highlighter tool always uses [BrushKind.highlighter]. Local state only for
|
|
|
|
|
|
/// this increment (not persisted — TODO(brush-persist-selection)).
|
|
|
|
|
|
BrushKind _penBrush = BrushKind.fountainPen;
|
|
|
|
|
|
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
/// Selected shape for the SHAPE tool.
|
|
|
|
|
|
ShapeKind _shapeKind = ShapeKind.line;
|
|
|
|
|
|
|
|
|
|
|
|
/// SELECT tool: ([page], strokeIndex) of the selected committed stroke, or
|
|
|
|
|
|
/// null. Selection is per-page (each page has its own stroke list).
|
|
|
|
|
|
({int page, int index})? _selected;
|
|
|
|
|
|
|
|
|
|
|
|
/// SHAPE tool: normalized start point + its page, while a shape drag is live.
|
|
|
|
|
|
({int page, Offset start})? _shapeDrag;
|
|
|
|
|
|
|
|
|
|
|
|
/// SELECT tool: last normalized drag position + page, to compute the
|
|
|
|
|
|
/// incremental translation; and whether the drag's undo snapshot was taken.
|
|
|
|
|
|
Offset? _selectLast;
|
|
|
|
|
|
bool _selectDragging = false;
|
|
|
|
|
|
|
|
|
|
|
|
/// rnote-style per-brush color memory: each brush (and the highlighter)
|
|
|
|
|
|
/// remembers its own color. Selecting a brush restores its color; picking a
|
|
|
|
|
|
/// color updates ONLY the active brush's entry. In-memory only for this
|
|
|
|
|
|
/// increment (TODO(brush-color-persist)).
|
|
|
|
|
|
final Map<BrushKind, Color> _brushColors = {
|
|
|
|
|
|
BrushKind.fountainPen: Colors.black,
|
|
|
|
|
|
BrushKind.ballpoint: Colors.blue,
|
|
|
|
|
|
BrushKind.pencil: Colors.green,
|
|
|
|
|
|
BrushKind.highlighter: Colors.orange,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/// The brush whose color the color-dots edit (highlighter tool ⇒ highlighter,
|
|
|
|
|
|
/// else the selected pen brush).
|
|
|
|
|
|
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
|
|
|
|
|
|
? BrushKind.highlighter
|
|
|
|
|
|
: _penBrush;
|
|
|
|
|
|
|
2026-08-05 19:52:15 +08:00
|
|
|
|
/// When true the "select text" tool button is latched on.
|
|
|
|
|
|
bool _selectTextTool = false;
|
|
|
|
|
|
|
|
|
|
|
|
/// Barrel-held temporary select-text (OneNote-style); ORed into [_selectTextMode].
|
|
|
|
|
|
bool _barrelSelectText = false;
|
|
|
|
|
|
|
|
|
|
|
|
/// Select-text is active from the tool button OR a held barrel mapped to
|
|
|
|
|
|
/// [PenButtonAction.selectText].
|
|
|
|
|
|
bool get _selectTextMode => _selectTextTool || _barrelSelectText;
|
|
|
|
|
|
|
|
|
|
|
|
/// Expanded paper-sticky overlay for a scratch link (null = collapsed).
|
|
|
|
|
|
ScratchLink? _expandedSticky;
|
2026-06-24 02:32:41 +08:00
|
|
|
|
|
2026-06-24 20:02:12 +08:00
|
|
|
|
/// When true the "place scratch link" tool is active: a tap on a page drops a
|
|
|
|
|
|
/// new anchor (a sticky-note tab) instead of inking. Pen capture is disabled
|
|
|
|
|
|
/// so the tap is handled by the per-page GestureDetector overlay.
|
|
|
|
|
|
bool _placeLinkMode = false;
|
|
|
|
|
|
|
2026-06-24 21:03:28 +08:00
|
|
|
|
/// When true the "remove highlight" tool is active: a tap on an existing
|
|
|
|
|
|
/// highlight rect deletes it (the un-highlight action). Pen capture is
|
|
|
|
|
|
/// disabled so the tap is handled by the per-page GestureDetector overlay.
|
|
|
|
|
|
bool _removeHighlightMode = false;
|
|
|
|
|
|
|
2026-06-25 00:11:45 +08:00
|
|
|
|
/// When true the TEXT tool is active: a pen-tap (the pen falls through to the
|
|
|
|
|
|
/// per-page overlay GestureDetector) OR a mouse double-click on a page drops a
|
|
|
|
|
|
/// new text box and focuses it. Pen capture is disabled so the overlay sees
|
|
|
|
|
|
/// the tap instead of the ink path.
|
|
|
|
|
|
bool _textMode = false;
|
|
|
|
|
|
|
2026-06-24 20:02:12 +08:00
|
|
|
|
/// All scratch-link anchors for this document, loaded on open and updated on
|
|
|
|
|
|
/// add/delete. Rendered as tappable markers in [pageOverlaysBuilder].
|
|
|
|
|
|
final List<ScratchLink> _scratchLinks = [];
|
|
|
|
|
|
|
2026-06-25 00:00:16 +08:00
|
|
|
|
/// All bookmarks for this document, loaded on open and updated on add/delete.
|
|
|
|
|
|
/// Listed in the bookmarks panel; tapping one jumps to its anchor. Scoped to
|
|
|
|
|
|
/// the PDF editor for now (note bookmarks are a later increment).
|
|
|
|
|
|
final List<Bookmark> _bookmarks = [];
|
|
|
|
|
|
|
2026-06-24 20:02:12 +08:00
|
|
|
|
static const _uuid = Uuid();
|
|
|
|
|
|
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
/// The active drawing color = the active brush's remembered color.
|
|
|
|
|
|
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
|
2026-06-21 22:04:49 +08:00
|
|
|
|
bool _allowFingerDrawing = false;
|
|
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
/// Whether the viewer currently has a non-empty text selection (drives the
|
|
|
|
|
|
/// "highlight selection" action's enabled state).
|
|
|
|
|
|
bool _hasSelection = false;
|
|
|
|
|
|
|
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;
|
2026-06-21 22:04:49 +08:00
|
|
|
|
static const double _highlighterWidthFraction = 0.02;
|
|
|
|
|
|
|
2026-08-05 19:52:15 +08:00
|
|
|
|
static const List<Color> _palette = kInkPalette;
|
2026-06-21 22:04:49 +08:00
|
|
|
|
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
/// True when an ink tool (brush/highlighter/eraser/select/shape) is active —
|
|
|
|
|
|
/// pen capture is on. False in select-text mode (pen reaches pdfrx text
|
2026-08-05 19:52:15 +08:00
|
|
|
|
/// selection), place-link / sticky expanded, and text mode.
|
2026-06-24 21:03:28 +08:00
|
|
|
|
bool get _penCaptureEnabled =>
|
2026-08-05 19:52:15 +08:00
|
|
|
|
!_selectTextMode &&
|
|
|
|
|
|
!_placeLinkMode &&
|
|
|
|
|
|
!_removeHighlightMode &&
|
|
|
|
|
|
!_textMode &&
|
|
|
|
|
|
_expandedSticky == null;
|
2026-06-24 02:32:41 +08:00
|
|
|
|
|
|
|
|
|
|
/// True when the eraser tool is active.
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode;
|
|
|
|
|
|
|
|
|
|
|
|
/// True when the SELECT tool is active (and not in a page-anchored mode).
|
|
|
|
|
|
bool get _isSelect =>
|
|
|
|
|
|
_tool == EditorToolKind.select && _penCaptureEnabled;
|
|
|
|
|
|
|
|
|
|
|
|
/// True when the SHAPE tool is active.
|
|
|
|
|
|
bool get _isShape => _tool == EditorToolKind.shape && _penCaptureEnabled;
|
2026-06-24 02:32:41 +08:00
|
|
|
|
|
2026-06-21 22:04:49 +08:00
|
|
|
|
@override
|
|
|
|
|
|
void initState() {
|
|
|
|
|
|
super.initState();
|
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();
|
2026-08-05 19:52:15 +08:00
|
|
|
|
PenInputService.instance.addListener(_onHwPenChanged);
|
2026-06-21 23:45:47 +08:00
|
|
|
|
_initPersistence();
|
2026-06-21 23:56:38 +08:00
|
|
|
|
_initPenConfig();
|
2026-06-21 22:04:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 23:56:38 +08:00
|
|
|
|
Future<void> _initPenConfig() async {
|
|
|
|
|
|
final controller = await PenConfigController.load();
|
|
|
|
|
|
if (!mounted) {
|
|
|
|
|
|
controller.dispose();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-22 02:10:05 +08:00
|
|
|
|
controller.addListener(_onPenConfigChanged);
|
2026-06-21 23:56:38 +08:00
|
|
|
|
setState(() {
|
|
|
|
|
|
_penConfig = controller;
|
|
|
|
|
|
_allowFingerDrawing = controller.value.fingerDrawing;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 02:10:05 +08:00
|
|
|
|
void _onPenConfigChanged() {
|
|
|
|
|
|
if (mounted) setState(() {});
|
2026-08-05 19:52:15 +08:00
|
|
|
|
_syncBarrelSelectText();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _onHwPenChanged() {
|
|
|
|
|
|
_syncBarrelSelectText();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Level-trigger: holding barrel with sideButton=selectText enables text
|
|
|
|
|
|
/// selection without latching the toolbar tool.
|
|
|
|
|
|
void _syncBarrelSelectText() {
|
|
|
|
|
|
final cfg = _penConfig?.value;
|
|
|
|
|
|
final hw = PenInputService.instance;
|
|
|
|
|
|
final want = cfg != null &&
|
|
|
|
|
|
hw.isActive &&
|
|
|
|
|
|
hw.current.barrel &&
|
|
|
|
|
|
cfg.sideButton == PenButtonAction.selectText;
|
|
|
|
|
|
if (want == _barrelSelectText) return;
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_barrelSelectText = want;
|
|
|
|
|
|
if (want) {
|
|
|
|
|
|
_placeLinkMode = false;
|
|
|
|
|
|
_removeHighlightMode = false;
|
|
|
|
|
|
_textMode = false;
|
|
|
|
|
|
_selected = null;
|
|
|
|
|
|
_expandedSticky = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-06-22 02:10:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 23:45:47 +08:00
|
|
|
|
Future<void> _initPersistence() async {
|
2026-06-24 21:03:28 +08:00
|
|
|
|
final repo = await SidecarRepository.open(widget.pdfPath, docType: 'pdf');
|
2026-06-21 23:45:47 +08:00
|
|
|
|
if (!mounted) {
|
2026-06-24 21:03:28 +08:00
|
|
|
|
repo.dispose();
|
2026-06-21 23:45:47 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-24 21:03:28 +08:00
|
|
|
|
_repo = repo;
|
|
|
|
|
|
_loadFromSidecar(repo);
|
2026-06-24 20:02:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 21:03:28 +08:00
|
|
|
|
/// Hydrate the in-memory editor state from the sidecar [repo] loaded on open:
|
|
|
|
|
|
/// per-page strokes, per-page highlights, and scratch-link anchors.
|
|
|
|
|
|
void _loadFromSidecar(SidecarRepository repo) {
|
|
|
|
|
|
final loadedStrokes = <int, List<PenStroke>>{};
|
|
|
|
|
|
for (final entry in repo.loadedStrokes.entries) {
|
|
|
|
|
|
loadedStrokes[entry.key] = entry.value
|
2026-06-21 23:45:47 +08:00
|
|
|
|
.map((es) => PenStroke(
|
|
|
|
|
|
points: es.points
|
2026-06-24 21:03:28 +08:00
|
|
|
|
.map((ep) =>
|
|
|
|
|
|
PenPoint(ep.x, ep.y, ep.pressure, tilt: ep.tilt))
|
2026-06-21 23:45:47 +08:00
|
|
|
|
.toList(),
|
|
|
|
|
|
color: es.color,
|
|
|
|
|
|
width: es.width,
|
|
|
|
|
|
kind: es.tool == EditorTool.highlighter
|
|
|
|
|
|
? PenStrokeKind.highlighter
|
|
|
|
|
|
: PenStrokeKind.pen,
|
2026-06-24 23:40:40 +08:00
|
|
|
|
// Brush is now persisted on the EditorStroke; carry it through
|
|
|
|
|
|
// so a reopened ballpoint/pencil/highlighter keeps its
|
|
|
|
|
|
// opacity/blend. Old sidecars without the field decode to
|
|
|
|
|
|
// fountainPen (see EditorStroke.brush back-compat default).
|
|
|
|
|
|
brush: es.brush,
|
2026-06-21 23:45:47 +08:00
|
|
|
|
))
|
|
|
|
|
|
.toList();
|
|
|
|
|
|
}
|
2026-06-24 21:03:28 +08:00
|
|
|
|
|
|
|
|
|
|
final loadedHighlights = <int, List<Rect>>{};
|
|
|
|
|
|
for (final entry in repo.loadedHighlights.entries) {
|
|
|
|
|
|
loadedHighlights[entry.key] =
|
|
|
|
|
|
entry.value.map((h) => h.toRect()).toList();
|
2026-06-21 23:45:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 21:03:28 +08:00
|
|
|
|
setState(() {
|
|
|
|
|
|
for (final entry in loadedStrokes.entries) {
|
|
|
|
|
|
_strokesByPage[entry.key] = entry.value;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (final entry in loadedHighlights.entries) {
|
|
|
|
|
|
_highlightsByPage[entry.key] = entry.value;
|
|
|
|
|
|
}
|
2026-06-25 00:11:45 +08:00
|
|
|
|
for (final entry in repo.loadedTexts.entries) {
|
|
|
|
|
|
_textsByPage[entry.key] = List<SidecarText>.of(entry.value);
|
|
|
|
|
|
}
|
2026-06-24 21:03:28 +08:00
|
|
|
|
_scratchLinks
|
|
|
|
|
|
..clear()
|
|
|
|
|
|
..addAll(repo.loadedScratchLinks.map((s) => s.link));
|
2026-06-25 00:00:16 +08:00
|
|
|
|
_bookmarks
|
|
|
|
|
|
..clear()
|
|
|
|
|
|
..addAll(repo.loadedBookmarks);
|
2026-06-24 21:03:28 +08:00
|
|
|
|
});
|
|
|
|
|
|
_bumpOverlay();
|
2026-06-21 23:45:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 22:04:49 +08:00
|
|
|
|
@override
|
|
|
|
|
|
void dispose() {
|
2026-06-24 21:03:28 +08:00
|
|
|
|
// Flush any pending sidecar write before tearing down.
|
|
|
|
|
|
final repo = _repo;
|
|
|
|
|
|
if (repo != null) {
|
|
|
|
|
|
repo.flush(); // fire-and-forget; atomic write completes off the UI tree
|
|
|
|
|
|
repo.dispose();
|
2026-06-21 23:45:47 +08:00
|
|
|
|
}
|
2026-06-24 02:32:41 +08:00
|
|
|
|
_overlayRepaint.dispose();
|
2026-06-24 20:18:22 +08:00
|
|
|
|
_liveStrokeVN.dispose();
|
2026-08-05 19:52:15 +08:00
|
|
|
|
_penConfig?.removeListener(_onPenConfigChanged);
|
2026-06-21 23:56:38 +08:00
|
|
|
|
_penConfig?.dispose();
|
2026-08-05 19:52:15 +08:00
|
|
|
|
PenInputService.instance.removeListener(_onHwPenChanged);
|
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();
|
2026-06-21 22:04:49 +08:00
|
|
|
|
super.dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
// ── Stroke persistence (reused verbatim from the bitmap editor) ────────────
|
2026-06-21 22:04:49 +08:00
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
void _commitStroke(int pageIndex, PenStroke stroke) {
|
|
|
|
|
|
_undoFor(pageIndex)
|
|
|
|
|
|
.record(List<PenStroke>.of(_strokesByPage[pageIndex] ?? const []));
|
2026-06-21 22:04:49 +08:00
|
|
|
|
setState(() {
|
2026-06-24 02:32:41 +08:00
|
|
|
|
// New list identity so the overlay painter sees a change.
|
|
|
|
|
|
_strokesByPage[pageIndex] = [
|
|
|
|
|
|
...?_strokesByPage[pageIndex],
|
2026-06-21 22:33:18 +08:00
|
|
|
|
stroke,
|
|
|
|
|
|
];
|
2026-06-21 22:04:49 +08:00
|
|
|
|
});
|
2026-06-24 02:32:41 +08:00
|
|
|
|
final snapshot = List<PenStroke>.of(_strokesByPage[pageIndex]!);
|
|
|
|
|
|
_schedulePageSave(pageIndex, snapshot);
|
|
|
|
|
|
_bumpOverlay();
|
2026-06-21 22:04:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
/// Replace committed stroke [index] on [pageIndex] with its surviving pieces
|
|
|
|
|
|
/// after a partial (segment) erase. An empty [replacements] removes it.
|
|
|
|
|
|
void _eraseStroke(int pageIndex, int index, List<PenStroke> replacements) {
|
|
|
|
|
|
final list = _strokesByPage[pageIndex];
|
2026-06-21 23:56:38 +08:00
|
|
|
|
final willMutate = list != null && index >= 0 && index < list.length;
|
|
|
|
|
|
if (willMutate) {
|
2026-06-24 02:32:41 +08:00
|
|
|
|
_undoFor(pageIndex).record(List<PenStroke>.of(list));
|
2026-06-21 23:56:38 +08:00
|
|
|
|
}
|
2026-06-21 22:04:49 +08:00
|
|
|
|
setState(() {
|
|
|
|
|
|
if (list != null && index >= 0 && index < list.length) {
|
2026-06-22 12:42:26 +08:00
|
|
|
|
final next = List<PenStroke>.of(list)
|
|
|
|
|
|
..replaceRange(index, index + 1, replacements);
|
2026-06-24 02:32:41 +08:00
|
|
|
|
_strokesByPage[pageIndex] = next;
|
2026-06-21 22:04:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-06-24 02:32:41 +08:00
|
|
|
|
final current = _strokesByPage[pageIndex];
|
2026-06-21 23:45:47 +08:00
|
|
|
|
final snapshot =
|
|
|
|
|
|
current != null ? List<PenStroke>.of(current) : <PenStroke>[];
|
2026-06-24 02:32:41 +08:00
|
|
|
|
_schedulePageSave(pageIndex, snapshot);
|
|
|
|
|
|
_bumpOverlay();
|
2026-06-21 23:45:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 21:03:28 +08:00
|
|
|
|
/// Convert [strokes] to [EditorStroke]s and hand them to the sidecar repo.
|
2026-06-21 23:45:47 +08:00
|
|
|
|
void _schedulePageSave(int pageIndex, List<PenStroke> strokes) {
|
2026-06-24 21:03:28 +08:00
|
|
|
|
final repo = _repo;
|
|
|
|
|
|
if (repo == null) return;
|
2026-06-23 03:51:44 +08:00
|
|
|
|
final editorStrokes = strokes
|
|
|
|
|
|
.map((s) => simplifyStroke(EditorStroke.fromPenStroke(s)))
|
|
|
|
|
|
.toList();
|
2026-06-24 21:03:28 +08:00
|
|
|
|
repo.scheduleStrokeSave(pageIndex, editorStrokes);
|
2026-06-21 22:04:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 23:56:38 +08:00
|
|
|
|
void _performUndo() {
|
|
|
|
|
|
final stack = _undoFor(_pageIndex);
|
|
|
|
|
|
if (!stack.canUndo) return;
|
2026-06-24 02:32:41 +08:00
|
|
|
|
final current = List<PenStroke>.of(_strokesByPage[_pageIndex] ?? const []);
|
2026-06-21 23:56:38 +08:00
|
|
|
|
final snapshot = stack.undo(current);
|
|
|
|
|
|
if (snapshot == null) return;
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_strokesByPage[_pageIndex] = List<PenStroke>.of(snapshot);
|
|
|
|
|
|
});
|
|
|
|
|
|
_schedulePageSave(_pageIndex, List<PenStroke>.of(snapshot));
|
2026-06-24 02:32:41 +08:00
|
|
|
|
_bumpOverlay();
|
2026-06-21 23:56:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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));
|
2026-06-24 02:32:41 +08:00
|
|
|
|
_bumpOverlay();
|
2026-06-21 23:56:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
// ── Pen capture (viewer-level) ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/// Normalize stylus pressure to [0,1] shaped by the configured curve, or null
|
|
|
|
|
|
/// when the device reports no usable pressure range (freehand simulates it).
|
|
|
|
|
|
double? _normalizedPressure(PointerEvent event) {
|
|
|
|
|
|
final raw = _rawNormalizedPressure(event);
|
|
|
|
|
|
if (raw == null) return null;
|
|
|
|
|
|
// PenConfig exposes gamma but not floor; use the shared natural floor (the
|
2026-06-24 11:13:43 +08:00
|
|
|
|
// bitmap editor did the same — it never sourced floor from config). The
|
|
|
|
|
|
// gamma is the BRUSH's pressure warp (fountain p² / pencil √p / linear),
|
|
|
|
|
|
// superseding the legacy config gamma — see TODO(brush-pressure-knob).
|
2026-06-24 02:32:41 +08:00
|
|
|
|
const floor = kNaturalPressureFloor;
|
2026-06-24 11:13:43 +08:00
|
|
|
|
final gamma = brushProfileFor(_currentBrush()).pressureGamma;
|
2026-06-24 02:32:41 +08:00
|
|
|
|
return PressureCurve(floor: floor, gamma: gamma).apply(raw);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
double? _rawNormalizedPressure(PointerEvent event) {
|
2026-08-05 19:52:15 +08:00
|
|
|
|
final hw = PenInputService.instance;
|
|
|
|
|
|
if (hw.isActive && hw.current.pressureValid) {
|
|
|
|
|
|
return hw.current.pressure.clamp(0.0, 1.0);
|
|
|
|
|
|
}
|
2026-06-24 02:32:41 +08:00
|
|
|
|
final range = event.pressureMax - event.pressureMin;
|
|
|
|
|
|
if (range > 0.0001) {
|
|
|
|
|
|
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (event.pressure > 0.0 && event.pressure < 1.0) {
|
|
|
|
|
|
return event.pressure;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Map a global pen position to (pageIndex, normalized-in-page) using the
|
|
|
|
|
|
/// controller's document-space page layout rects. Returns null if outside
|
|
|
|
|
|
/// every page box or the viewer isn't ready.
|
|
|
|
|
|
({int page, Offset normalized})? _documentToPage(Offset global) {
|
|
|
|
|
|
if (!_controller.isReady) return null;
|
|
|
|
|
|
final doc = _controller.globalToDocument(global);
|
|
|
|
|
|
if (doc == null) 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 (page: i, normalized: Offset(nx, ny));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 19:52:15 +08:00
|
|
|
|
/// Hit-test scratch-link markers near [normalized] on [page].
|
|
|
|
|
|
ScratchLink? _hitScratchMarker(int page, Offset normalized) {
|
|
|
|
|
|
const r = 0.045;
|
|
|
|
|
|
final r2 = r * r;
|
|
|
|
|
|
for (final link in _scratchLinks) {
|
|
|
|
|
|
if (link.pageIndex != page) continue;
|
|
|
|
|
|
final dx = link.nx - normalized.dx;
|
|
|
|
|
|
final dy = link.ny - normalized.dy;
|
|
|
|
|
|
if (dx * dx + dy * dy <= r2) return link;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
void _onPenEvent(PointerEvent event) {
|
|
|
|
|
|
if (_isStylus(event.kind)) _emitPenDebug(event);
|
|
|
|
|
|
|
|
|
|
|
|
final hit = _documentToPage(event.position);
|
|
|
|
|
|
|
|
|
|
|
|
if (event is PointerDownEvent) {
|
|
|
|
|
|
if (hit == null) return;
|
2026-08-05 19:52:15 +08:00
|
|
|
|
// Stylus can open sticky markers (PenCaptureRegion otherwise steals taps).
|
|
|
|
|
|
final sticky = _hitScratchMarker(hit.page, hit.normalized);
|
|
|
|
|
|
if (sticky != null) {
|
|
|
|
|
|
_openScratchLink(sticky);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-24 02:32:41 +08:00
|
|
|
|
if (_isEraser) {
|
|
|
|
|
|
_liveStrokePage = hit.page;
|
|
|
|
|
|
_eraseAt(hit.page, hit.normalized);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
if (_isSelect) {
|
|
|
|
|
|
_liveStrokePage = hit.page;
|
|
|
|
|
|
_selectLast = hit.normalized;
|
|
|
|
|
|
_selectDragging = false;
|
|
|
|
|
|
_selectAt(hit.page, hit.normalized);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (_isShape) {
|
|
|
|
|
|
_liveStrokePage = hit.page;
|
|
|
|
|
|
_shapeDrag = (page: hit.page, start: hit.normalized);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-24 02:32:41 +08:00
|
|
|
|
_liveStrokePage = hit.page;
|
|
|
|
|
|
_livePoints
|
|
|
|
|
|
..clear()
|
|
|
|
|
|
..add(PenPoint(hit.normalized.dx, hit.normalized.dy,
|
|
|
|
|
|
_normalizedPressure(event)));
|
|
|
|
|
|
_updateLiveStroke();
|
|
|
|
|
|
} else if (event is PointerMoveEvent) {
|
|
|
|
|
|
final page = _liveStrokePage;
|
|
|
|
|
|
if (page == null) return;
|
|
|
|
|
|
if (_isEraser) {
|
|
|
|
|
|
// Erase only against the page the gesture started on; ignore drift.
|
|
|
|
|
|
if (hit != null && hit.page == page) {
|
|
|
|
|
|
_eraseAt(page, hit.normalized);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
if (_isSelect) {
|
|
|
|
|
|
if (hit == null || hit.page != page) return;
|
|
|
|
|
|
_dragSelected(page, hit.normalized);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (_isShape) {
|
|
|
|
|
|
if (hit == null || hit.page != page) return;
|
|
|
|
|
|
_updateShapePreview(page, hit.normalized);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-24 02:32:41 +08:00
|
|
|
|
// A stroke belongs to ONE page: ignore samples on a different page.
|
|
|
|
|
|
if (hit == null || hit.page != page) return;
|
|
|
|
|
|
_livePoints.add(PenPoint(
|
|
|
|
|
|
hit.normalized.dx, hit.normalized.dy, _normalizedPressure(event)));
|
|
|
|
|
|
_updateLiveStroke();
|
|
|
|
|
|
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
|
|
|
|
|
|
_endStroke(commit: event is PointerUpEvent);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
/// SELECT down: hit-test the page's committed strokes (topmost first) and set
|
|
|
|
|
|
/// the selection (or clear it on empty space).
|
|
|
|
|
|
void _selectAt(int page, Offset normalized) {
|
|
|
|
|
|
final strokes = _strokesByPage[page];
|
|
|
|
|
|
final radius = _penConfig?.value.eraserRadius ?? kDefaultEraserRadius;
|
|
|
|
|
|
final aspect = _pageAspect(page);
|
|
|
|
|
|
int? hitIndex;
|
|
|
|
|
|
if (strokes != null) {
|
|
|
|
|
|
for (var i = strokes.length - 1; i >= 0; i--) {
|
|
|
|
|
|
if (strokeHit(strokes[i], normalized.dx, normalized.dy, radius,
|
|
|
|
|
|
aspect: aspect)) {
|
|
|
|
|
|
hitIndex = i;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_selected = hitIndex == null ? null : (page: page, index: hitIndex);
|
|
|
|
|
|
});
|
|
|
|
|
|
_bumpOverlay();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// SELECT drag: translate the selected stroke by the incremental delta,
|
|
|
|
|
|
/// recording ONE undo snapshot on the first delta of the drag.
|
|
|
|
|
|
void _dragSelected(int page, Offset normalized) {
|
|
|
|
|
|
final sel = _selected;
|
|
|
|
|
|
final last = _selectLast;
|
|
|
|
|
|
if (sel == null || last == null || sel.page != page) {
|
|
|
|
|
|
_selectLast = normalized;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
final dx = normalized.dx - last.dx;
|
|
|
|
|
|
final dy = normalized.dy - last.dy;
|
|
|
|
|
|
_selectLast = normalized;
|
|
|
|
|
|
if (dx == 0 && dy == 0) return;
|
|
|
|
|
|
_moveSelected(dx, dy, isDragStart: !_selectDragging);
|
|
|
|
|
|
_selectDragging = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Translate the selected stroke by ([dx],[dy]); persists + (on [isDragStart])
|
|
|
|
|
|
/// records one undo snapshot via the existing per-page undo stack.
|
|
|
|
|
|
void _moveSelected(double dx, double dy, {required bool isDragStart}) {
|
|
|
|
|
|
final sel = _selected;
|
|
|
|
|
|
if (sel == null) return;
|
|
|
|
|
|
final list = _strokesByPage[sel.page];
|
|
|
|
|
|
if (list == null || sel.index < 0 || sel.index >= list.length) return;
|
|
|
|
|
|
if (isDragStart) _undoFor(sel.page).record(List<PenStroke>.of(list));
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
final next = List<PenStroke>.of(list);
|
|
|
|
|
|
next[sel.index] = translateStroke(next[sel.index], dx, dy);
|
|
|
|
|
|
_strokesByPage[sel.page] = next;
|
|
|
|
|
|
});
|
|
|
|
|
|
_schedulePageSave(sel.page, List<PenStroke>.of(_strokesByPage[sel.page]!));
|
|
|
|
|
|
_bumpOverlay();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Delete the selected stroke (button or long-press) as one undo step.
|
|
|
|
|
|
void _deleteSelected() {
|
|
|
|
|
|
final sel = _selected;
|
|
|
|
|
|
if (sel == null) return;
|
|
|
|
|
|
final list = _strokesByPage[sel.page];
|
|
|
|
|
|
if (list == null || sel.index < 0 || sel.index >= list.length) return;
|
|
|
|
|
|
_undoFor(sel.page).record(List<PenStroke>.of(list));
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
final next = List<PenStroke>.of(list)..removeAt(sel.index);
|
|
|
|
|
|
_strokesByPage[sel.page] = next;
|
|
|
|
|
|
_selected = null;
|
|
|
|
|
|
});
|
|
|
|
|
|
_schedulePageSave(sel.page, List<PenStroke>.of(_strokesByPage[sel.page]!));
|
|
|
|
|
|
_bumpOverlay();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// SHAPE preview: rebuild the generated shape stroke from start→current and
|
|
|
|
|
|
/// publish it as the live stroke (drawn by the page overlay painter).
|
|
|
|
|
|
void _updateShapePreview(int page, Offset current) {
|
|
|
|
|
|
final drag = _shapeDrag;
|
|
|
|
|
|
if (drag == null || drag.page != page) return;
|
|
|
|
|
|
final pts = generateShapePoints(
|
|
|
|
|
|
_shapeKind,
|
|
|
|
|
|
PenPoint(drag.start.dx, drag.start.dy, 1.0),
|
|
|
|
|
|
PenPoint(current.dx, current.dy, 1.0),
|
|
|
|
|
|
);
|
|
|
|
|
|
_liveStrokeVN.value = _LiveStrokeData(
|
|
|
|
|
|
page,
|
|
|
|
|
|
PenStroke(
|
|
|
|
|
|
points: pts,
|
|
|
|
|
|
color: _currentColor().toARGB32(),
|
|
|
|
|
|
width: _currentStrokeWidth(),
|
|
|
|
|
|
kind: PenStrokeKind.pen,
|
2026-08-05 19:52:15 +08:00
|
|
|
|
brush: kShapeBrush,
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
void _updateLiveStroke() {
|
|
|
|
|
|
final page = _liveStrokePage;
|
|
|
|
|
|
if (page == null || _livePoints.isEmpty) return;
|
2026-06-24 20:18:22 +08:00
|
|
|
|
final stroke = PenStroke(
|
2026-06-24 02:32:41 +08:00
|
|
|
|
points: List.of(_livePoints),
|
|
|
|
|
|
color: _currentColor().toARGB32(),
|
|
|
|
|
|
width: _currentStrokeWidth(),
|
|
|
|
|
|
kind: _currentKind(),
|
2026-06-24 11:13:43 +08:00
|
|
|
|
brush: _currentBrush(),
|
2026-06-24 02:32:41 +08:00
|
|
|
|
);
|
2026-06-24 20:18:22 +08:00
|
|
|
|
// Publish the live stroke so the page overlay painter repaints it NOW
|
|
|
|
|
|
// (notifies its repaint Listenable) — the ink follows the pen.
|
|
|
|
|
|
_liveStrokeVN.value = _LiveStrokeData(page, stroke);
|
2026-06-24 02:32:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _endStroke({required bool commit}) {
|
|
|
|
|
|
final page = _liveStrokePage;
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
// SHAPE: on release, commit the generated shape stroke.
|
|
|
|
|
|
final drag = _shapeDrag;
|
|
|
|
|
|
if (_isShape && drag != null && commit) {
|
|
|
|
|
|
final end = _liveStrokeVN.value;
|
|
|
|
|
|
if (end != null && end.page == drag.page) {
|
|
|
|
|
|
_commitStroke(drag.page, end.stroke);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (page != null &&
|
|
|
|
|
|
commit &&
|
|
|
|
|
|
!_isEraser &&
|
|
|
|
|
|
!_isSelect &&
|
|
|
|
|
|
!_isShape &&
|
|
|
|
|
|
_livePoints.isNotEmpty) {
|
2026-06-24 02:32:41 +08:00
|
|
|
|
// A single tap → tiny dot is allowed (perfect_freehand renders a dot for
|
|
|
|
|
|
// a 1-point stroke).
|
|
|
|
|
|
_commitStroke(
|
|
|
|
|
|
page,
|
|
|
|
|
|
PenStroke(
|
|
|
|
|
|
points: List.of(_livePoints),
|
|
|
|
|
|
color: _currentColor().toARGB32(),
|
|
|
|
|
|
width: _currentStrokeWidth(),
|
|
|
|
|
|
kind: _currentKind(),
|
2026-06-24 11:13:43 +08:00
|
|
|
|
brush: _currentBrush(),
|
2026-06-24 02:32:41 +08:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
_liveStrokePage = null;
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
_shapeDrag = null;
|
|
|
|
|
|
_selectLast = null;
|
|
|
|
|
|
_selectDragging = false;
|
2026-06-24 02:32:41 +08:00
|
|
|
|
_livePoints.clear();
|
2026-06-24 20:18:22 +08:00
|
|
|
|
_liveStrokeVN.value = null;
|
2026-06-24 02:32:41 +08:00
|
|
|
|
_bumpOverlay();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Partial (segment) erase on [pageIndex]: find the first committed stroke the
|
|
|
|
|
|
/// eraser circle touches and replace it with its surviving pieces.
|
|
|
|
|
|
void _eraseAt(int pageIndex, Offset normalized) {
|
|
|
|
|
|
final strokes = _strokesByPage[pageIndex];
|
|
|
|
|
|
if (strokes == null || strokes.isEmpty) return;
|
|
|
|
|
|
final radius = _penConfig?.value.eraserRadius ?? kDefaultEraserRadius;
|
|
|
|
|
|
final wholeStroke = _penConfig?.value.eraserWholeStroke ?? false;
|
|
|
|
|
|
final aspect = _pageAspect(pageIndex);
|
|
|
|
|
|
for (var i = strokes.length - 1; i >= 0; i--) {
|
|
|
|
|
|
final stroke = strokes[i];
|
|
|
|
|
|
if (!strokeHit(stroke, normalized.dx, normalized.dy, radius,
|
|
|
|
|
|
aspect: aspect)) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
final pieces = wholeStroke
|
|
|
|
|
|
? const <PenStroke>[]
|
|
|
|
|
|
: splitStrokeByCircle(stroke, normalized.dx, normalized.dy, radius,
|
|
|
|
|
|
aspect: aspect);
|
|
|
|
|
|
if (pieces.length == 1 && identical(pieces.first, stroke)) return;
|
|
|
|
|
|
_eraseStroke(pageIndex, i, pieces);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Page aspect (height / width) so the eraser circle stays round on screen.
|
|
|
|
|
|
double _pageAspect(int pageIndex) {
|
|
|
|
|
|
if (!_controller.isReady) return 1.0;
|
|
|
|
|
|
final rects = _controller.layout.pageLayouts;
|
|
|
|
|
|
if (pageIndex < 0 || pageIndex >= rects.length) return 1.0;
|
|
|
|
|
|
final r = rects[pageIndex];
|
|
|
|
|
|
return r.width <= 0 ? 1.0 : r.height / r.width;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool _isStylus(PointerDeviceKind kind) =>
|
|
|
|
|
|
kind == PointerDeviceKind.stylus ||
|
|
|
|
|
|
kind == PointerDeviceKind.invertedStylus;
|
|
|
|
|
|
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
double _currentStrokeWidth() => _tool == EditorToolKind.highlighter
|
2026-06-24 02:32:41 +08:00
|
|
|
|
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
|
|
|
|
|
|
: (_penConfig?.value.penWidth ?? _penWidthFraction);
|
|
|
|
|
|
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
PenStrokeKind _currentKind() => _tool == EditorToolKind.highlighter
|
2026-06-24 02:32:41 +08:00
|
|
|
|
? PenStrokeKind.highlighter
|
|
|
|
|
|
: PenStrokeKind.pen;
|
|
|
|
|
|
|
2026-06-24 11:13:43 +08:00
|
|
|
|
/// Brush in effect: highlighter tool ⇒ highlighter brush, else the selected
|
|
|
|
|
|
/// pen brush. Drives both the capture-time pressure warp and render geometry.
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
BrushKind _currentBrush() => _tool == EditorToolKind.highlighter
|
2026-06-24 11:13:43 +08:00
|
|
|
|
? BrushKind.highlighter
|
|
|
|
|
|
: _penBrush;
|
|
|
|
|
|
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
Color _currentColor() => _tool == EditorToolKind.highlighter
|
2026-06-24 02:32:41 +08:00
|
|
|
|
? _color.withAlpha(0x80)
|
|
|
|
|
|
: _color;
|
|
|
|
|
|
|
|
|
|
|
|
void _emitPenDebug(PointerEvent event) {
|
|
|
|
|
|
if (!_showPenDebug) return;
|
|
|
|
|
|
final norm = _normalizedPressure(event);
|
|
|
|
|
|
if (norm != null && norm > _peakNorm) _peakNorm = norm;
|
2026-06-22 02:10:05 +08:00
|
|
|
|
setState(() {
|
2026-06-24 02:32:41 +08:00
|
|
|
|
_penDebug = '${event.kind.name} raw=${event.pressure.toStringAsFixed(1)}'
|
|
|
|
|
|
'/${event.pressureMax.toStringAsFixed(0)} '
|
|
|
|
|
|
'norm=${norm?.toStringAsFixed(3) ?? "null"} '
|
|
|
|
|
|
'peak=${_peakNorm.toStringAsFixed(3)} '
|
|
|
|
|
|
'btn=${event.buttons} tilt=${event.tilt.toStringAsFixed(2)}'
|
|
|
|
|
|
'\n${PenInputService.instance.debugSummary}';
|
2026-06-22 02:10:05 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 20:18:22 +08:00
|
|
|
|
// ── Pinch zoom (glitch-guarded, drives pdfrx controller) ────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
void _onPinchStart(ScaleStartDetails details) {
|
|
|
|
|
|
if (!_controller.isReady) return;
|
|
|
|
|
|
_pinchScaleStart = _controller.currentZoom;
|
|
|
|
|
|
_pinchPointerCount = details.pointerCount;
|
|
|
|
|
|
_pinchLastRawScale = 1.0;
|
|
|
|
|
|
_pinchLastAppliedScale = _pinchScaleStart!;
|
|
|
|
|
|
_pinchRawScaleAtBaseline = 1.0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _onPinchUpdate(ScaleUpdateDetails details) {
|
|
|
|
|
|
final scaleStart = _pinchScaleStart;
|
|
|
|
|
|
if (scaleStart == null || !_controller.isReady) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Re-baseline on any pointer-count change (a finger lands/lifts, or a
|
|
|
|
|
|
// Windows touch 2↔1↔2 dropout). Anchor to the CLEAN tracked scale, not a
|
|
|
|
|
|
// matrix read-back, so the displayed scale is continuous; skip this frame.
|
|
|
|
|
|
if (details.pointerCount != _pinchPointerCount) {
|
|
|
|
|
|
_pinchPointerCount = details.pointerCount;
|
|
|
|
|
|
_pinchScaleStart = _pinchLastAppliedScale;
|
|
|
|
|
|
_pinchLastRawScale = details.scale;
|
|
|
|
|
|
_pinchRawScaleAtBaseline = details.scale;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Per-frame finger-motion ratio from the recognizer's OWN cumulative scale.
|
|
|
|
|
|
// A ratio outside the glitch band is a multi-touch spike → drop the frame;
|
|
|
|
|
|
// absolute tracking means the next good frame resumes from the true span.
|
|
|
|
|
|
final rawRatio =
|
|
|
|
|
|
_pinchLastRawScale > 0 ? details.scale / _pinchLastRawScale : 1.0;
|
|
|
|
|
|
final scaleDrop =
|
|
|
|
|
|
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
|
|
|
|
|
|
final focalDrop = details.focalPointDelta.distance > _kFocalGlitchPx;
|
2026-08-05 19:52:15 +08:00
|
|
|
|
if (scaleDrop || focalDrop) {
|
|
|
|
|
|
InputDiagnostics.instance.recordScaleFrame(
|
|
|
|
|
|
rawScale: details.scale,
|
|
|
|
|
|
pointerCount: details.pointerCount,
|
|
|
|
|
|
currentScale: _pinchLastAppliedScale,
|
|
|
|
|
|
appliedChange: 1.0,
|
|
|
|
|
|
focalJumpPx: details.focalPointDelta.distance,
|
|
|
|
|
|
scaleDrop: scaleDrop,
|
|
|
|
|
|
focalDrop: focalDrop,
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-24 20:18:22 +08:00
|
|
|
|
|
|
|
|
|
|
final targetScale = absolutePinchScale(
|
|
|
|
|
|
scaleStart: _pinchScaleStart!,
|
|
|
|
|
|
rawScaleAtBaseline: _pinchRawScaleAtBaseline,
|
|
|
|
|
|
rawScale: details.scale,
|
|
|
|
|
|
minScale: _kPinchMinScale,
|
|
|
|
|
|
maxScale: _kPinchMaxScale,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Focal zoom: keep the document point under the live focal (finger midpoint)
|
|
|
|
|
|
// fixed, which also yields 2-finger pan for free as the focal moves.
|
|
|
|
|
|
// localFocalPoint is in the viewer's local coords (the overlay fills it).
|
|
|
|
|
|
_controller.zoomOnLocalPosition(
|
|
|
|
|
|
localPosition: details.localFocalPoint,
|
|
|
|
|
|
newZoom: targetScale,
|
|
|
|
|
|
duration: Duration.zero,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-08-05 19:52:15 +08:00
|
|
|
|
final applied =
|
|
|
|
|
|
_pinchLastAppliedScale > 0 ? targetScale / _pinchLastAppliedScale : 1.0;
|
|
|
|
|
|
InputDiagnostics.instance.recordScaleFrame(
|
|
|
|
|
|
rawScale: details.scale,
|
|
|
|
|
|
pointerCount: details.pointerCount,
|
|
|
|
|
|
currentScale: targetScale,
|
|
|
|
|
|
appliedChange: applied,
|
|
|
|
|
|
focalJumpPx: details.focalPointDelta.distance,
|
|
|
|
|
|
scaleDrop: false,
|
|
|
|
|
|
focalDrop: false,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-24 20:18:22 +08:00
|
|
|
|
_pinchLastRawScale = details.scale;
|
|
|
|
|
|
_pinchLastAppliedScale = targetScale;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _onPinchEnd(ScaleEndDetails details) {
|
|
|
|
|
|
_pinchScaleStart = null;
|
|
|
|
|
|
_pinchPointerCount = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
// ── Text selection → highlight ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
void _onTextSelectionChange(PdfTextSelection selection) {
|
|
|
|
|
|
final has = selection.textSelectionPointRange != null;
|
|
|
|
|
|
if (has != _hasSelection && mounted) {
|
|
|
|
|
|
setState(() => _hasSelection = has);
|
2026-06-22 02:10:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
/// Read the current text selection, convert each selected fragment's PDF
|
|
|
|
|
|
/// rectangle to a NORMALIZED page rect, store it in [_highlightsByPage], and
|
|
|
|
|
|
/// clear the selection so the highlight is visible.
|
|
|
|
|
|
Future<void> _highlightSelection() async {
|
|
|
|
|
|
if (!_controller.isReady) return;
|
|
|
|
|
|
final delegate = _controller.textSelectionDelegate;
|
|
|
|
|
|
final ranges = await delegate.getSelectedTextRanges();
|
|
|
|
|
|
if (!mounted || ranges.isEmpty) return;
|
|
|
|
|
|
|
|
|
|
|
|
final added = <int, List<Rect>>{};
|
|
|
|
|
|
final doc = _controller.document;
|
|
|
|
|
|
for (final range in ranges) {
|
|
|
|
|
|
final pageIndex = range.pageNumber - 1;
|
|
|
|
|
|
if (pageIndex < 0 || pageIndex >= doc.pages.length) continue;
|
|
|
|
|
|
final page = doc.pages[pageIndex];
|
|
|
|
|
|
final w = page.width;
|
|
|
|
|
|
final h = page.height;
|
|
|
|
|
|
if (w <= 0 || h <= 0) continue;
|
|
|
|
|
|
for (final frag in range.enumerateFragmentBoundingRects()) {
|
|
|
|
|
|
// PDF-page-coords rect → unscaled Flutter page-pixel rect (scale 1 ⇒
|
|
|
|
|
|
// page.size), then normalize by page size so it stays glued under zoom.
|
|
|
|
|
|
final r = frag.bounds.toRect(page: page);
|
|
|
|
|
|
final norm = Rect.fromLTRB(
|
|
|
|
|
|
(r.left / w).clamp(0.0, 1.0),
|
|
|
|
|
|
(r.top / h).clamp(0.0, 1.0),
|
|
|
|
|
|
(r.right / w).clamp(0.0, 1.0),
|
|
|
|
|
|
(r.bottom / h).clamp(0.0, 1.0),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (norm.width <= 0 || norm.height <= 0) continue;
|
|
|
|
|
|
(added[pageIndex] ??= <Rect>[]).add(norm);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (added.isEmpty) return;
|
|
|
|
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
for (final entry in added.entries) {
|
|
|
|
|
|
(_highlightsByPage[entry.key] ??= <Rect>[]).addAll(entry.value);
|
|
|
|
|
|
}
|
|
|
|
|
|
_hasSelection = false;
|
|
|
|
|
|
});
|
2026-06-24 21:03:28 +08:00
|
|
|
|
// Persist each touched page's highlights to the sidecar (Phase 2 — closes
|
|
|
|
|
|
// the old in-memory-only limitation; highlights now survive reopen).
|
|
|
|
|
|
for (final pageIndex in added.keys) {
|
|
|
|
|
|
_scheduleHighlightSave(pageIndex);
|
|
|
|
|
|
}
|
2026-06-24 02:32:41 +08:00
|
|
|
|
await delegate.clearTextSelection();
|
|
|
|
|
|
_bumpOverlay();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 21:03:28 +08:00
|
|
|
|
/// Serialize the current highlights for [pageIndex] to the sidecar.
|
|
|
|
|
|
void _scheduleHighlightSave(int pageIndex) {
|
|
|
|
|
|
final repo = _repo;
|
|
|
|
|
|
if (repo == null) return;
|
|
|
|
|
|
final rects = _highlightsByPage[pageIndex] ?? const <Rect>[];
|
|
|
|
|
|
repo.scheduleHighlightSave(
|
|
|
|
|
|
pageIndex,
|
|
|
|
|
|
rects.map((r) => SidecarHighlight.fromRect(r)).toList(),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Remove the highlight rect on [pageIndex] under the normalized point
|
|
|
|
|
|
/// [normalized] (topmost hit), persisting the change. Returns true if one was
|
|
|
|
|
|
/// removed. This is the "un-highlight" action (a stored highlight could not be
|
|
|
|
|
|
/// removed before): in SELECT-TEXT mode a tap on an existing highlight erases
|
|
|
|
|
|
/// it.
|
|
|
|
|
|
bool _removeHighlightAt(int pageIndex, Offset normalized) {
|
|
|
|
|
|
final rects = _highlightsByPage[pageIndex];
|
|
|
|
|
|
if (rects == null || rects.isEmpty) return false;
|
|
|
|
|
|
for (var i = rects.length - 1; i >= 0; i--) {
|
|
|
|
|
|
if (rects[i].contains(normalized)) {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
final next = List<Rect>.of(rects)..removeAt(i);
|
|
|
|
|
|
if (next.isEmpty) {
|
|
|
|
|
|
_highlightsByPage.remove(pageIndex);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_highlightsByPage[pageIndex] = next;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
_scheduleHighlightSave(pageIndex);
|
|
|
|
|
|
_bumpOverlay();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 00:11:45 +08:00
|
|
|
|
// ── Typed text annotations (PDF editor only for now) ────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/// Serialize the current text annotations for [pageIndex] to the sidecar.
|
|
|
|
|
|
void _scheduleTextsSave(int pageIndex) {
|
|
|
|
|
|
final repo = _repo;
|
|
|
|
|
|
if (repo == null) return;
|
|
|
|
|
|
repo.scheduleTextsSave(
|
|
|
|
|
|
pageIndex,
|
|
|
|
|
|
List<SidecarText>.of(_textsByPage[pageIndex] ?? const <SidecarText>[]),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Create a new text box at normalized [normalized] on [pageIndex] and focus
|
|
|
|
|
|
/// it for input. (Not undoable for this increment — see report; a blank box
|
|
|
|
|
|
/// self-deletes on blur, so a stray placement leaves no residue.)
|
|
|
|
|
|
void _placeTextBox(int pageIndex, Offset normalized) {
|
|
|
|
|
|
final id = _uuid.v4();
|
|
|
|
|
|
final box = SidecarText(
|
|
|
|
|
|
id: id,
|
|
|
|
|
|
nx: normalized.dx.clamp(0.0, 1.0),
|
|
|
|
|
|
ny: normalized.dy.clamp(0.0, 1.0),
|
|
|
|
|
|
text: '',
|
|
|
|
|
|
fontSize: _kDefaultTextFontFraction,
|
|
|
|
|
|
color: _color.toARGB32(),
|
2026-08-05 20:27:35 +08:00
|
|
|
|
fontFamily: kAnnotationFontFamily,
|
2026-06-25 00:11:45 +08:00
|
|
|
|
);
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_textsByPage[pageIndex] = [...?_textsByPage[pageIndex], box];
|
|
|
|
|
|
_editingText = (page: pageIndex, id: id);
|
|
|
|
|
|
});
|
|
|
|
|
|
_bumpOverlay();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Open an existing text box [id] on [pageIndex] for editing.
|
|
|
|
|
|
void _editTextBox(int pageIndex, String id) {
|
|
|
|
|
|
setState(() => _editingText = (page: pageIndex, id: id));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Live edit: replace the editing box's text. Persisted (debounced) so the
|
|
|
|
|
|
/// content survives a crash mid-typing.
|
|
|
|
|
|
void _updateEditingText(String text) {
|
|
|
|
|
|
final editing = _editingText;
|
|
|
|
|
|
if (editing == null) return;
|
|
|
|
|
|
final list = _textsByPage[editing.page];
|
|
|
|
|
|
if (list == null) return;
|
|
|
|
|
|
final idx = list.indexWhere((t) => t.id == editing.id);
|
|
|
|
|
|
if (idx == -1) return;
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
final next = List<SidecarText>.of(list);
|
|
|
|
|
|
next[idx] = next[idx].copyWith(text: text);
|
|
|
|
|
|
_textsByPage[editing.page] = next;
|
|
|
|
|
|
});
|
|
|
|
|
|
_scheduleTextsSave(editing.page);
|
|
|
|
|
|
_bumpOverlay();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Finish editing (field blur / tool change): if the box is empty it is
|
|
|
|
|
|
/// removed (empty-on-blur deletes); otherwise the committed text is persisted.
|
|
|
|
|
|
void _finishTextEdit() {
|
|
|
|
|
|
final editing = _editingText;
|
|
|
|
|
|
if (editing == null) return;
|
|
|
|
|
|
final list = _textsByPage[editing.page];
|
|
|
|
|
|
if (list != null) {
|
|
|
|
|
|
final idx = list.indexWhere((t) => t.id == editing.id);
|
|
|
|
|
|
if (idx != -1 && list[idx].text.trim().isEmpty) {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
final next = List<SidecarText>.of(list)..removeAt(idx);
|
|
|
|
|
|
if (next.isEmpty) {
|
|
|
|
|
|
_textsByPage.remove(editing.page);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_textsByPage[editing.page] = next;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
_scheduleTextsSave(editing.page);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
setState(() => _editingText = null);
|
|
|
|
|
|
_bumpOverlay();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 20:27:35 +08:00
|
|
|
|
/// TEXT tool drag-to-move: nudge box [id] on [pageIndex] by [deltaPx] (a
|
|
|
|
|
|
/// screen-pixel pan delta), converting to a normalized delta via the page's
|
|
|
|
|
|
/// on-screen [pageW]/[pageH]. Persists (debounced) like other text edits.
|
|
|
|
|
|
void _dragTextBox(
|
|
|
|
|
|
int pageIndex,
|
|
|
|
|
|
String id,
|
|
|
|
|
|
Offset deltaPx,
|
|
|
|
|
|
double pageW,
|
|
|
|
|
|
double pageH,
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (pageW <= 0 || pageH <= 0) return;
|
|
|
|
|
|
final list = _textsByPage[pageIndex];
|
|
|
|
|
|
if (list == null) return;
|
|
|
|
|
|
final idx = list.indexWhere((t) => t.id == id);
|
|
|
|
|
|
if (idx == -1) return;
|
|
|
|
|
|
final t = list[idx];
|
|
|
|
|
|
final nx = (t.nx + deltaPx.dx / pageW).clamp(0.0, 1.0);
|
|
|
|
|
|
final ny = (t.ny + deltaPx.dy / pageH).clamp(0.0, 1.0);
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
final next = List<SidecarText>.of(list);
|
|
|
|
|
|
next[idx] = t.copyWith(nx: nx, ny: ny);
|
|
|
|
|
|
_textsByPage[pageIndex] = next;
|
|
|
|
|
|
});
|
|
|
|
|
|
_scheduleTextsSave(pageIndex);
|
|
|
|
|
|
_bumpOverlay();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Live style edit for the box currently being edited: updates its
|
|
|
|
|
|
/// page-relative [fontSize] fraction and/or numeric [fontWeight] via
|
|
|
|
|
|
/// copyWith, persisting (debounced) like [_updateEditingText].
|
|
|
|
|
|
void _updateEditingStyle({double? fontSize, int? fontWeight}) {
|
|
|
|
|
|
final editing = _editingText;
|
|
|
|
|
|
if (editing == null) return;
|
|
|
|
|
|
final list = _textsByPage[editing.page];
|
|
|
|
|
|
if (list == null) return;
|
|
|
|
|
|
final idx = list.indexWhere((t) => t.id == editing.id);
|
|
|
|
|
|
if (idx == -1) return;
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
final next = List<SidecarText>.of(list);
|
|
|
|
|
|
next[idx] = next[idx].copyWith(
|
|
|
|
|
|
fontSize: fontSize,
|
|
|
|
|
|
fontWeight: fontWeight,
|
|
|
|
|
|
);
|
|
|
|
|
|
_textsByPage[editing.page] = next;
|
|
|
|
|
|
});
|
|
|
|
|
|
_scheduleTextsSave(editing.page);
|
|
|
|
|
|
_bumpOverlay();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 00:11:45 +08:00
|
|
|
|
/// Toggle the TEXT tool (drops [_editingText] when leaving, so a half-typed
|
|
|
|
|
|
/// box gets the empty-on-blur treatment).
|
|
|
|
|
|
void _toggleTextMode() {
|
|
|
|
|
|
if (_textMode) {
|
|
|
|
|
|
_finishTextEdit();
|
|
|
|
|
|
setState(() => _textMode = false);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_setTool(EditorToolKind.text);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
// ── Navigation / tools ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
void _goToPage(int index) {
|
|
|
|
|
|
if (_pageCount == 0) return;
|
|
|
|
|
|
final clamped = index.clamp(0, _pageCount - 1);
|
|
|
|
|
|
_controller.goToPage(pageNumber: clamped + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
void _setTool(EditorToolKind tool) {
|
2026-06-24 02:32:41 +08:00
|
|
|
|
setState(() {
|
|
|
|
|
|
_tool = tool;
|
2026-08-05 19:52:15 +08:00
|
|
|
|
_selectTextTool = false;
|
2026-06-24 20:02:12 +08:00
|
|
|
|
_placeLinkMode = false;
|
2026-06-24 21:03:28 +08:00
|
|
|
|
_removeHighlightMode = false;
|
2026-06-25 00:11:45 +08:00
|
|
|
|
// The TEXT tool is the one EditorToolKind that drives a page-anchored
|
|
|
|
|
|
// (non-ink) interaction, so it owns the _textMode flag.
|
|
|
|
|
|
_textMode = tool == EditorToolKind.text;
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
if (tool != EditorToolKind.select) _selected = null;
|
2026-06-24 02:32:41 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _enableSelectText() {
|
2026-06-24 20:02:12 +08:00
|
|
|
|
setState(() {
|
2026-08-05 19:52:15 +08:00
|
|
|
|
_selectTextTool = true;
|
2026-06-24 20:02:12 +08:00
|
|
|
|
_placeLinkMode = false;
|
2026-06-24 21:03:28 +08:00
|
|
|
|
_removeHighlightMode = false;
|
2026-06-25 00:11:45 +08:00
|
|
|
|
_textMode = false;
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
_selected = null;
|
2026-08-05 19:52:15 +08:00
|
|
|
|
_expandedSticky = null;
|
2026-06-24 20:02:12 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Toggle "place scratch link" mode. While active, a tap on a page drops a
|
|
|
|
|
|
/// new anchor at the tapped normalized position.
|
|
|
|
|
|
void _togglePlaceLinkMode() {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_placeLinkMode = !_placeLinkMode;
|
2026-06-24 21:03:28 +08:00
|
|
|
|
if (_placeLinkMode) {
|
2026-08-05 19:52:15 +08:00
|
|
|
|
_selectTextTool = false;
|
2026-06-24 21:03:28 +08:00
|
|
|
|
_removeHighlightMode = false;
|
2026-06-25 00:11:45 +08:00
|
|
|
|
_textMode = false;
|
2026-08-05 19:52:15 +08:00
|
|
|
|
_expandedSticky = null;
|
2026-06-24 21:03:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Toggle "remove highlight" mode (the un-highlight action). While active, a
|
|
|
|
|
|
/// tap on an existing highlight deletes it (persisted to the sidecar).
|
|
|
|
|
|
void _toggleRemoveHighlightMode() {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_removeHighlightMode = !_removeHighlightMode;
|
|
|
|
|
|
if (_removeHighlightMode) {
|
2026-08-05 19:52:15 +08:00
|
|
|
|
_selectTextTool = false;
|
2026-06-24 21:03:28 +08:00
|
|
|
|
_placeLinkMode = false;
|
2026-06-25 00:11:45 +08:00
|
|
|
|
_textMode = false;
|
2026-06-24 21:03:28 +08:00
|
|
|
|
_selected = null;
|
2026-08-05 19:52:15 +08:00
|
|
|
|
_expandedSticky = null;
|
2026-06-24 21:03:28 +08:00
|
|
|
|
}
|
2026-06-24 20:02:12 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Scratch-link anchors ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/// Create + persist a new anchor at normalized [normalized] on [pageIndex],
|
|
|
|
|
|
/// then show it. Leaves place-link mode on so several can be dropped in a row.
|
|
|
|
|
|
Future<void> _placeScratchLink(int pageIndex, Offset normalized) async {
|
|
|
|
|
|
final link = ScratchLink(
|
|
|
|
|
|
id: _uuid.v4(),
|
2026-06-24 21:03:28 +08:00
|
|
|
|
// The source file path is the identity now (the sidecar IS the identity);
|
|
|
|
|
|
// documentId is retained on the model only so the split view can address
|
|
|
|
|
|
// the source PDF. Use the file path so it's stable across reopen.
|
|
|
|
|
|
documentId: widget.pdfPath,
|
2026-06-24 20:02:12 +08:00
|
|
|
|
pageIndex: pageIndex,
|
|
|
|
|
|
nx: normalized.dx.clamp(0.0, 1.0),
|
|
|
|
|
|
ny: normalized.dy.clamp(0.0, 1.0),
|
|
|
|
|
|
);
|
2026-06-24 21:03:28 +08:00
|
|
|
|
_repo?.scheduleScratchLinkUpsert(link);
|
2026-06-24 20:02:12 +08:00
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
setState(() => _scratchLinks.add(link));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 19:52:15 +08:00
|
|
|
|
/// Expand the sticky overlay on-page (paper sticky UX). Uses the SAME
|
|
|
|
|
|
/// [SidecarRepository] — no second open, no split-view race.
|
2026-06-24 21:03:28 +08:00
|
|
|
|
Future<void> _openScratchLink(ScratchLink link) async {
|
2026-08-05 19:52:15 +08:00
|
|
|
|
setState(() {
|
|
|
|
|
|
_expandedSticky = link;
|
|
|
|
|
|
_selectTextTool = false;
|
|
|
|
|
|
_placeLinkMode = false;
|
|
|
|
|
|
_removeHighlightMode = false;
|
|
|
|
|
|
_textMode = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _closeSticky() {
|
2026-06-24 21:03:28 +08:00
|
|
|
|
if (!mounted) return;
|
2026-08-05 19:52:15 +08:00
|
|
|
|
setState(() => _expandedSticky = null);
|
2026-06-24 20:02:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Confirm + delete an anchor (and its private scratchpad).
|
|
|
|
|
|
Future<void> _confirmDeleteScratchLink(ScratchLink link) async {
|
|
|
|
|
|
final l = AppLocalizations.of(context);
|
|
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
|
|
|
|
context: context,
|
|
|
|
|
|
builder: (ctx) => AlertDialog(
|
|
|
|
|
|
title: Text(l.scratchLinkDeleteTitle),
|
|
|
|
|
|
content: Text(l.scratchLinkDeleteBody),
|
|
|
|
|
|
actions: [
|
|
|
|
|
|
TextButton(
|
|
|
|
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
|
|
|
|
child: Text(l.cancel),
|
|
|
|
|
|
),
|
|
|
|
|
|
TextButton(
|
|
|
|
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
|
|
|
|
child: Text(l.delete),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (confirmed != true) return;
|
2026-06-24 21:03:28 +08:00
|
|
|
|
_repo?.scheduleScratchLinkDelete(link.id);
|
2026-06-24 20:02:12 +08:00
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
setState(() => _scratchLinks.removeWhere((s) => s.id == link.id));
|
2026-06-24 02:32:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 00:00:16 +08:00
|
|
|
|
// ── Bookmarks (paragraph-precise) ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/// Add a bookmark at a PRECISE location. Prefers the current text selection's
|
|
|
|
|
|
/// START fragment (page + normalized rect + char index = the paragraph) so the
|
|
|
|
|
|
/// bookmark lands on the exact paragraph; falls back to the current page's top
|
|
|
|
|
|
/// when there is no selection. The label is the selected-text snippet
|
|
|
|
|
|
/// (truncated) or "Page N".
|
|
|
|
|
|
Future<void> _addBookmark() async {
|
|
|
|
|
|
if (!_controller.isReady) return;
|
|
|
|
|
|
final l = AppLocalizations.of(context);
|
|
|
|
|
|
|
|
|
|
|
|
int pageNumber = _pageIndex + 1; // 1-based
|
|
|
|
|
|
double? aLeft, aTop, aRight, aBottom;
|
|
|
|
|
|
int? charIndex;
|
|
|
|
|
|
String label = '';
|
|
|
|
|
|
|
|
|
|
|
|
if (_hasSelection) {
|
|
|
|
|
|
final delegate = _controller.textSelectionDelegate;
|
|
|
|
|
|
final ranges = await delegate.getSelectedTextRanges();
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
if (ranges.isNotEmpty) {
|
|
|
|
|
|
final range = ranges.first;
|
|
|
|
|
|
final doc = _controller.document;
|
|
|
|
|
|
final pageIndex = range.pageNumber - 1;
|
|
|
|
|
|
if (pageIndex >= 0 && pageIndex < doc.pages.length) {
|
|
|
|
|
|
final page = doc.pages[pageIndex];
|
|
|
|
|
|
final w = page.width;
|
|
|
|
|
|
final h = page.height;
|
|
|
|
|
|
if (w > 0 && h > 0) {
|
|
|
|
|
|
// First fragment's bounding rect → normalized page rect (top-left
|
|
|
|
|
|
// origin), exactly as _highlightSelection normalizes highlight
|
|
|
|
|
|
// rects.
|
|
|
|
|
|
for (final frag in range.enumerateFragmentBoundingRects()) {
|
|
|
|
|
|
final r = frag.bounds.toRect(page: page);
|
|
|
|
|
|
aLeft = (r.left / w).clamp(0.0, 1.0);
|
|
|
|
|
|
aTop = (r.top / h).clamp(0.0, 1.0);
|
|
|
|
|
|
aRight = (r.right / w).clamp(0.0, 1.0);
|
|
|
|
|
|
aBottom = (r.bottom / h).clamp(0.0, 1.0);
|
|
|
|
|
|
break; // anchor to the FIRST fragment (the selection start).
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
pageNumber = range.pageNumber;
|
|
|
|
|
|
charIndex = range.start;
|
|
|
|
|
|
final text = range.text.trim().replaceAll(RegExp(r'\s+'), ' ');
|
|
|
|
|
|
if (text.isNotEmpty) {
|
|
|
|
|
|
label = text.length > 60 ? '${text.substring(0, 60)}…' : text;
|
|
|
|
|
|
}
|
|
|
|
|
|
await delegate.clearTextSelection();
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
setState(() => _hasSelection = false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (label.isEmpty) label = l.bookmarkDefaultLabel(pageNumber);
|
|
|
|
|
|
|
|
|
|
|
|
final bookmark = Bookmark(
|
|
|
|
|
|
id: _uuid.v4(),
|
|
|
|
|
|
// The source file path is the identity (the sidecar IS the identity).
|
|
|
|
|
|
documentId: widget.pdfPath,
|
|
|
|
|
|
pageNumber: pageNumber,
|
|
|
|
|
|
label: label,
|
|
|
|
|
|
createdAt: DateTime.now().toUtc(),
|
|
|
|
|
|
anchorLeft: aLeft,
|
|
|
|
|
|
anchorTop: aTop,
|
|
|
|
|
|
anchorRight: aRight,
|
|
|
|
|
|
anchorBottom: aBottom,
|
|
|
|
|
|
charIndex: charIndex,
|
|
|
|
|
|
);
|
|
|
|
|
|
_repo?.scheduleBookmarkUpsert(bookmark);
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
setState(() => _bookmarks.add(bookmark));
|
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
SnackBar(
|
|
|
|
|
|
content: Text(bookmark.label),
|
|
|
|
|
|
duration: const Duration(seconds: 2),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Jump to a bookmark: scroll to its page and, when it carries a normalized
|
|
|
|
|
|
/// in-page anchor rect, to that exact paragraph (via goToRectInsidePage).
|
|
|
|
|
|
/// PDF page coords have a BOTTOM-left origin (Y up), so the stored top-left
|
|
|
|
|
|
/// normalized rect is flipped on Y when reconstructing the PdfRect.
|
|
|
|
|
|
Future<void> _goToBookmark(Bookmark bookmark) async {
|
|
|
|
|
|
if (!_controller.isReady) return;
|
|
|
|
|
|
final pageNumber = bookmark.pageNumber.clamp(1, _pageCount);
|
|
|
|
|
|
final top = bookmark.anchorTop;
|
|
|
|
|
|
final left = bookmark.anchorLeft;
|
|
|
|
|
|
if (top == null || left == null) {
|
|
|
|
|
|
_controller.goToPage(pageNumber: pageNumber);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
final page = _controller.document.pages[pageNumber - 1];
|
|
|
|
|
|
final w = page.width;
|
|
|
|
|
|
final h = page.height;
|
|
|
|
|
|
final right = bookmark.anchorRight ?? left;
|
|
|
|
|
|
final bottom = bookmark.anchorBottom ?? top;
|
|
|
|
|
|
// Flutter (y-down) normalized → PDF (y-up) page coords.
|
|
|
|
|
|
final pdfRect = PdfRect(
|
|
|
|
|
|
(left * w).clamp(0.0, w),
|
|
|
|
|
|
((1.0 - top) * h).clamp(0.0, h), // pdf top (bigger)
|
|
|
|
|
|
(right * w).clamp(0.0, w),
|
|
|
|
|
|
((1.0 - bottom) * h).clamp(0.0, h), // pdf bottom (smaller)
|
|
|
|
|
|
);
|
|
|
|
|
|
await _controller.goToRectInsidePage(
|
|
|
|
|
|
pageNumber: pageNumber,
|
|
|
|
|
|
rect: pdfRect,
|
|
|
|
|
|
anchor: PdfPageAnchor.top,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Confirm + delete a bookmark (persisted).
|
|
|
|
|
|
Future<void> _confirmDeleteBookmark(Bookmark bookmark) async {
|
|
|
|
|
|
final l = AppLocalizations.of(context);
|
|
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
|
|
|
|
context: context,
|
|
|
|
|
|
builder: (ctx) => AlertDialog(
|
|
|
|
|
|
title: Text(l.bookmarkDeleteTitle),
|
|
|
|
|
|
content: Text(l.bookmarkDeleteBody),
|
|
|
|
|
|
actions: [
|
|
|
|
|
|
TextButton(
|
|
|
|
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
|
|
|
|
child: Text(l.cancel),
|
|
|
|
|
|
),
|
|
|
|
|
|
TextButton(
|
|
|
|
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
|
|
|
|
child: Text(l.delete),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (confirmed != true) return;
|
|
|
|
|
|
_repo?.scheduleBookmarkDelete(bookmark.id);
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
setState(() => _bookmarks.removeWhere((b) => b.id == bookmark.id));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Open the bookmarks panel (a bottom sheet): each entry shows its label +
|
|
|
|
|
|
/// page; tap → jump to the anchor; swipe to dismiss → delete (persisted).
|
|
|
|
|
|
void _openBookmarksPanel() {
|
|
|
|
|
|
final l = AppLocalizations.of(context);
|
|
|
|
|
|
showModalBottomSheet<void>(
|
|
|
|
|
|
context: context,
|
|
|
|
|
|
showDragHandle: true,
|
|
|
|
|
|
builder: (sheetContext) {
|
|
|
|
|
|
return SafeArea(
|
|
|
|
|
|
child: ConstrainedBox(
|
|
|
|
|
|
constraints: BoxConstraints(
|
|
|
|
|
|
maxHeight: MediaQuery.of(sheetContext).size.height * 0.6,
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Padding(
|
|
|
|
|
|
padding:
|
|
|
|
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
l.bookmarksTitle,
|
|
|
|
|
|
style: Theme.of(sheetContext).textTheme.titleMedium,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
if (_bookmarks.isEmpty)
|
|
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.all(24),
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
l.bookmarksEmpty,
|
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
color: Theme.of(sheetContext).colorScheme.outline,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
else
|
|
|
|
|
|
Flexible(
|
|
|
|
|
|
child: ListView.builder(
|
|
|
|
|
|
shrinkWrap: true,
|
|
|
|
|
|
itemCount: _bookmarks.length,
|
|
|
|
|
|
itemBuilder: (context, i) {
|
|
|
|
|
|
final bm = _bookmarks[i];
|
|
|
|
|
|
return Dismissible(
|
|
|
|
|
|
key: ValueKey(bm.id),
|
|
|
|
|
|
direction: DismissDirection.endToStart,
|
|
|
|
|
|
background: Container(
|
|
|
|
|
|
color: Theme.of(context).colorScheme.errorContainer,
|
|
|
|
|
|
alignment: Alignment.centerRight,
|
|
|
|
|
|
padding: const EdgeInsets.only(right: 24),
|
|
|
|
|
|
child: Icon(
|
|
|
|
|
|
Icons.delete_outline,
|
|
|
|
|
|
color:
|
|
|
|
|
|
Theme.of(context).colorScheme.onErrorContainer,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
onDismissed: (_) {
|
|
|
|
|
|
_repo?.scheduleBookmarkDelete(bm.id);
|
|
|
|
|
|
setState(
|
|
|
|
|
|
() => _bookmarks.removeWhere((b) => b.id == bm.id),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
child: ListTile(
|
|
|
|
|
|
leading: Icon(
|
|
|
|
|
|
Icons.bookmark,
|
|
|
|
|
|
color: Color(bm.color),
|
|
|
|
|
|
),
|
|
|
|
|
|
title: Text(
|
|
|
|
|
|
bm.label,
|
|
|
|
|
|
maxLines: 2,
|
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
|
),
|
|
|
|
|
|
subtitle: Text(l.bookmarkPageLabel(bm.pageNumber)),
|
|
|
|
|
|
onTap: () {
|
|
|
|
|
|
Navigator.pop(sheetContext);
|
|
|
|
|
|
_goToBookmark(bm);
|
|
|
|
|
|
},
|
|
|
|
|
|
onLongPress: () {
|
|
|
|
|
|
Navigator.pop(sheetContext);
|
|
|
|
|
|
_confirmDeleteBookmark(bm);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 23:56:38 +08:00
|
|
|
|
void _toggleFingerDrawing() {
|
|
|
|
|
|
final next = !_allowFingerDrawing;
|
|
|
|
|
|
setState(() => _allowFingerDrawing = next);
|
|
|
|
|
|
_penConfig?.setFingerDrawing(next);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _openThumbnails() {
|
2026-06-24 02:32:41 +08:00
|
|
|
|
final doc = _controller.isReady ? _controller.document : null;
|
2026-06-21 23:56:38 +08:00
|
|
|
|
if (doc == null) return;
|
|
|
|
|
|
showPageThumbnailSheet(
|
|
|
|
|
|
context,
|
|
|
|
|
|
document: doc,
|
|
|
|
|
|
currentPage: _pageIndex,
|
|
|
|
|
|
onPageSelected: _goToPage,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _openPenSettings() {
|
|
|
|
|
|
final config = _penConfig;
|
|
|
|
|
|
if (config == null) return;
|
|
|
|
|
|
showPenSettingsSheet(context, config);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
// ── Build ──────────────────────────────────────────────────────────────────
|
2026-06-21 22:04:49 +08:00
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-06-23 09:35:26 +08:00
|
|
|
|
final l = AppLocalizations.of(context);
|
2026-08-05 20:27:35 +08:00
|
|
|
|
final formatBar = _buildTextFormatBar();
|
2026-06-21 22:04:49 +08:00
|
|
|
|
return Scaffold(
|
2026-08-05 20:27:35 +08:00
|
|
|
|
body: pageNavShortcuts(
|
|
|
|
|
|
onPrevious:
|
|
|
|
|
|
_pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
|
|
|
|
|
|
onNext: _pageIndex < _pageCount - 1
|
|
|
|
|
|
? () => _goToPage(_pageIndex + 1)
|
|
|
|
|
|
: null,
|
|
|
|
|
|
onFirst: _pageCount > 0 ? () => _goToPage(0) : null,
|
|
|
|
|
|
onLast: _pageCount > 0 ? () => _goToPage(_pageCount - 1) : null,
|
|
|
|
|
|
child: Stack(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Positioned.fill(child: _buildViewer()),
|
|
|
|
|
|
// Floating Material You tool palette (top-center).
|
2026-06-21 22:04:49 +08:00
|
|
|
|
SafeArea(
|
|
|
|
|
|
child: Align(
|
2026-08-05 20:27:35 +08:00
|
|
|
|
alignment: Alignment.topCenter,
|
2026-06-21 22:04:49 +08:00
|
|
|
|
child: Padding(
|
2026-08-05 20:27:35 +08:00
|
|
|
|
padding: const EdgeInsets.only(top: 8),
|
|
|
|
|
|
child: _buildToolPalette(),
|
2026-06-21 22:04:49 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-08-05 20:27:35 +08:00
|
|
|
|
// Compact text-format bar (S/M/L + Bold), shown BELOW the tool
|
|
|
|
|
|
// palette while a text box is being edited.
|
|
|
|
|
|
if (formatBar != null)
|
|
|
|
|
|
SafeArea(
|
|
|
|
|
|
child: Align(
|
|
|
|
|
|
alignment: Alignment.topCenter,
|
|
|
|
|
|
child: Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(top: 68),
|
|
|
|
|
|
child: formatBar,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
// Floating page-control pill (bottom-center).
|
|
|
|
|
|
if (_viewerReady && _pageCount > 0)
|
|
|
|
|
|
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: l.back,
|
|
|
|
|
|
onPressed: () => Navigator.of(context).maybePop(),
|
|
|
|
|
|
),
|
2026-06-21 22:04:49 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-08-05 20:27:35 +08:00
|
|
|
|
if (_showPenDebug) _buildDebugReadout(context),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-06-21 22:04:49 +08:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
Widget _buildViewer() {
|
|
|
|
|
|
return PdfViewer.file(
|
|
|
|
|
|
widget.pdfPath,
|
|
|
|
|
|
controller: _controller,
|
|
|
|
|
|
params: PdfViewerParams(
|
2026-06-24 20:18:22 +08:00
|
|
|
|
// pdfrx keeps 1-finger scroll + wheel (panEnabled) but we TAKE OVER the
|
|
|
|
|
|
// 2-finger pinch (scaleEnabled:false): pdfrx's forked InteractiveViewer
|
|
|
|
|
|
// applies `desiredScale = _scaleStart * details.scale` with no per-frame
|
|
|
|
|
|
// glitch guard, so a Windows-touch scale spike or pointer-count blip pops
|
|
|
|
|
|
// the zoom and snaps back. We drive zoom ourselves via the glitch-guarded
|
|
|
|
|
|
// _TwoFingerPinch recognizer in viewerOverlayBuilder → controller.
|
|
|
|
|
|
// zoomOnLocalPosition (focal zoom). See _onPinchUpdate.
|
|
|
|
|
|
panEnabled: true,
|
|
|
|
|
|
scaleEnabled: false,
|
2026-06-24 02:32:41 +08:00
|
|
|
|
// Native vector text selection. Pen falls through to this only in
|
|
|
|
|
|
// select-text mode (PenCaptureRegion.captureEnabled == false).
|
|
|
|
|
|
textSelectionParams: PdfTextSelectionParams(
|
|
|
|
|
|
enabled: true,
|
|
|
|
|
|
onTextSelectionChange: _onTextSelectionChange,
|
|
|
|
|
|
),
|
|
|
|
|
|
onViewerReady: (document, controller) {
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_viewerReady = true;
|
|
|
|
|
|
_pageCount = document.pages.length;
|
2026-06-21 22:33:18 +08:00
|
|
|
|
});
|
2026-06-24 02:32:41 +08:00
|
|
|
|
// Honor a requested initial page (search-result jump), clamped.
|
|
|
|
|
|
final target = widget.initialPage.clamp(0, _pageCount - 1);
|
|
|
|
|
|
if (target > 0) {
|
|
|
|
|
|
controller.goToPage(pageNumber: target + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
onPageChanged: (pageNumber) {
|
|
|
|
|
|
if (pageNumber == null || !mounted) return;
|
|
|
|
|
|
final idx = pageNumber - 1;
|
|
|
|
|
|
if (idx != _pageIndex) setState(() => _pageIndex = idx);
|
|
|
|
|
|
},
|
|
|
|
|
|
// (1) Per-page overlay: committed ink + live stroke + highlights, all in
|
|
|
|
|
|
// normalized page space scaled to the on-screen page rect.
|
|
|
|
|
|
pageOverlaysBuilder: (context, pageRectInViewer, page) {
|
|
|
|
|
|
final pageIndex = page.pageNumber - 1;
|
2026-06-24 20:02:12 +08:00
|
|
|
|
final pageW = pageRectInViewer.width;
|
|
|
|
|
|
final pageH = pageRectInViewer.height;
|
|
|
|
|
|
final linksOnPage =
|
|
|
|
|
|
_scratchLinks.where((l) => l.pageIndex == pageIndex);
|
2026-06-24 02:32:41 +08:00
|
|
|
|
return [
|
|
|
|
|
|
Positioned.fill(
|
|
|
|
|
|
child: IgnorePointer(
|
|
|
|
|
|
child: CustomPaint(
|
|
|
|
|
|
painter: _PageOverlayPainter(
|
2026-06-24 20:18:22 +08:00
|
|
|
|
overlayRepaint: _overlayRepaint,
|
|
|
|
|
|
liveStrokeVN: _liveStrokeVN,
|
|
|
|
|
|
pageIndex: pageIndex,
|
2026-06-24 02:32:41 +08:00
|
|
|
|
strokes: _strokesByPage[pageIndex] ?? const [],
|
|
|
|
|
|
highlights: _highlightsByPage[pageIndex] ?? const [],
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
selectedIndex:
|
|
|
|
|
|
(_selected != null && _selected!.page == pageIndex)
|
|
|
|
|
|
? _selected!.index
|
|
|
|
|
|
: null,
|
2026-06-24 02:32:41 +08:00
|
|
|
|
pageSize: pageRectInViewer.size,
|
|
|
|
|
|
thinning: _penConfig?.value.pressureSensitivity ??
|
|
|
|
|
|
kDefaultPenThinning,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-24 20:02:12 +08:00
|
|
|
|
// Tap-to-place layer: only swallows taps while place-link mode is on.
|
|
|
|
|
|
// Otherwise it's a no-op (IgnorePointer) so ink/scroll fall through.
|
|
|
|
|
|
if (_placeLinkMode)
|
|
|
|
|
|
Positioned.fill(
|
|
|
|
|
|
child: GestureDetector(
|
|
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
|
|
onTapUp: (details) {
|
|
|
|
|
|
final local = details.localPosition;
|
|
|
|
|
|
if (pageW <= 0 || pageH <= 0) return;
|
|
|
|
|
|
final nx = (local.dx / pageW).clamp(0.0, 1.0);
|
|
|
|
|
|
final ny = (local.dy / pageH).clamp(0.0, 1.0);
|
|
|
|
|
|
_placeScratchLink(pageIndex, Offset(nx, ny));
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-24 21:03:28 +08:00
|
|
|
|
// Tap-to-remove-highlight layer (the un-highlight action): only
|
|
|
|
|
|
// swallows taps while remove-highlight mode is on; a tap on an
|
|
|
|
|
|
// existing highlight deletes it (persisted to the sidecar).
|
|
|
|
|
|
if (_removeHighlightMode)
|
|
|
|
|
|
Positioned.fill(
|
|
|
|
|
|
child: GestureDetector(
|
|
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
|
|
onTapUp: (details) {
|
|
|
|
|
|
final local = details.localPosition;
|
|
|
|
|
|
if (pageW <= 0 || pageH <= 0) return;
|
|
|
|
|
|
final nx = (local.dx / pageW).clamp(0.0, 1.0);
|
|
|
|
|
|
final ny = (local.dy / pageH).clamp(0.0, 1.0);
|
|
|
|
|
|
_removeHighlightAt(pageIndex, Offset(nx, ny));
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-25 00:11:45 +08:00
|
|
|
|
// Placement layer: while the TEXT tool is active, a pen-tap OR a
|
|
|
|
|
|
// mouse double-click on empty page space drops a new box. It sits
|
|
|
|
|
|
// BELOW the per-box labels in the stack so a tap that lands on an
|
|
|
|
|
|
// existing label edits it instead of placing a new box.
|
|
|
|
|
|
if (_textMode)
|
|
|
|
|
|
Positioned.fill(
|
|
|
|
|
|
child: _TextPlacementLayer(
|
|
|
|
|
|
onPlace: (local) {
|
|
|
|
|
|
if (pageW <= 0 || pageH <= 0) return;
|
|
|
|
|
|
final nx = (local.dx / pageW).clamp(0.0, 1.0);
|
|
|
|
|
|
final ny = (local.dy / pageH).clamp(0.0, 1.0);
|
|
|
|
|
|
_placeTextBox(pageIndex, Offset(nx, ny));
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
// Typed-text annotations (committed). Each non-editing box is a
|
|
|
|
|
|
// tappable label glued at (nx*pageW, ny*pageH) with page-scaled
|
|
|
|
|
|
// font. Tapping one re-opens it for editing. The box currently
|
|
|
|
|
|
// being edited is rendered as a TextField below instead.
|
|
|
|
|
|
for (final t in (_textsByPage[pageIndex] ?? const <SidecarText>[]))
|
|
|
|
|
|
if (!(_editingText?.page == pageIndex &&
|
|
|
|
|
|
_editingText?.id == t.id))
|
|
|
|
|
|
Positioned(
|
|
|
|
|
|
left: t.nx * pageW,
|
|
|
|
|
|
top: t.ny * pageH,
|
|
|
|
|
|
child: _TextAnnotationLabel(
|
|
|
|
|
|
text: t.text,
|
|
|
|
|
|
fontSizePx: t.fontSize * pageW,
|
|
|
|
|
|
color: Color(t.color),
|
2026-08-05 20:27:35 +08:00
|
|
|
|
fontWeight: _fontWeightFromValue(t.fontWeight),
|
|
|
|
|
|
fontFamily: t.fontFamily,
|
2026-06-25 00:11:45 +08:00
|
|
|
|
onTap: _textMode ? () => _editTextBox(pageIndex, t.id) : null,
|
2026-08-05 20:27:35 +08:00
|
|
|
|
// Double-tap always edits, even outside text mode.
|
|
|
|
|
|
onDoubleTap: () => _editTextBox(pageIndex, t.id),
|
|
|
|
|
|
onPanUpdate: _textMode
|
|
|
|
|
|
? (details) => _dragTextBox(
|
|
|
|
|
|
pageIndex,
|
|
|
|
|
|
t.id,
|
|
|
|
|
|
details.delta,
|
|
|
|
|
|
pageW,
|
|
|
|
|
|
pageH,
|
|
|
|
|
|
)
|
|
|
|
|
|
: null,
|
2026-06-25 00:11:45 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
// Active editing field for a box on this page: a real Flutter
|
|
|
|
|
|
// TextField so the OS IME + Windows-Ink handwriting panel work.
|
|
|
|
|
|
if (_editingText?.page == pageIndex)
|
|
|
|
|
|
for (final t in (_textsByPage[pageIndex] ?? const <SidecarText>[]))
|
|
|
|
|
|
if (t.id == _editingText!.id)
|
|
|
|
|
|
Positioned(
|
|
|
|
|
|
left: t.nx * pageW,
|
|
|
|
|
|
top: t.ny * pageH,
|
|
|
|
|
|
width: (pageW - t.nx * pageW).clamp(40.0, pageW),
|
|
|
|
|
|
child: _TextAnnotationField(
|
|
|
|
|
|
key: ValueKey('text-edit-${t.id}'),
|
|
|
|
|
|
initialText: t.text,
|
|
|
|
|
|
fontSizePx: t.fontSize * pageW,
|
|
|
|
|
|
color: Color(t.color),
|
2026-08-05 20:27:35 +08:00
|
|
|
|
fontWeight: _fontWeightFromValue(t.fontWeight),
|
|
|
|
|
|
fontFamily: t.fontFamily,
|
2026-06-25 00:11:45 +08:00
|
|
|
|
hintText: AppLocalizations.of(context).textPlaceholder,
|
|
|
|
|
|
onChanged: _updateEditingText,
|
|
|
|
|
|
onDone: _finishTextEdit,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-24 20:02:12 +08:00
|
|
|
|
// Anchor markers (sticky-note tabs): tap → split view, long-press →
|
|
|
|
|
|
// delete. Sized in screen px so the tap target stays usable at any
|
|
|
|
|
|
// zoom; positioned at (nx*pageW, ny*pageH).
|
|
|
|
|
|
for (final link in linksOnPage)
|
|
|
|
|
|
Positioned(
|
|
|
|
|
|
left: link.nx * pageW - _kMarkerSize / 2,
|
|
|
|
|
|
top: link.ny * pageH - _kMarkerSize / 2,
|
|
|
|
|
|
width: _kMarkerSize,
|
|
|
|
|
|
height: _kMarkerSize,
|
|
|
|
|
|
child: _ScratchLinkMarker(
|
|
|
|
|
|
onTap: () => _openScratchLink(link),
|
|
|
|
|
|
onLongPress: () => _confirmDeleteScratchLink(link),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-24 02:32:41 +08:00
|
|
|
|
];
|
|
|
|
|
|
},
|
2026-06-24 20:18:22 +08:00
|
|
|
|
// (2) Viewer-level pen capture + our glitch-guarded pinch. Stylus is
|
|
|
|
|
|
// captured ONLY when a pen tool is active; touch/mouse (and pen in
|
|
|
|
|
|
// select-text mode) fall through to pdfrx for scroll/text-selection.
|
|
|
|
|
|
// The pinch recognizer is touch-only and concedes the arena below 2
|
|
|
|
|
|
// pointers, so 1-finger scroll still reaches pdfrx and the pen (captured
|
|
|
|
|
|
// outside the arena by PenCaptureRegion) is never stolen.
|
2026-06-24 02:32:41 +08:00
|
|
|
|
viewerOverlayBuilder: (context, size, handleLinkTap) {
|
|
|
|
|
|
return [
|
2026-06-24 20:18:22 +08:00
|
|
|
|
Positioned.fill(
|
|
|
|
|
|
child: RawGestureDetector(
|
|
|
|
|
|
// translucent (NOT opaque): the touch must ALSO hit-test pdfrx
|
|
|
|
|
|
// underneath so its pan recognizer can win the 1-finger case.
|
|
|
|
|
|
behavior: HitTestBehavior.translucent,
|
|
|
|
|
|
gestures: {
|
|
|
|
|
|
_TwoFingerPinch:
|
|
|
|
|
|
GestureRecognizerFactoryWithHandlers<_TwoFingerPinch>(
|
|
|
|
|
|
() => _TwoFingerPinch(debugOwner: this),
|
|
|
|
|
|
(r) => r
|
|
|
|
|
|
..onStart = _onPinchStart
|
|
|
|
|
|
..onUpdate = _onPinchUpdate
|
|
|
|
|
|
..onEnd = _onPinchEnd,
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
child: const SizedBox.expand(),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-24 02:32:41 +08:00
|
|
|
|
Positioned.fill(
|
|
|
|
|
|
child: PenCaptureRegion(
|
|
|
|
|
|
captureEnabled: _penCaptureEnabled,
|
|
|
|
|
|
onPenEvent: _onPenEvent,
|
|
|
|
|
|
child: const IgnorePointer(child: SizedBox.expand()),
|
|
|
|
|
|
),
|
2026-06-21 22:04:49 +08:00
|
|
|
|
),
|
2026-08-05 19:52:15 +08:00
|
|
|
|
if (_hasSelection)
|
|
|
|
|
|
Positioned(
|
|
|
|
|
|
left: 16,
|
|
|
|
|
|
right: 16,
|
|
|
|
|
|
bottom: 24,
|
|
|
|
|
|
child: _SelectionActionBar(
|
|
|
|
|
|
onHighlight: _highlightSelection,
|
|
|
|
|
|
onBookmark: _addBookmark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
if (_expandedSticky != null && _repo != null)
|
|
|
|
|
|
Positioned(
|
|
|
|
|
|
right: 20,
|
|
|
|
|
|
top: 72,
|
|
|
|
|
|
child: StickyNoteOverlay(
|
|
|
|
|
|
key: ValueKey(_expandedSticky!.id),
|
|
|
|
|
|
link: _expandedSticky!,
|
|
|
|
|
|
repo: _repo!,
|
|
|
|
|
|
onClose: _closeSticky,
|
|
|
|
|
|
onDelete: () async {
|
|
|
|
|
|
final link = _expandedSticky!;
|
|
|
|
|
|
_closeSticky();
|
|
|
|
|
|
await _confirmDeleteScratchLink(link);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-06-24 02:32:41 +08:00
|
|
|
|
];
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
2026-06-21 22:04:49 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
/// Floating Material You tool palette.
|
2026-06-21 22:04:49 +08:00
|
|
|
|
Widget _buildToolPalette() {
|
|
|
|
|
|
final cs = Theme.of(context).colorScheme;
|
2026-06-23 09:35:26 +08:00
|
|
|
|
final l = AppLocalizations.of(context);
|
2026-06-21 22:04:49 +08:00
|
|
|
|
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: [
|
2026-06-24 11:13:43 +08:00
|
|
|
|
BrushPickerButton(
|
|
|
|
|
|
selected: _penBrush,
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
active: _tool == EditorToolKind.brush && _penCaptureEnabled,
|
2026-06-24 11:13:43 +08:00
|
|
|
|
tooltip: l.brushPicker,
|
|
|
|
|
|
labelFor: (b) => brushLabel(b, l),
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
colorFor: (b) => _brushColors[b] ?? Colors.black,
|
2026-06-24 11:13:43 +08:00
|
|
|
|
onSelected: (b) {
|
|
|
|
|
|
setState(() => _penBrush = b);
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
_setTool(EditorToolKind.brush);
|
2026-06-24 11:13:43 +08:00
|
|
|
|
},
|
2026-06-21 22:04:49 +08:00
|
|
|
|
),
|
2026-06-23 10:21:40 +08:00
|
|
|
|
ToolButton(
|
2026-06-21 22:04:49 +08:00
|
|
|
|
icon: Icons.brush_outlined,
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
selected: _tool == EditorToolKind.highlighter && _penCaptureEnabled,
|
2026-06-23 09:35:26 +08:00
|
|
|
|
tooltip: l.toolHighlighter,
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
onPressed: () => _setTool(EditorToolKind.highlighter),
|
2026-06-21 22:04:49 +08:00
|
|
|
|
),
|
2026-06-23 10:21:40 +08:00
|
|
|
|
ToolButton(
|
2026-06-21 22:04:49 +08:00
|
|
|
|
icon: Icons.cleaning_services_outlined,
|
2026-06-24 02:32:41 +08:00
|
|
|
|
selected: _isEraser,
|
2026-06-23 09:35:26 +08:00
|
|
|
|
tooltip: l.toolEraser,
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
onPressed: () => _setTool(EditorToolKind.eraser),
|
2026-06-24 02:32:41 +08:00
|
|
|
|
),
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
ToolButton(
|
|
|
|
|
|
icon: Icons.ads_click,
|
|
|
|
|
|
selected: _isSelect,
|
|
|
|
|
|
tooltip: l.toolSelect,
|
|
|
|
|
|
onPressed: () => _setTool(EditorToolKind.select),
|
|
|
|
|
|
),
|
|
|
|
|
|
ShapePickerButton(
|
|
|
|
|
|
selected: _shapeKind,
|
|
|
|
|
|
active: _isShape,
|
|
|
|
|
|
tooltip: l.shapePicker,
|
|
|
|
|
|
labelFor: (s) => shapeLabel(s, l),
|
|
|
|
|
|
onActivate: () => _setTool(EditorToolKind.shape),
|
|
|
|
|
|
onSelected: (s) {
|
|
|
|
|
|
setState(() => _shapeKind = s);
|
|
|
|
|
|
_setTool(EditorToolKind.shape);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
if (_isSelect && _selected != null)
|
|
|
|
|
|
ToolButton(
|
|
|
|
|
|
icon: Icons.delete_outline,
|
|
|
|
|
|
selected: false,
|
|
|
|
|
|
tooltip: l.actionDeleteSelection,
|
|
|
|
|
|
onPressed: _deleteSelected,
|
|
|
|
|
|
),
|
2026-06-25 00:11:45 +08:00
|
|
|
|
// Typed-text tool: pen-tap or mouse double-click drops a text box.
|
|
|
|
|
|
ToolButton(
|
|
|
|
|
|
icon: Icons.title,
|
|
|
|
|
|
selected: _textMode,
|
|
|
|
|
|
tooltip: l.toolText,
|
|
|
|
|
|
onPressed: _toggleTextMode,
|
|
|
|
|
|
),
|
2026-06-24 02:32:41 +08:00
|
|
|
|
PaletteDivider(cs: cs),
|
|
|
|
|
|
// Text selection + highlight (real vector text).
|
|
|
|
|
|
ToolButton(
|
|
|
|
|
|
icon: Icons.text_fields,
|
|
|
|
|
|
selected: _selectTextMode,
|
|
|
|
|
|
tooltip: l.toolSelectText,
|
|
|
|
|
|
onPressed: _enableSelectText,
|
|
|
|
|
|
),
|
|
|
|
|
|
ToolButton(
|
|
|
|
|
|
icon: Icons.highlight,
|
|
|
|
|
|
selected: false,
|
|
|
|
|
|
tooltip: l.actionHighlightSelection,
|
|
|
|
|
|
onPressed: _hasSelection ? _highlightSelection : null,
|
2026-06-21 22:04:49 +08:00
|
|
|
|
),
|
2026-06-24 21:03:28 +08:00
|
|
|
|
// Un-highlight: toggle a mode where tapping an existing highlight
|
|
|
|
|
|
// removes it (the user couldn't remove highlights before).
|
|
|
|
|
|
ToolButton(
|
|
|
|
|
|
icon: Icons.highlight_off,
|
|
|
|
|
|
selected: _removeHighlightMode,
|
|
|
|
|
|
tooltip: l.toolRemoveHighlight,
|
|
|
|
|
|
onPressed: _toggleRemoveHighlightMode,
|
|
|
|
|
|
),
|
2026-06-23 10:21:40 +08:00
|
|
|
|
PaletteDivider(cs: cs),
|
2026-06-24 20:02:12 +08:00
|
|
|
|
// Place scratch link (sticky-note tab). Tap a page to drop an anchor.
|
|
|
|
|
|
ToolButton(
|
|
|
|
|
|
icon: Icons.sticky_note_2_outlined,
|
|
|
|
|
|
selected: _placeLinkMode,
|
|
|
|
|
|
tooltip: l.toolPlaceScratchLink,
|
|
|
|
|
|
onPressed: _togglePlaceLinkMode,
|
|
|
|
|
|
),
|
|
|
|
|
|
PaletteDivider(cs: cs),
|
2026-06-25 00:00:16 +08:00
|
|
|
|
// Bookmark: add (selection-anchored if any, else current page) +
|
|
|
|
|
|
// open the bookmarks panel (list / jump-to / delete).
|
|
|
|
|
|
ToolButton(
|
|
|
|
|
|
icon: Icons.bookmark_add_outlined,
|
|
|
|
|
|
selected: false,
|
|
|
|
|
|
tooltip: l.toolAddBookmark,
|
|
|
|
|
|
onPressed: _viewerReady ? _addBookmark : null,
|
|
|
|
|
|
),
|
|
|
|
|
|
ToolButton(
|
|
|
|
|
|
icon: Icons.bookmarks_outlined,
|
|
|
|
|
|
selected: false,
|
|
|
|
|
|
tooltip: l.toolBookmarks,
|
|
|
|
|
|
onPressed: _viewerReady ? _openBookmarksPanel : null,
|
|
|
|
|
|
),
|
|
|
|
|
|
PaletteDivider(cs: cs),
|
2026-06-21 23:56:38 +08:00
|
|
|
|
// Undo / redo (per page).
|
2026-06-23 10:21:40 +08:00
|
|
|
|
ToolButton(
|
2026-06-21 23:56:38 +08:00
|
|
|
|
icon: Icons.undo,
|
|
|
|
|
|
selected: false,
|
2026-06-23 09:35:26 +08:00
|
|
|
|
tooltip: l.actionUndo,
|
2026-06-21 23:56:38 +08:00
|
|
|
|
onPressed: _undoFor(_pageIndex).canUndo ? _performUndo : null,
|
|
|
|
|
|
),
|
2026-06-23 10:21:40 +08:00
|
|
|
|
ToolButton(
|
2026-06-21 23:56:38 +08:00
|
|
|
|
icon: Icons.redo,
|
|
|
|
|
|
selected: false,
|
2026-06-23 09:35:26 +08:00
|
|
|
|
tooltip: l.actionRedo,
|
2026-06-21 23:56:38 +08:00
|
|
|
|
onPressed: _undoFor(_pageIndex).canRedo ? _performRedo : null,
|
|
|
|
|
|
),
|
2026-06-23 10:21:40 +08:00
|
|
|
|
PaletteDivider(cs: cs),
|
2026-06-21 22:04:49 +08:00
|
|
|
|
for (final c in _palette) _colorDot(c, cs),
|
2026-06-23 10:21:40 +08:00
|
|
|
|
PaletteDivider(cs: cs),
|
|
|
|
|
|
ToolButton(
|
2026-06-21 22:04:49 +08:00
|
|
|
|
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
|
|
|
|
|
|
selected: _allowFingerDrawing,
|
2026-06-24 02:32:41 +08:00
|
|
|
|
tooltip:
|
|
|
|
|
|
_allowFingerDrawing ? l.fingerDrawingOn : l.fingerDrawingOff,
|
2026-06-21 23:56:38 +08:00
|
|
|
|
onPressed: _toggleFingerDrawing,
|
|
|
|
|
|
),
|
2026-06-23 10:21:40 +08:00
|
|
|
|
ToolButton(
|
2026-06-21 23:56:38 +08:00
|
|
|
|
icon: Icons.grid_view,
|
|
|
|
|
|
selected: false,
|
2026-06-23 09:35:26 +08:00
|
|
|
|
tooltip: l.pages,
|
2026-06-24 02:32:41 +08:00
|
|
|
|
onPressed: _viewerReady ? _openThumbnails : null,
|
2026-06-21 23:56:38 +08:00
|
|
|
|
),
|
2026-06-23 10:21:40 +08:00
|
|
|
|
ToolButton(
|
2026-06-21 23:56:38 +08:00
|
|
|
|
icon: Icons.settings_outlined,
|
|
|
|
|
|
selected: false,
|
2026-06-23 09:35:26 +08:00
|
|
|
|
tooltip: l.penSettings,
|
2026-06-21 23:56:38 +08:00
|
|
|
|
onPressed: _penConfig != null ? _openPenSettings : null,
|
2026-06-21 22:04:49 +08:00
|
|
|
|
),
|
2026-06-23 10:21:40 +08:00
|
|
|
|
ToolButton(
|
2026-06-21 23:13:05 +08:00
|
|
|
|
icon: Icons.bug_report_outlined,
|
|
|
|
|
|
selected: _showPenDebug,
|
2026-06-23 09:35:26 +08:00
|
|
|
|
tooltip: l.inputDiagnostic,
|
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
|
|
|
|
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
|
|
|
|
},
|
2026-06-21 23:13:05 +08:00
|
|
|
|
),
|
2026-06-21 22:04:49 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 20:27:35 +08:00
|
|
|
|
/// Compact text-format bar shown (below the tool palette) while a text box
|
|
|
|
|
|
/// is being edited: S/M/L font-size presets + a Bold toggle. Edits apply
|
|
|
|
|
|
/// live to the editing [SidecarText] via [_updateEditingStyle]. Returns null
|
|
|
|
|
|
/// when nothing is being edited (or the box has since been removed).
|
|
|
|
|
|
Widget? _buildTextFormatBar() {
|
|
|
|
|
|
final editing = _editingText;
|
|
|
|
|
|
if (editing == null) return null;
|
|
|
|
|
|
final list = _textsByPage[editing.page];
|
|
|
|
|
|
if (list == null) return null;
|
|
|
|
|
|
final idx = list.indexWhere((t) => t.id == editing.id);
|
|
|
|
|
|
if (idx == -1) return null;
|
|
|
|
|
|
final current = list[idx];
|
|
|
|
|
|
final cs = Theme.of(context).colorScheme;
|
|
|
|
|
|
final l = AppLocalizations.of(context);
|
|
|
|
|
|
final isBold = current.fontWeight >= 700;
|
|
|
|
|
|
|
|
|
|
|
|
Widget sizeButton(String label, double fraction) {
|
|
|
|
|
|
final selected = (current.fontSize - fraction).abs() < 0.001;
|
|
|
|
|
|
return TextButton(
|
|
|
|
|
|
style: TextButton.styleFrom(
|
|
|
|
|
|
minimumSize: const Size(36, 36),
|
|
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
|
|
backgroundColor:
|
|
|
|
|
|
selected ? cs.secondaryContainer : Colors.transparent,
|
|
|
|
|
|
foregroundColor:
|
|
|
|
|
|
selected ? cs.onSecondaryContainer : cs.onSurfaceVariant,
|
|
|
|
|
|
),
|
|
|
|
|
|
onPressed: () => _updateEditingStyle(fontSize: fraction),
|
|
|
|
|
|
child: Text(label),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Material(
|
|
|
|
|
|
color: cs.surfaceContainerHigh,
|
|
|
|
|
|
elevation: 3,
|
|
|
|
|
|
borderRadius: BorderRadius.circular(20),
|
|
|
|
|
|
child: Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
sizeButton(l.textFontSmall, 0.022),
|
|
|
|
|
|
sizeButton(l.textFontMedium, 0.03),
|
|
|
|
|
|
sizeButton(l.textFontLarge, 0.045),
|
|
|
|
|
|
PaletteDivider(cs: cs),
|
|
|
|
|
|
ToolButton(
|
|
|
|
|
|
icon: Icons.format_bold,
|
|
|
|
|
|
selected: isBold,
|
|
|
|
|
|
tooltip: l.textBold,
|
|
|
|
|
|
onPressed: () =>
|
|
|
|
|
|
_updateEditingStyle(fontWeight: isBold ? 400 : 700),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 22:04:49 +08:00
|
|
|
|
Widget _colorDot(Color c, ColorScheme cs) {
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
// Selected against the ACTIVE brush's remembered color; a tap updates only
|
|
|
|
|
|
// that brush's entry (rnote per-brush color memory). Inert in select mode.
|
|
|
|
|
|
final selected = _color == c && !_isSelect;
|
2026-06-21 22:04:49 +08:00
|
|
|
|
return GestureDetector(
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
onTap: () => setState(() => _brushColors[_activeColorBrush] = c),
|
2026-06-21 22:04:49 +08:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 20:27:35 +08:00
|
|
|
|
/// Floating page control: a scrubber Slider (multi-page docs) above a
|
|
|
|
|
|
/// COMPACT pill (prev / "n / total" / next). Mirrors the slide editor's
|
|
|
|
|
|
/// scrubber; the center button now opens the thumbnail grid.
|
2026-06-21 22:04:49 +08:00
|
|
|
|
Widget _buildPagePill() {
|
|
|
|
|
|
final cs = Theme.of(context).colorScheme;
|
2026-06-23 09:35:26 +08:00
|
|
|
|
final l = AppLocalizations.of(context);
|
2026-06-24 02:32:41 +08:00
|
|
|
|
final total = _pageCount;
|
2026-08-05 20:27:35 +08:00
|
|
|
|
final scrub = _pageScrub;
|
|
|
|
|
|
final shown = (scrub ?? (_pageIndex + 1).toDouble()).round();
|
|
|
|
|
|
return Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
if (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()),
|
|
|
|
|
|
divisions: total > 1 ? total - 1 : null,
|
|
|
|
|
|
onChanged: (v) => setState(() => _pageScrub = v),
|
|
|
|
|
|
onChangeEnd: (v) {
|
|
|
|
|
|
setState(() => _pageScrub = null);
|
|
|
|
|
|
_goToPage(v.round() - 1);
|
|
|
|
|
|
},
|
2026-06-21 23:13:05 +08:00
|
|
|
|
),
|
2026-06-21 22:33:18 +08:00
|
|
|
|
),
|
2026-06-21 23:13:05 +08:00
|
|
|
|
),
|
2026-08-05 20:27:35 +08:00
|
|
|
|
),
|
|
|
|
|
|
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: l.previousPage,
|
|
|
|
|
|
icon: const Icon(Icons.chevron_left),
|
|
|
|
|
|
onPressed:
|
|
|
|
|
|
_pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
|
|
|
|
|
|
),
|
|
|
|
|
|
TextButton(
|
|
|
|
|
|
onPressed: _viewerReady ? _openThumbnails : null,
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
l.pageOfPages(shown, total),
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
color: cs.onSurface,
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
IconButton(
|
|
|
|
|
|
tooltip: l.nextPage,
|
|
|
|
|
|
icon: const Icon(Icons.chevron_right),
|
|
|
|
|
|
onPressed: _pageIndex < total - 1
|
|
|
|
|
|
? () => _goToPage(_pageIndex + 1)
|
|
|
|
|
|
: null,
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2026-06-24 02:32:41 +08:00
|
|
|
|
),
|
2026-08-05 20:27:35 +08:00
|
|
|
|
),
|
2026-06-24 02:32:41 +08:00
|
|
|
|
),
|
2026-08-05 20:27:35 +08:00
|
|
|
|
],
|
2026-06-24 02:32:41 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Widget _buildDebugReadout(BuildContext context) {
|
|
|
|
|
|
return 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),
|
|
|
|
|
|
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)),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
2026-06-21 23:13:05 +08:00
|
|
|
|
),
|
2026-06-24 02:32:41 +08:00
|
|
|
|
),
|
2026-06-21 23:13:05 +08:00
|
|
|
|
),
|
2026-06-21 22:33:18 +08:00
|
|
|
|
),
|
2026-06-21 22:04:49 +08:00
|
|
|
|
),
|
2026-06-24 02:32:41 +08:00
|
|
|
|
),
|
2026-06-21 22:04:49 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 20:18:22 +08:00
|
|
|
|
/// A [ScaleGestureRecognizer] that competes ONLY for 2-finger touch gestures.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// pdfrx's internal pan recognizer runs underneath (scaleEnabled is off, so it
|
|
|
|
|
|
/// still scrolls on 1 finger). A vanilla ScaleGestureRecognizer is eager: with a
|
|
|
|
|
|
/// single uncontested pointer it would win the arena and STEAL 1-finger scroll.
|
|
|
|
|
|
/// Restricting [supportedDevices] to touch keeps the stylus out (the pen is
|
|
|
|
|
|
/// captured outside the arena by [PenCaptureRegion]); rejecting the pointer while
|
|
|
|
|
|
/// fewer than 2 are down hands the 1-finger gesture back to pdfrx immediately,
|
|
|
|
|
|
/// and accepting on the 2nd finger lets us drive the pinch. Guarding inside
|
|
|
|
|
|
/// onUpdate would be too late — by then the arena is already won.
|
|
|
|
|
|
class _TwoFingerPinch extends ScaleGestureRecognizer {
|
|
|
|
|
|
_TwoFingerPinch({super.debugOwner})
|
|
|
|
|
|
: super(supportedDevices: const {PointerDeviceKind.touch});
|
|
|
|
|
|
|
|
|
|
|
|
final Set<int> _pointers = <int>{};
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void addAllowedPointer(PointerDownEvent event) {
|
|
|
|
|
|
_pointers.add(event.pointer);
|
|
|
|
|
|
super.addAllowedPointer(event);
|
|
|
|
|
|
if (_pointers.length < 2) {
|
|
|
|
|
|
// Concede the 1-finger case now so pdfrx's pan wins without waiting.
|
|
|
|
|
|
resolvePointer(event.pointer, GestureDisposition.rejected);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Second finger down: claim the gesture before pdfrx treats it as a pan.
|
|
|
|
|
|
resolve(GestureDisposition.accepted);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void handleEvent(PointerEvent event) {
|
|
|
|
|
|
if (event is PointerUpEvent || event is PointerCancelEvent) {
|
|
|
|
|
|
_pointers.remove(event.pointer);
|
|
|
|
|
|
}
|
|
|
|
|
|
super.handleEvent(event);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void rejectGesture(int pointer) {
|
|
|
|
|
|
_pointers.remove(pointer);
|
|
|
|
|
|
super.rejectGesture(pointer);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The currently in-progress stroke and the page it belongs to. Published via a
|
|
|
|
|
|
/// [ValueNotifier] so [_PageOverlayPainter] can read the live stroke at PAINT
|
|
|
|
|
|
/// time (driven by the notifier) instead of capturing a stale build-time
|
|
|
|
|
|
/// snapshot — that snapshot bug made ink appear only on pointer-up.
|
|
|
|
|
|
class _LiveStrokeData {
|
|
|
|
|
|
const _LiveStrokeData(this.page, this.stroke);
|
|
|
|
|
|
|
|
|
|
|
|
final int page;
|
|
|
|
|
|
final PenStroke stroke;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
/// Paints one page's overlay: text highlights (under), committed ink, then the
|
|
|
|
|
|
/// live in-progress stroke (over). Strokes are in normalized page coords; the
|
|
|
|
|
|
/// painter scales them to the on-screen page rect ([pageSize]) so they stay
|
|
|
|
|
|
/// glued to the page under pdfrx's native zoom/scroll.
|
2026-06-24 20:18:22 +08:00
|
|
|
|
///
|
|
|
|
|
|
/// The live stroke is read from [liveStrokeVN] at paint time (not passed as a
|
|
|
|
|
|
/// constructor snapshot) so that mid-stroke updates — which notify the merged
|
|
|
|
|
|
/// repaint Listenable — redraw the in-progress ink immediately. Committed
|
|
|
|
|
|
/// strokes + highlights still arrive via the constructor (they change only on
|
|
|
|
|
|
/// setState, which rebuilds this painter).
|
2026-06-24 02:32:41 +08:00
|
|
|
|
class _PageOverlayPainter extends CustomPainter {
|
|
|
|
|
|
_PageOverlayPainter({
|
2026-06-24 20:18:22 +08:00
|
|
|
|
required Listenable overlayRepaint,
|
|
|
|
|
|
required this.liveStrokeVN,
|
|
|
|
|
|
required this.pageIndex,
|
2026-06-24 02:32:41 +08:00
|
|
|
|
required this.strokes,
|
|
|
|
|
|
required this.highlights,
|
|
|
|
|
|
required this.pageSize,
|
|
|
|
|
|
required this.thinning,
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
this.selectedIndex,
|
2026-06-24 20:18:22 +08:00
|
|
|
|
}) : super(repaint: Listenable.merge([overlayRepaint, liveStrokeVN]));
|
2026-06-24 02:32:41 +08:00
|
|
|
|
|
2026-06-24 20:18:22 +08:00
|
|
|
|
/// Live stroke source, read at paint time. Only painted when its page matches
|
|
|
|
|
|
/// [pageIndex].
|
|
|
|
|
|
final ValueListenable<_LiveStrokeData?> liveStrokeVN;
|
|
|
|
|
|
final int pageIndex;
|
2026-06-24 02:32:41 +08:00
|
|
|
|
final List<PenStroke> strokes;
|
|
|
|
|
|
final List<Rect> highlights;
|
|
|
|
|
|
final Size pageSize;
|
|
|
|
|
|
final double thinning;
|
|
|
|
|
|
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
/// SELECT tool: index into [strokes] of the selected stroke on this page, or
|
|
|
|
|
|
/// null. Drives the selection bounding-box overlay.
|
|
|
|
|
|
final int? selectedIndex;
|
|
|
|
|
|
|
2026-06-24 02:32:41 +08:00
|
|
|
|
@override
|
|
|
|
|
|
void paint(Canvas canvas, Size size) {
|
|
|
|
|
|
// 1. Text highlights (semi-transparent yellow), normalized → pixels.
|
|
|
|
|
|
if (highlights.isNotEmpty) {
|
|
|
|
|
|
final hp = Paint()
|
|
|
|
|
|
..color = const Color(0x66FFEB3B)
|
|
|
|
|
|
..style = PaintingStyle.fill;
|
|
|
|
|
|
for (final n in highlights) {
|
|
|
|
|
|
canvas.drawRect(
|
|
|
|
|
|
Rect.fromLTRB(
|
|
|
|
|
|
n.left * size.width,
|
|
|
|
|
|
n.top * size.height,
|
|
|
|
|
|
n.right * size.width,
|
|
|
|
|
|
n.bottom * size.height,
|
|
|
|
|
|
),
|
|
|
|
|
|
hp,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. Committed ink.
|
|
|
|
|
|
for (final stroke in strokes) {
|
|
|
|
|
|
final path =
|
|
|
|
|
|
buildStrokePath(stroke, size, isComplete: true, thinning: thinning);
|
|
|
|
|
|
if (path.getBounds().isEmpty) continue;
|
2026-06-24 23:27:11 +08:00
|
|
|
|
// Single drawPath per stroke ⇒ highlighter self-overlap never darkens;
|
|
|
|
|
|
// cross-stroke overlap darkens via BlendMode.multiply (closes
|
|
|
|
|
|
// TODO(brush-opacity); shared resolver with the PenCanvas painters).
|
|
|
|
|
|
canvas.drawPath(path, paintForStroke(stroke));
|
2026-06-24 02:32:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 20:18:22 +08:00
|
|
|
|
// 3. Live stroke — read from the notifier at paint time, only for this page.
|
|
|
|
|
|
final live = liveStrokeVN.value;
|
|
|
|
|
|
if (live != null && live.page == pageIndex && live.stroke.points.isNotEmpty) {
|
|
|
|
|
|
final path = buildStrokePath(live.stroke, size,
|
|
|
|
|
|
isComplete: false, thinning: thinning);
|
2026-06-24 02:32:41 +08:00
|
|
|
|
if (!path.getBounds().isEmpty) {
|
2026-06-24 23:27:11 +08:00
|
|
|
|
canvas.drawPath(path, paintForStroke(live.stroke));
|
2026-06-24 02:32:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
|
|
|
|
|
|
// 4. SELECT bounding box around the selected stroke (over everything).
|
|
|
|
|
|
final si = selectedIndex;
|
|
|
|
|
|
if (si != null && si >= 0 && si < strokes.length) {
|
|
|
|
|
|
final b = penStrokeBounds(strokes[si]);
|
|
|
|
|
|
if (b != null) {
|
|
|
|
|
|
const padPx = 6.0;
|
|
|
|
|
|
final rect = Rect.fromLTRB(
|
|
|
|
|
|
b.left * size.width - padPx,
|
|
|
|
|
|
b.top * size.height - padPx,
|
|
|
|
|
|
b.right * size.width + padPx,
|
|
|
|
|
|
b.bottom * size.height + padPx,
|
|
|
|
|
|
);
|
|
|
|
|
|
final rr = RRect.fromRectAndRadius(rect, const Radius.circular(4));
|
|
|
|
|
|
canvas.drawRRect(
|
|
|
|
|
|
rr,
|
|
|
|
|
|
Paint()
|
|
|
|
|
|
..color = const Color(0xFF2962FF).withValues(alpha: 0.12)
|
|
|
|
|
|
..style = PaintingStyle.fill,
|
|
|
|
|
|
);
|
|
|
|
|
|
canvas.drawRRect(
|
|
|
|
|
|
rr,
|
|
|
|
|
|
Paint()
|
|
|
|
|
|
..color = const Color(0xFF2962FF)
|
|
|
|
|
|
..style = PaintingStyle.stroke
|
|
|
|
|
|
..strokeWidth = 1.5
|
|
|
|
|
|
..isAntiAlias = true,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-24 02:32:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
bool shouldRepaint(_PageOverlayPainter old) =>
|
2026-06-24 20:18:22 +08:00
|
|
|
|
!identical(old.liveStrokeVN, liveStrokeVN) ||
|
|
|
|
|
|
old.pageIndex != pageIndex ||
|
2026-06-24 02:32:41 +08:00
|
|
|
|
!identical(old.strokes, strokes) ||
|
|
|
|
|
|
old.strokes.length != strokes.length ||
|
|
|
|
|
|
!identical(old.highlights, highlights) ||
|
|
|
|
|
|
old.highlights.length != highlights.length ||
|
feat(tools): rnote-style toolbar core writing batch
Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.
- Multiple brushes, each remembering its OWN color (rnote-style):
selecting a brush restores its color, changing color updates only
that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
preview and committed as generated PenStrokes (shape_geometry.dart)
so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.
Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
2026-06-24 20:38:18 +08:00
|
|
|
|
old.selectedIndex != selectedIndex ||
|
2026-06-24 02:32:41 +08:00
|
|
|
|
old.pageSize != pageSize ||
|
|
|
|
|
|
old.thinning != thinning;
|
|
|
|
|
|
}
|
2026-06-24 20:02:12 +08:00
|
|
|
|
|
2026-06-24 20:18:22 +08:00
|
|
|
|
/// Test-only handle on the live-stroke painter wiring (Bug 1 regression guard).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The live stroke must be read from a [ValueListenable] at PAINT time, so a
|
|
|
|
|
|
/// mid-stroke update repaints WITHOUT a rebuild of `pageOverlaysBuilder`. This
|
|
|
|
|
|
/// seam lets a widget test pump the real painter and assert that pushing a new
|
|
|
|
|
|
/// value into the notifier triggers a repaint (which the old build-time-snapshot
|
|
|
|
|
|
/// design did not).
|
|
|
|
|
|
@visibleForTesting
|
|
|
|
|
|
class LiveStrokeOverlayHarness {
|
|
|
|
|
|
LiveStrokeOverlayHarness({required this.pageIndex});
|
|
|
|
|
|
|
|
|
|
|
|
final int pageIndex;
|
|
|
|
|
|
final ValueNotifier<int> overlayRepaint = ValueNotifier<int>(0);
|
|
|
|
|
|
final ValueNotifier<_LiveStrokeData?> _liveStrokeVN = ValueNotifier(null);
|
|
|
|
|
|
|
|
|
|
|
|
/// Push a live stroke for [page] (or null to clear). Mirrors what
|
|
|
|
|
|
/// `_updateLiveStroke`/`_endStroke` do at runtime.
|
|
|
|
|
|
void setLiveStroke(int page, PenStroke? stroke) {
|
|
|
|
|
|
_liveStrokeVN.value = stroke == null ? null : _LiveStrokeData(page, stroke);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The real [CustomPainter] used by the editor, wired to this harness's
|
|
|
|
|
|
/// notifiers exactly as `pageOverlaysBuilder` wires it.
|
|
|
|
|
|
CustomPainter buildPainter() => _PageOverlayPainter(
|
|
|
|
|
|
overlayRepaint: overlayRepaint,
|
|
|
|
|
|
liveStrokeVN: _liveStrokeVN,
|
|
|
|
|
|
pageIndex: pageIndex,
|
|
|
|
|
|
strokes: const [],
|
|
|
|
|
|
highlights: const [],
|
|
|
|
|
|
pageSize: const Size(100, 100),
|
|
|
|
|
|
thinning: kDefaultPenThinning,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
void dispose() {
|
|
|
|
|
|
overlayRepaint.dispose();
|
|
|
|
|
|
_liveStrokeVN.dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-05 19:52:15 +08:00
|
|
|
|
/// Floating action bar shown while PDF text is selected (OneNote-style).
|
|
|
|
|
|
class _SelectionActionBar extends StatelessWidget {
|
|
|
|
|
|
const _SelectionActionBar({
|
|
|
|
|
|
required this.onHighlight,
|
|
|
|
|
|
required this.onBookmark,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
final VoidCallback onHighlight;
|
|
|
|
|
|
final VoidCallback onBookmark;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
final cs = Theme.of(context).colorScheme;
|
|
|
|
|
|
final l = AppLocalizations.of(context);
|
|
|
|
|
|
return Center(
|
|
|
|
|
|
child: Material(
|
|
|
|
|
|
elevation: 4,
|
|
|
|
|
|
color: cs.surfaceContainerHigh,
|
|
|
|
|
|
borderRadius: BorderRadius.circular(24),
|
|
|
|
|
|
child: Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
TextButton.icon(
|
|
|
|
|
|
onPressed: onHighlight,
|
|
|
|
|
|
icon: const Icon(Icons.highlight, size: 18),
|
|
|
|
|
|
label: Text(l.actionHighlightSelection),
|
|
|
|
|
|
),
|
|
|
|
|
|
TextButton.icon(
|
|
|
|
|
|
onPressed: onBookmark,
|
|
|
|
|
|
icon: const Icon(Icons.bookmark_add_outlined, size: 18),
|
|
|
|
|
|
label: Text(l.toolAddBookmark),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 20:02:12 +08:00
|
|
|
|
/// A small sticky-note "tab" marker glued to a page at a scratch-link anchor.
|
2026-08-05 19:52:15 +08:00
|
|
|
|
/// Tap opens the sticky overlay; long-press deletes the anchor.
|
2026-06-24 20:02:12 +08:00
|
|
|
|
class _ScratchLinkMarker extends StatelessWidget {
|
|
|
|
|
|
const _ScratchLinkMarker({required this.onTap, required this.onLongPress});
|
|
|
|
|
|
|
|
|
|
|
|
final VoidCallback onTap;
|
|
|
|
|
|
final VoidCallback onLongPress;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
final cs = Theme.of(context).colorScheme;
|
|
|
|
|
|
return GestureDetector(
|
|
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
|
|
onTap: onTap,
|
|
|
|
|
|
onLongPress: onLongPress,
|
|
|
|
|
|
child: Material(
|
|
|
|
|
|
color: cs.tertiaryContainer,
|
|
|
|
|
|
elevation: 2,
|
|
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
|
side: BorderSide(color: cs.outlineVariant),
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Icon(
|
|
|
|
|
|
Icons.sticky_note_2,
|
|
|
|
|
|
size: 20,
|
|
|
|
|
|
color: cs.onTertiaryContainer,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-25 00:11:45 +08:00
|
|
|
|
|
|
|
|
|
|
/// Empty-space placement layer for the TEXT tool. Resolves the two requested
|
|
|
|
|
|
/// gestures by POINTER KIND (the user asked for "pen-tap OR mouse double-click"):
|
|
|
|
|
|
/// * stylus / touch → a single tap places (one deliberate pen poke);
|
|
|
|
|
|
/// * mouse → a DOUBLE-click places (a single click is too easy to trigger
|
|
|
|
|
|
/// while panning, matching the "鼠标双击" request).
|
|
|
|
|
|
/// The down-pointer's kind is captured in [onTapDown] and consumed by
|
|
|
|
|
|
/// [onTapUp]; mouse double-clicks come through [onDoubleTapDown].
|
|
|
|
|
|
class _TextPlacementLayer extends StatefulWidget {
|
|
|
|
|
|
const _TextPlacementLayer({required this.onPlace});
|
|
|
|
|
|
|
|
|
|
|
|
/// Called with the LOCAL position (within the page rect) where a box should
|
|
|
|
|
|
/// be placed.
|
|
|
|
|
|
final void Function(Offset local) onPlace;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
State<_TextPlacementLayer> createState() => _TextPlacementLayerState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class _TextPlacementLayerState extends State<_TextPlacementLayer> {
|
|
|
|
|
|
PointerDeviceKind? _downKind;
|
|
|
|
|
|
Offset? _downLocal;
|
|
|
|
|
|
|
2026-08-05 20:27:35 +08:00
|
|
|
|
/// A down→up drift beyond this (px) means the gesture was a scroll/pan
|
|
|
|
|
|
/// (e.g. a 1-finger drag that also reaches this translucent layer), not a
|
|
|
|
|
|
/// deliberate tap-to-place.
|
|
|
|
|
|
static const double _kMaxTapDriftPx = 12.0;
|
|
|
|
|
|
|
2026-06-25 00:11:45 +08:00
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
return GestureDetector(
|
2026-08-05 20:27:35 +08:00
|
|
|
|
// translucent: this layer must NOT swallow the touch/scroll gesture from
|
|
|
|
|
|
// pdfrx underneath (only opaque'd taps that resolve to a genuine
|
|
|
|
|
|
// tap-to-place, guarded by the drift check below, actually place a box).
|
|
|
|
|
|
behavior: HitTestBehavior.translucent,
|
2026-06-25 00:11:45 +08:00
|
|
|
|
onTapDown: (d) {
|
|
|
|
|
|
_downKind = d.kind;
|
|
|
|
|
|
_downLocal = d.localPosition;
|
|
|
|
|
|
},
|
|
|
|
|
|
onTapUp: (d) {
|
|
|
|
|
|
// A mouse single-click does NOT place (mouse uses double-click); pen and
|
|
|
|
|
|
// touch place on a single tap.
|
|
|
|
|
|
if (_downKind == PointerDeviceKind.mouse) return;
|
2026-08-05 20:27:35 +08:00
|
|
|
|
final down = _downLocal;
|
|
|
|
|
|
if (down != null &&
|
|
|
|
|
|
(d.localPosition - down).distance > _kMaxTapDriftPx) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-25 00:11:45 +08:00
|
|
|
|
widget.onPlace(d.localPosition);
|
|
|
|
|
|
},
|
|
|
|
|
|
onDoubleTapDown: (d) {
|
|
|
|
|
|
_downLocal = d.localPosition;
|
|
|
|
|
|
},
|
|
|
|
|
|
onDoubleTap: () {
|
|
|
|
|
|
final local = _downLocal;
|
|
|
|
|
|
if (local != null) widget.onPlace(local);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A committed text annotation rendered glued to the page. Read-only label;
|
|
|
|
|
|
/// tapping it (when [onTap] is non-null, i.e. the TEXT tool is active) re-opens
|
|
|
|
|
|
/// it for editing.
|
|
|
|
|
|
class _TextAnnotationLabel extends StatelessWidget {
|
|
|
|
|
|
const _TextAnnotationLabel({
|
|
|
|
|
|
required this.text,
|
|
|
|
|
|
required this.fontSizePx,
|
|
|
|
|
|
required this.color,
|
2026-08-05 20:27:35 +08:00
|
|
|
|
this.fontWeight = FontWeight.w400,
|
|
|
|
|
|
this.fontFamily,
|
2026-06-25 00:11:45 +08:00
|
|
|
|
this.onTap,
|
2026-08-05 20:27:35 +08:00
|
|
|
|
this.onDoubleTap,
|
|
|
|
|
|
this.onPanUpdate,
|
2026-06-25 00:11:45 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
final String text;
|
|
|
|
|
|
final double fontSizePx;
|
|
|
|
|
|
final Color color;
|
2026-08-05 20:27:35 +08:00
|
|
|
|
final FontWeight fontWeight;
|
|
|
|
|
|
final String? fontFamily;
|
|
|
|
|
|
|
|
|
|
|
|
/// Single-tap handler (only wired while the TEXT tool is active).
|
2026-06-25 00:11:45 +08:00
|
|
|
|
final VoidCallback? onTap;
|
|
|
|
|
|
|
2026-08-05 20:27:35 +08:00
|
|
|
|
/// Double-tap handler: ALWAYS wired (regardless of active tool) so a box can
|
|
|
|
|
|
/// be reopened for editing at any time.
|
|
|
|
|
|
final VoidCallback? onDoubleTap;
|
|
|
|
|
|
|
|
|
|
|
|
/// TEXT tool drag-to-move (only wired while the TEXT tool is active).
|
|
|
|
|
|
final GestureDragUpdateCallback? onPanUpdate;
|
|
|
|
|
|
|
2026-06-25 00:11:45 +08:00
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
return GestureDetector(
|
|
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
|
|
onTap: onTap,
|
2026-08-05 20:27:35 +08:00
|
|
|
|
onDoubleTap: onDoubleTap,
|
|
|
|
|
|
onPanUpdate: onPanUpdate,
|
2026-06-25 00:11:45 +08:00
|
|
|
|
child: Text(
|
|
|
|
|
|
text,
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontSize: fontSizePx,
|
|
|
|
|
|
color: color,
|
2026-08-05 20:27:35 +08:00
|
|
|
|
fontWeight: fontWeight,
|
|
|
|
|
|
fontFamily: fontFamily ?? kAnnotationFontFamily,
|
2026-06-25 00:11:45 +08:00
|
|
|
|
height: 1.2,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The active editing field for a text box. A REAL Flutter [TextField] so the
|
|
|
|
|
|
/// OS IME and — on Windows — the Windows-Ink handwriting panel feed it
|
2026-08-05 19:52:15 +08:00
|
|
|
|
/// automatically. Does NOT autofocus on insert (Windows tablets otherwise pop
|
|
|
|
|
|
/// the soft keyboard on every pen tap that places a box); focus only after an
|
|
|
|
|
|
/// explicit tap on the field.
|
2026-06-25 00:11:45 +08:00
|
|
|
|
class _TextAnnotationField extends StatefulWidget {
|
|
|
|
|
|
const _TextAnnotationField({
|
|
|
|
|
|
super.key,
|
|
|
|
|
|
required this.initialText,
|
|
|
|
|
|
required this.fontSizePx,
|
|
|
|
|
|
required this.color,
|
|
|
|
|
|
required this.hintText,
|
|
|
|
|
|
required this.onChanged,
|
|
|
|
|
|
required this.onDone,
|
2026-08-05 20:27:35 +08:00
|
|
|
|
this.fontWeight = FontWeight.w400,
|
|
|
|
|
|
this.fontFamily,
|
2026-06-25 00:11:45 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
final String initialText;
|
|
|
|
|
|
final double fontSizePx;
|
|
|
|
|
|
final Color color;
|
|
|
|
|
|
final String hintText;
|
|
|
|
|
|
final ValueChanged<String> onChanged;
|
|
|
|
|
|
final VoidCallback onDone;
|
2026-08-05 20:27:35 +08:00
|
|
|
|
final FontWeight fontWeight;
|
|
|
|
|
|
final String? fontFamily;
|
2026-06-25 00:11:45 +08:00
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
State<_TextAnnotationField> createState() => _TextAnnotationFieldState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class _TextAnnotationFieldState extends State<_TextAnnotationField> {
|
|
|
|
|
|
late final TextEditingController _controller;
|
|
|
|
|
|
late final FocusNode _focusNode;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void initState() {
|
|
|
|
|
|
super.initState();
|
|
|
|
|
|
_controller = TextEditingController(text: widget.initialText);
|
|
|
|
|
|
_focusNode = FocusNode();
|
|
|
|
|
|
_focusNode.addListener(_onFocusChange);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _onFocusChange() {
|
|
|
|
|
|
if (!_focusNode.hasFocus) widget.onDone();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void dispose() {
|
|
|
|
|
|
_focusNode.removeListener(_onFocusChange);
|
|
|
|
|
|
_focusNode.dispose();
|
|
|
|
|
|
_controller.dispose();
|
|
|
|
|
|
super.dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
final cs = Theme.of(context).colorScheme;
|
|
|
|
|
|
return Material(
|
|
|
|
|
|
color: cs.surface.withValues(alpha: 0.85),
|
|
|
|
|
|
elevation: 1,
|
|
|
|
|
|
borderRadius: BorderRadius.circular(4),
|
|
|
|
|
|
child: TextField(
|
|
|
|
|
|
controller: _controller,
|
|
|
|
|
|
focusNode: _focusNode,
|
2026-08-05 19:52:15 +08:00
|
|
|
|
autofocus: false,
|
2026-06-25 00:11:45 +08:00
|
|
|
|
maxLines: null,
|
|
|
|
|
|
minLines: 1,
|
|
|
|
|
|
keyboardType: TextInputType.multiline,
|
|
|
|
|
|
textInputAction: TextInputAction.newline,
|
|
|
|
|
|
cursorColor: widget.color,
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontSize: widget.fontSizePx,
|
|
|
|
|
|
color: widget.color,
|
2026-08-05 20:27:35 +08:00
|
|
|
|
fontWeight: widget.fontWeight,
|
|
|
|
|
|
fontFamily: widget.fontFamily ?? kAnnotationFontFamily,
|
2026-06-25 00:11:45 +08:00
|
|
|
|
height: 1.2,
|
|
|
|
|
|
),
|
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
|
isDense: true,
|
|
|
|
|
|
border: InputBorder.none,
|
|
|
|
|
|
hintText: widget.hintText,
|
|
|
|
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
|
|
|
|
|
),
|
|
|
|
|
|
onChanged: widget.onChanged,
|
|
|
|
|
|
onTapOutside: (_) => _focusNode.unfocus(),
|
|
|
|
|
|
onEditingComplete: () => _focusNode.unfocus(),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|