Phase 2 (core swap). The PDF editor and split-view scratchpad stop
writing SQLite and persist to a per-file sidecar
`<pdfPath>.badnote.json` (debounced, atomic temp+rename+.bak) — so
annotations travel with the file. The source path is the identity
(no more djb2 doc-id).
- SidecarRepository wraps the Phase-1 store with debounced autosave.
- pen_editor: per-page ink, scratch-links AND highlights now persist
to the sidecar and restore on reopen (closes persist-highlights).
- New "un-highlight" tool: tap a stored highlight to remove it — the
highlight could not be removed before.
- split_view: each anchor's scratchpad lives in the sidecar's
scratchLinks[id].scratchpad, keyed by anchor id.
Note: pre-existing SQLite annotations are migrated later (Phase 5);
note/slide editors swap in Phase 4. analyze clean, tests green.
Phase 1 of the file-based storage plan. Pure library, no runtime
behavior change yet (editors still use SQLite).
- BadnoteSidecar: per-file annotation document (schema-versioned)
holding per-page ink (EditorStroke JSON), text highlights,
scratch-link anchors (ScratchLink JSON) each with its own
scratchpad (InkStroke world-coord JSON), and bookmarks. Reuses the
existing toJson formats — no parallel stroke format.
- SidecarStore.writeAtomic: temp-file + rename atomic write keeping a
.bak; read() falls back to .bak on a missing/corrupt primary.
Round-trip + atomic-write + .bak-recovery tests. analyze clean,
322 tests green.
Phase 0 of the file-based storage plan (docs/plans/
2026-06-24-file-based-storage.md). Foundation only — no editor or
DB change yet.
- VaultService (SharedPreferences): stores the vault root path,
vaultRootValid() = path set AND directory exists.
- VaultSetupScreen: first-run folder picker (file_picker, Windows).
- main.dart gates HomeScreen behind a valid vault, re-prompting if
the saved folder is gone.
- Settings: a Vault section to change the folder.
Editors still use SQLite; later phases move annotations into
per-file sidecars under the vault. analyze clean, tests green.
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.
Two critical PDF-editor bugs.
1. Live ink only appeared after lifting the pen. The page overlay
painter captured the live stroke as a build-time snapshot, so
per-move repaints redrew stale (null) data until commit. Route
the live stroke through a ValueNotifier the painter reads at
paint time (repaint: merge(overlayRepaint, liveStrokeVN)).
2. Pinch-zoom jumped on Windows touch. pdfrx's internal forked
InteractiveViewer scales with an unguarded scaleStart*details.scale
that pops on a touch-count blip or one-frame spike. Take over the
pinch: scaleEnabled:false (pdfrx keeps 1-finger scroll + wheel),
a glitch-guarded ScaleGestureRecognizer drives focal zoom via the
pdfrx controller, reusing absolutePinchScale + the re-baseline /
per-frame-clamp / focal-jump guards already proven on the note
canvas.
Zoom + pen feel are device-validated. analyze clean, tests green.
Replace the rejected standalone sticky-card board with the real
feature: place a link anchor anywhere on a PDF page, tap it to open
split view whose right pane is THAT anchor's own infinite scratchpad
(keyed by anchor id) — like a paper sticky-note tab.
- ScratchLink model + scratch_links table (id, doc, page, nx, ny).
- PDF editor: "place link" tool drops/loads/shows tappable markers;
tap opens SplitViewScreen for that anchor; long-press deletes.
- SplitViewScreen rebuilt on pdfrx (was syncfusion), right scratchpad
keyed by scratchLinkId, new brush palette (was AnnotationToolbar).
- Remove board_screen + its test + the home board entry.
analyze clean, tests green.
Wire the F7 双链 + 无限便利贴 model (Board/LinkGraph) into a
reachable screen. Previously the model existed but had no UI and
no entry point.
board_screen.dart: an infinite InteractiveViewer canvas of
draggable, editable sticky cards. Card text renders [[links]] as
tappable chips that pan to the target card (dangling links styled
apart). A backlinks panel lists "linked from" via backlinksOf.
"Add card" FAB drops a card at the viewport center.
Persistence: a board_cards table (DB v7), one row per card,
debounced 800ms like the ink editors, loaded on open — boards
survive restart. Entry added to the home screen app bar
(dashboard_customize icon).
Ink-on-cards, multi-board management and link autocomplete are
deferred (TODO board-ink / board-multi / board-link-autocomplete).
analyze clean, 285 tests green.
Replace the 2-tool ink system with a data-driven, Krita-style
BrushProfile (lib/editor/engine/brush.dart). Adding a brush is a
const map entry, not render-path branching.
Four presets from the rnote/krita spec:
- fountain pen: quadratic (p^2) pressure, wide dynamic width
- ballpoint: near-constant width (thinning 0.15)
- highlighter: flat width, square caps
- pencil: sqrt(p) pressure, moderate width
Pressure is pre-warped per brush via PressureCurve(gamma) before
perfect_freehand; geometry fields (thinning/streamline/smoothing/
caps) flow through the shared stroke recipe so the PDF overlay and
the note/slide PenCanvas both honor the brush. Brush kind is now
persisted on the stroke model. Picker added to all three toolbars.
Opacity/multiply and pencil grain are carried as data but not yet
composited (TODO brush-opacity / brush-texture); this increment is
width + pressure-curve differentiation. analyze clean, 283 tests.
The split-view scratchpad was the last surface on the old ink_canvas. Move
its inking engine to the performant PenCanvas while keeping the infinite
auto-expanding world.
Key idea: store strokes in absolute WORLD pixels (InkStroke — unchanged
saveScratchpad format) and render through PenCanvas by normalizing against
the CURRENT world size. When the world auto-expands, stored world coords do
not move — only the normalization divisor grows — so ink stays put with zero
drift (proven by the world-expand-stability test).
- Replace InteractiveViewer+SizedBox+InkCanvas with PenCanvas (own pan/zoom,
minScale 0.1 to survey the big world); keep the AnnotationToolbar.
- Stroke callbacks go through ink_stroke_adapter; load filters to freehand
so the canvas list stays 1:1 with the undo manager.
- Pen/highlighter/eraser map from the toolbar's PenTool; width is world px.
The left PDF-reference pane (SfPdfViewer, read-only) is unchanged.
Tests: world-expand stability added. flutter analyze: 0. Suite: 270/270.
PPT slides now annotate with the single performant inking engine
(PenCanvas) instead of the old ink_canvas, per "all note features on the
pen-first canvas".
- PenSlideScreen: per-slide normalized strokes over each slide image,
prev/next + slider nav, undo/redo, shared M3 palette, and the pressure
curve / eraser size+mode / palm rejection from the shared canvas.
- slide_export: pure, tested export geometry. Because strokes are now
normalized to the page rect, the PDF exporter maps them straight into
each slide's draw rect — fixing the old exporter's known ink
misalignment (it guessed live-widget size).
- Route PPT import + open -> PenSlideScreen; delete the dead old
ppt_annotator_screen. (ink_canvas/annotation_toolbar remain for
split_view, the last old-canvas screen.)
Tests: slide_export geometry (4). flutter analyze: 0 issues. Suite: 269/269.
Notes now use the single performant inking engine (PenCanvas) instead of
the old ink_canvas, per "all note features on the pen-first canvas".
- ink_stroke_adapter: pure InkStroke<->PenStroke bridge (normalize against
a logical note page; drop non-freehand shapes/text). Round-trip tested.
- pen_palette_widgets: shared M3 ToolButton/PaletteDivider/RoundIconButton
so PDF + note editors use identical chrome (PenEditorScreen migrated to
them; its private copies deleted).
- PenNoteScreen: PenCanvas over a white logical page, undo/redo, title,
save -> Note.strokes (+ local OCR for search). Pressure curve, eraser
size/mode and palm rejection all inherited from the shared canvas.
- Route home (new/open) + search note hits -> PenNoteScreen; remove the
now-redundant "Pen Canvas (beta)" spike button; delete the dead old
note_editor_screen.
Tests: ink_stroke_adapter (5) + pen_note_screen widget (load + commit, 2).
flutter analyze: 0 issues. Full suite: 265/265.
Extend the en/zh localization to the two screens reached from the home
app bar (the "全都用 + 多语言" ask):
- settings: title, theme-mode segments (System/Light/Dark), seed-color
description, color-picker + clear-data dialogs.
- search: hint, error, empty/no-results states, Notes/Documents section
headers, page label.
New ARB keys regenerated; l10n_test now asserts the new keys resolve in
both English and Chinese.
flutter analyze: 0 issues. l10n_test: 3/3.
Whole-project `flutter analyze` exited 1 on 17 pre-existing info/warning
lints (no errors) in dev tools and test files. Clean them so analyze is
green:
- editor_repository_test: drop the redundant sqflite_common import; keep
the used utils import with a transitive-dep ignore.
- pen_* widget tests: `(_, __)` wildcard params -> `(_, _)`.
- search_snippet_test / gen_bench_pdf / gen_dense_strokes: drop needless
interpolation braces; remove an unused `Size` show and an unused local;
mark the gen tool as print-allowed.
No runtime behavior changed. flutter analyze: No issues found. Affected
tests: 22/22 pass.
Two tests asserted pre-refactor behavior:
- pen_config_test expected the old pressureGamma default of 1.0; it is
now the natural curve (the pen-feel feature). Also assert the new
eraser defaults.
- widget_test pumped HomeScreen without localization delegates, which
the now-localized app bar requires. Provide the delegates.
Full suite: 258/258 pass.
"优化橡皮擦工具,你优化在哪" — the eraser already did segment erase, but
the radius was a hardcoded const with no size control and no whole-stroke
mode. Add both, OneNote/Notability-style:
- PenConfig: eraserRadius (0.005-0.1, default 0.02) + eraserWholeStroke
bool, with copyWith / JSON / setters.
- PenCanvas: uses widget.eraserRadius for the live hit area AND the cursor
preview (they stay in sync); eraserWholeStroke=true removes the whole
stroke on contact, false keeps the segment-split behavior.
- pen_editor threads both from PenConfig.
- pen-settings: new Eraser section — size slider + "Stroke Eraser" switch.
Tests: pen_eraser_mode_widget proves point-eraser keeps the untouched ends
while stroke-eraser deletes the whole stroke from the same pass.
flutter analyze: 0 issues.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"手写笔就是一个带压感的手指,没有特殊适配" — correct. The new editor fed
RAW LINEAR stylus pressure into perfect_freehand, and the pressureGamma
config (a slider in pen-settings) was read ONLY by that slider's UI and
NEVER applied to a stroke. Dead wiring, like the rest.
Wire it for real:
- PenCanvas applies PressureCurve(floor, gamma) at capture, so stored
pressure carries the feel and live + PDF export replay identically.
- Natural defaults: gamma 0.7 (light touches register more width, rnote/
OneNote-like) + floor 0.12 (thin strokes keep body, not scratchy).
- pen_editor threads PenConfig.pressureGamma into the canvas — the slider
now actually changes stroke width.
- pen_config: natural default + one-time migration of the legacy inert
gamma 1.0, guarded by a marker so a deliberate 1.0 still sticks.
Tests: pen_config_gamma_migration (4) + pressure_curve (6) pass; pen
widget regressions green. flutter analyze: 0 issues.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The app had zero localization ("软件多语言做了吗" — no). Add Flutter's
official gen-l10n pipeline and localize the core flow the user sees.
- pubspec: flutter_localizations + intl + generate: true
- l10n.yaml + lib/l10n/app_en.arb + app_zh.arb (37 strings)
- main.dart: localizationsDelegates + supportedLocales (follows OS locale)
- pen editor: all tool tooltips, page pill, error states localized
- home: app bar actions + empty-state buttons localized
Proven end-to-end: l10n_test pumps the same widget under Locale('en')
and Locale('zh') and asserts English vs Chinese strings resolve.
flutter analyze: 0 issues. l10n_test: 3/3 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Device log showed a single-frame scale pop (cur 0.504->0.694, a
+38% jump UP while the pinch was still shrinking).
Root cause: the absolute mapping targetScale = scaleStart *
details.scale is only valid when details.scale is 1.0 at the
moment scaleStart is captured. That holds at gesture start, but
on a mid-gesture re-baseline (a finger blips 2->1->2, routine on
Windows touch) a fresh scaleStart got multiplied by the
recognizer's still-cumulative details.scale, popping the zoom
then snapping back.
Fix: track rawScaleAtBaseline and normalize details.scale against
it so the cumulative reads 1.0 at every baseline. Extracted
absolutePinchScale() pure solver + 5 unit tests covering the
exact re-baseline scenario.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fills the gap where the committed-ink Picture cache (the P0.5 perf primitive:
record once per revision, replay every frame) was only covered indirectly via
StaticInkPainter. Verifies same-(host,revision) builds exactly once then returns
the cached Picture; a new revision rebuilds; host ids are independent; LRU
eviction beyond maxSize keeps the most-recently-used and disposes the rest
post-frame.
flutter analyze lib/editor clean; 244/244 tests (+4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two more device-independent live-path guards: the highlighter tool commits a
PenStrokeKind.highlighter stroke; a single finger PANS the shared transform
(translation changes) when finger-drawing is off and does NOT draw — the
touch→pan vs stylus→draw split.
flutter analyze lib/editor clean; 240/240 tests (+2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A stylus point drawn at 2x zoom maps to the correct normalized page coordinate
(canvas-local 200,300 → scene 100,150 → normalized 0.25,0.25), guarding that ink
stays glued to the page under zoom/pan — the core own-canvas invariant.
flutter analyze lib/editor clean; 238/238 tests (+1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>