A stylus pass through a committed stroke in eraser mode fires onEraseStroke for
it; a pass far from any stroke erases nothing. Locks in the eraser path (the
user reported eraser reliability issues) as a regression guard without a device.
flutter analyze lib/editor clean; 237/237 tests (+2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Device-independent pinch test over PenCanvas: a steady two-finger spread must
grow the shared transform scale MONOTONICALLY, with no frame popping above the
final scale and snapping back (the flicker invariant the absolute-from-snapshot
rewrite enforces). Plus: a single stylus drag never pans/zooms (the recognizer
excludes stylus) — the transform stays identity while the pen draws.
Locks in 6829076 + the arbiter exclusion as regressions guards without a device.
flutter analyze lib/editor clean; 235/235 tests (+2 widget).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pumps PenCanvas with a plain Container as the page widget (no pdfrx/pdfium) and
simulates gestures to verify the INTEGRATED live path end-to-end: a stylus drag
commits a multi-point stroke; a single finger is rejected when finger-drawing is
off but draws when on; a 2nd pointer cancels an in-progress stylus stroke
(pinch/palm); committed strokes render through the revision-gated render cache
(eca5141) with no exception. Real behavioral evidence for the arbiter + render
swap beyond static analysis — without a device.
flutter analyze lib/editor clean; 233/233 tests (+5 widget).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extracts the untested ad-hoc arithmetic from pen_editor_screen._centerPage into
pure, shared, tested functions: fitWidthScale (fill viewport width — the
continuous-single default), fitPageScale (letterboxed min-axis fit), and
centerOffset (top-left translation to center scaled content; negative when it
overflows/scrolls). Powers the viewport's initial transform + the reader
fit-width/fit-page actions.
flutter analyze lib/editor clean; 228/228 tests (+5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scoreText (more normalized occurrences rank higher; earlier first match breaks
ties) and rankHits (score every source, drop non-matches, attach a display
snippet of the original text, sort best-first with an explicit input-order
tiebreak since Dart's sort isn't stable). The search_indexer's pure ranking
core, making search_text + search_snippet load-bearing.
flutter analyze lib/editor clean; 223/223 tests (+9).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Board + BoardCard: positioned sticky-note cards in board coordinates, immutable
copy-on-write edits (add/removeById/moveCard/setText, unique ids), cardsIn()
broad-phase culling, and linkGraph()/backlinksOf() that derive the 双链 graph
from the cards' [[links]] — making link_graph load-bearing. Cards also host ink
via a StrokeHost keyed by card id (same host-agnostic engine as PDF pages).
Pure model; fully unit-tested (CRUD, immutability, culling, backlinks).
flutter analyze lib/editor clean; 215/215 tests (+7).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ties the engine together: a StrokeHost is anything ink attaches to — a PDF page,
an infinite-board region, or a (P5) CAS overlay — with a stable hostId (cache +
persistence key), a contentSize (normalized↔px mapping), and a revision-tracked
StrokeStore. strokesIn(viewport) broad-phase-culls via stroke_bounds for the
board. The viewport mounts one AnnotationLayer per host; nothing in the engine
knows page vs board (the one host-agnostic ink engine).
Makes StrokeStore (P0) + stroke_bounds load-bearing together. Pure; unit-tested.
flutter analyze lib/editor clean; 208/208 tests (+4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ToolSettings: the active tool + each tool's OWN remembered ToolConfig
(color/width), so pen→highlighter→pen restores the pen's last color/width
instead of bleeding the highlighter's. Immutable copy-on-write (withActive/
withColor/withWidth only touch the active tool); sensible defaults (black thin
pen, yellow fat highlighter, medium eraser). The toolbar holds + persists one.
Pure model; fully unit-tested (per-tool isolation, immutability, equality).
flutter analyze lib/editor clean; 204/204 tests (+6).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
simplifyStroke reduces a stroke's points via Ramer–Douglas–Peucker at a
normalized perpendicular-distance tolerance: a fast Surface-Pen stroke drops
hundreds of near-collinear samples with no visible change, shrinking the DB row
and speeding re-rasterization (R1/R10). Endpoints + significant vertices kept;
pressure/tilt + color/width/tool/id preserved; <=2 points or tol<=0 are no-ops
(returns the same instance). The commit path can call it before saveHost; the
live in-progress stroke stays untouched.
Pure geometry over EditorStroke; fully unit-tested (collinear collapse, peak
retention, within-tolerance drop, metadata preservation).
flutter analyze lib/editor clean; 198/198 tests (+7).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
strokeBounds (tight AABB over normalized points, null for empty, zero-size for a
single point), strokesBounds (union), and strokeIntersects (does a stroke's box
overlap a viewport rect — touching edges count). Broad-phase primitive for the
infinite board: skip painting/erasing/hit-testing strokes off-screen (R1 perf),
and a cheap pre-filter before the exact per-point eraser test.
Pure geometry over EditorStroke; fully unit-tested.
flutter analyze lib/editor clean; 191/191 tests (+10).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
normalizeForIndex (lowercase + collapse whitespace runs incl. hard PDF/OCR
newlines + trim) and matchesNormalized so a query matches across the line breaks
in raw extracted text. Deliberately NO word-tokenization: Chinese has no
inter-word spaces, so substring match over normalized text is correct for both
Latin and CJK (段/word segmentation belongs in the DB FTS tokenizer). Verified
on CJK inputs (你好/笔记应用).
flutter analyze lib/editor clean; 181/181 tests (+9).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Book-like reading the user explicitly wanted: pairIntoRows groups pages into
two-up spread rows (optional coverAlone for a title page; trailing odd page sits
alone), spreadRowHeights fits each page to half the column and takes the tallest
per row (common baseline), and spreadStackMetrics stacks the rows so the
existing PageStackMetrics.visibleRange windows continuous-DOUBLE by ROW.
Pure geometry reusing the continuous-single layer; the row-mounting widget is
device-gated. Zero-rework-risk (not rendering).
flutter analyze lib/editor clean; 172/172 tests (+8).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
For library-wide full-text search (the user's #1 named differentiator):
snippetFor() finds the first case-insensitive match of a query in a source
string (PDF text page / typed box / OCR'd handwriting) and returns a windowed
excerpt centered on it, preserving the match offset + length and
truncatedStart/End flags so the results list can render "…ctx **match** ctx…"
and jump to the hit. The full match is always included; near-edge matches don't
over-truncate. Returns null for empty/absent query.
The FTS index + ranking live in the DB (search_indexer, later); this is the
pure, storage-free excerpt math, fully unit-tested.
flutter analyze lib/editor clean; 164/164 tests (+8).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SpeedyNote-style page binding the user asked for: a notebook is an ordered
list of logical pages, each a SOURCE page (renders PDF page N, vector preserved)
or a BLANK inserted page. Crucially, inserting/reordering logical pages does NOT
renumber the PDF underlay — each page carries its source index. Copy-on-write
edits (insertBlankAt/After, removeAt, move) return a new immutable PageMap;
out-of-range edits throw RangeError; value equality + unmodifiable page list.
Pure model (no DB/widget) so it's fully unit-tested; the notebook_pages table +
viewport wire it later.
flutter analyze lib/editor clean; 156/156 tests (+11).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The user's repeated "可配置笔" ask, as a pure value type: raw normalized
pressure is pre-shaped into [floor, 1] via a min-width floor (the plan's
marker fixed-pressure floor) and a gamma response (γ<1 = more sensitive at light
touch, γ>1 = firmer). Clamps out-of-range + NaN inputs; endpoints anchored at
floor and 1. Widget-free/storage-free; PenConfig + the canvas wire it later
(live-path, on-device validated).
flutter analyze lib/editor clean; 145/145 tests (+6).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pure bidirectional-link engine for the sticky-note/board system (a user-named
differentiator). parseLinkTargets extracts trimmed, de-duped [[targets]];
LinkGraph builds forward + backlink indices (fromTexts parses, fromLinks takes
explicit targets), ignores self-links, and danglingTargets() surfaces links to
unknown nodes. Widget-free + storage-free so it is fully unit-tested; the board
UI + persistence wrap it later.
Built ahead of its phase deliberately as a zero-rework-risk pure data structure
(not rendering/perf — the P0.5 device gate can't invalidate it).
flutter analyze lib/editor clean; 139/139 tests (+10: parsing, backlinks,
self-link, dangling, immutability).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends PageStackMetrics with the navigation geometry continuous-single needs:
maxScrollExtent (last page bottom rests at viewport bottom, never negative),
clampScroll, and dominantPageAt — the page covering most of the viewport, which
drives the page-number indicator + thumbnail-grid highlight + jump-to-page (F4).
Pure; clamps past both ends; 0 for empty documents.
flutter analyze lib/editor clean; 129/129 tests (+6).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Production adapter wrapping a pdfrx PdfDocument: snapshots page sizes via the
real pdfrx 2.4.4 geometry API (PdfDocument.pages, PdfPage.width/height) so the
pure layout math runs on the real document. Because it lives under lib/editor/,
`flutter analyze lib/editor` (the Oracle) type-checks it against the installed
pdfrx every run — a version bump that renames/retypes these members now FAILS
analysis instead of silently drifting (SF4 source-pin, statically).
Runtime contract + layout composition tested via the pdfium-free .fromSizes
ctor; the .fromDocument pin is the static guarantee (exercising it needs pdfium
= device path).
flutter analyze lib/editor clean; 123/123 tests (+3).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Connects the two pure P0.5 pieces: a minimal PageDocumentSource abstraction
(pageCount + pageSize, a pdfrx PdfDocument in production) and
pageStackMetricsForWidth() which fits every page to a single column width
(continuous-single) — height = columnWidth × aspect — feeding
PageStackMetrics.visibleRange. Defensive against non-positive page width.
This makes the windowing math consumable + unit-testable against a fake source
(no pdfium/GPU); the production pdfrx adapter is the thin device-side wrapper
added with the page-mounting widget.
flutter analyze lib/editor clean; 120/120 tests (+5: fit-to-width, gap, empty,
zero-width guard, windowing composition).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PageStackMetrics + PageWindow: the pure geometry that decides which pages are
mounted for a scroll position (windowed lazy hosting → 60fps on a 300-page doc,
R1). Pages stack vertically with cumulative tops (O(log n) binary-search
visibleRange); a viewport [scroll, scroll+extent) grown by cacheExtent on each
side selects the inclusive intersecting page band, half-open at page boundaries,
clamped to valid indices, empty for empty/over-scrolled-past documents, and
gap-aware (a scroll resting inside an inter-page gap shows no page).
Widget-free + pdfrx-free by design: the page-mounting widget and zoom-settle DPI
refresh are device-gated; only the windowing math is automatable, and it is here
with exhaustive unit coverage (boundaries, cache band, clamping, gaps, empty).
flutter analyze lib/editor clean; 115/115 tests (+13).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "heavy" page-bitmap cache (R11/MF2), deliberately SEPARATE from the
resolution-independent ink Picture cache: page tiles are only crisp at the DPI
they were rasterized for, so TileKey carries a DPI bucket. get()/put() (tiles
render async via pdfrx), bounded LRU with MRU promotion, per-key replacement
disposes the old image, evictHostsExcept() for scroll-out, and post-frame
ui.Image disposal so the raster thread never frees an in-use image.
dpiBucketFor() snaps a continuous pinch scale to a coarse bucket (ceil by step,
capped at maxBucket) so a smooth zoom re-uses tiles instead of spawning one per
frame and bounds retained-DPI memory (~3× cap).
The pdfrx tile RENDERING (page_tile.dart) + zoom-settle DPI refresh remain
device-gated (crisp-at-4× on the Surface) — only the cache data structure is
automatable, and it is here, fully unit-tested.
flutter analyze lib/editor clean; 102/102 tests (+12: bucket math, LRU, MRU,
host eviction, post-frame disposal via debugDisposed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The live PenCanvas committed-ink layer now uses the relocated render/ painters
(render.StaticInkPainter + InkPictureCache + StrokeStore) instead of the old
canvas/ink_painters versions — the P0.5 perf prerequisite. The committed layer's
ui.Picture is recorded once per StrokeStore.revision and replayed on the raster
thread, so pinch / pan / live-stroke frames no longer re-rasterize committed ink.
- pen_canvas mirrors widget.strokes (PenStroke) into a StrokeStore (EditorStroke)
on every new-list identity (the parent already replaces the list on each
commit/erase), bumping the revision → cache invalidates → static layer repaints.
- thinning (PenConfig.pressureSensitivity) is threaded into the render painters
AND folded into the cache key + shouldRepaint, so a sensitivity change can't
replay a stale Picture built at the old thinning.
- live layer converts _liveStroke→EditorStroke per frame (correct: it must
repaint every move); eraser preview keeps the existing canvas painter.
- pen_canvas disposes the InkPictureCache.
Equivalent by construction (both paths call buildStrokeOutline with the same
thinning); device confirms final fidelity. The old canvas Static/LiveInkPainter
are now orphaned (buildStrokePath still used by tests) — P1 deletes them.
flutter analyze lib/editor clean; 90/90 tests (+6: thinning repaint/cache + live).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes a P0 step-8 test gap. Drives SaveScheduler with a recording
EditorRepository subclass (real in-memory ffi db only to satisfy the ctor) and
pins: flush writes immediately; rapid schedules coalesce to ONE debounced write
with the latest snapshot; the captured snapshot is isolated from later mutation
of the source list; distinct hosts flush independently; dispose cancels a
pending write; schedule-after-dispose is a no-op.
flutter analyze clean; 6/6 new, 84/84 total.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pdf_service._buildFreehandPdfPath hardcoded its OWN StrokeOptions
(thinning:0.7, streamline:0.5) instead of the shared geometry — the R7
hairline-export divergence. The prior pen-feel commit (streamline 0.5→0.32 on
screen) widened the gap: export still rendered at 0.5.
Extract the ONE perfect_freehand recipe into stroke_geometry.freehandOutlinePoints
(owns thinning/smoothing/streamline/simulatePressure). buildStrokeOutline (screen)
and pdf_service (export, via InkStroke→pfPoints) now both call it, so the
StrokeOptions live in exactly one place and screen↔export can't drift again.
Export now matches screen: thinning 0.85 (kDefaultPenThinning), streamline 0.32.
test/export_geometry_test.dart pins it: buildStrokeOutline traces exactly the
shared outline; default thinning == kDefaultPenThinning; thinning is wired;
empty input is safe.
flutter analyze lib/editor clean; 78/78 tests pass (+4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P0 step 4: the draw-vs-pan/zoom decision (single-pointer + device-kind + palm
rejection + hardware-pan-button suppression) is lifted verbatim out of the
PenCanvas StatefulWidget into pure functions in input/input_arbiter.dart, and
pen_canvas now delegates _shouldDraw/_isStylus to them. Behavior-identical
(same expressions), now decided by ONE unit-tested place.
Adds test/input_arbiter_test.dart pinning the full truth table: stylus/mouse
always draw, finger draws only with the toggle, >=2 pointers never draw (pinch
owns it), hardware pan button suppresses, trackpad/unknown never draw.
flutter analyze clean; 74/74 tests pass (+8). No live-path behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
W4/P0 engine: add engine/stroke_eraser.dart (pure, aspect-corrected) with
whole-stroke `strokeHit` + partial `splitStrokeByCircle`. Grazing a long
stroke now CUTS it into surviving pieces instead of deleting it whole.
Wired through PenCanvas.onEraseStroke (now (index, replacements)) →
pen_editor_screen._eraseStroke (replaceRange); undo/persistence unchanged
(whole-page snapshot). 8 new unit tests; 66/66 pass.
Fix side-button (侧键): _isEraserSignal used `buttons == kSecondaryButton`,
but tip-down + barrel = kStylusContact|kPrimaryStylusButton = 0x03, so the
side button only registered on hover, never while drawing. Now a bitmask
test. (Eraser-end/tilt remain blocked on the silent native badnote/pen
channel — needs on-device native logging.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
W1 — Custom pen width + pressure sensitivity (Saber-style):
- Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure
(hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen
force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated
all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset).
- De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by
the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity
with a Pressure Sensitivity slider; live-applies via a config listener.
W3 — Native Windows pen plugin (tilt + barrel/eraser buttons):
- windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler
(before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read
GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming.
- PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer
correlation); graceful no-op off-Windows.
- pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd
(eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt.
W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim);
definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3).
Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md
(Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE).
Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline +
tilt-adapter round-trip. flutter analyze clean; linux debug build OK.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make on-device OCR a pluggable local service so it runs locally on every
platform (not just Windows), aimed at GoodNotes/Notability-class handwriting on
low-power hardware (e.g. Zen2 APU, CPU/iGPU).
- New OcrBackend abstraction (lib/services/ocr/): selector prefers an embedded
ONNX recognition backend, falling back to the OS-native backend (Windows
WinRT), and to a clean no-op when neither is available.
- OnnxRecognitionBackend: flutter_onnxruntime session from a bundled asset,
dart:ui preprocessing (resize to 48px, CHW float32, normalized), pure-Dart CTC
greedy decode. Fully guarded — absent model/dict is a no-op; never throws.
- ocr_engine.dart kept as a thin facade (recognizeImage) delegating to the
selector, so ocr_service.dart is unchanged.
- CtcDecoder unit-tested (6 tests). flutter analyze clean; all tests pass.
- Model is not committed; tool/fetch_ocr_model.sh + assets/models/ocr/README.md
document fetching PP-OCRv4 rec + dict on the dev machine.
- CI: forward HTTPS_PROXY to the Windows build so CMake can fetch the ONNX
Runtime native lib behind the GFW; README documents the system-install
alternative. PP-OCR geometry/blank assumptions documented for on-device tuning.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bug fixes (Flutter):
- Wrap multi-statement DB writes (insert/update/delete note, deleteDocument,
deletePageData, OCR FTS merge, migrations) in transactions to prevent data
loss on interruption and a read-modify-write FTS race.
- Fix PdfDocument leaks on exception (try/finally dispose) and preserve image
aspect ratio when stamping images onto PDF pages.
- Guard file-picker against empty selection (was .single -> crash).
- Fix eraser ConcurrentModificationError and unmodifiable-list crash on PDF
pages; capture page synchronously on save to stop wrong-page data loss.
- Fix Riverpod DB-not-ready races, broken pull-to-refresh, settings load race,
and search N+1; transform stored annotations on PDF page rotation.
- Normalize pen pressure for devices without a pressure range.
- PPT: single source of truth for slide strokes so ink displays and exports.
UI/UX:
- Material 3 typography, theme-aware colors (dark-mode fixes), hover cursors
and right-click/visible actions on desktop, keyboard shortcuts (undo/redo/
save/find), toolbar overflow handling, friendlier empty states, semantic OCR
status badges, relative timestamps, 1-based page indicators, large-deck PPT
navigation, and a scratchpad-scope label in split view.
Server (optional backend):
- Persist JWT secret (was per-process random), block path traversal in storage,
fix CORS '*'+credentials, add OCR job ownership checks, last-writer-wins sync
guard, constant-time login, and split out heavy OCR deps so the API/tests run
without them.
CI: Gitea workflows for format+analyze+test (Linux, system sqlite) and a
Windows release build; pristine `flutter analyze`, all Flutter and server tests
green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>