Compare commits

..

88 Commits

Author SHA1 Message Date
307161f465 fix: restore PDF pen capture and overhaul sticky/pens/pages
All checks were successful
CI / Windows build (push) Successful in 9m55s
Reinstall PenCaptureBinding so stylus ink hits again; keep finger Listener translucent under pinch; page-anchor sticky with drag/resize; OneNote pen slots (brush+width+color); blank-note multi-page; default side button to hold-select-text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 02:38:58 +08:00
ad9b1b46db docs: note tip-velocity physics is wired at capture
All checks were successful
CI / Windows build (push) Successful in 18m10s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 16:01:53 +08:00
f31dd0fb52 fix: PDF finger ink, chrome UX, OneNote pens, and pen physics
Some checks failed
CI / Windows build (push) Has been cancelled
Wire finger drawing on PDF without breaking pinch; auto-hide page scrubber and fix bounce; share sticky tools with resize and per-page remember; side-button select; separate pen slots with colors; rnote pressure shapes plus tip-velocity width and lower stroke latency.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 16:01:01 +08:00
85af037b7d fix: coalesce pinch updates and stop live zoom write-back
All checks were successful
CI / Windows build (push) Successful in 9m55s
Surface Aug6 diag showed sDrop=0 but ~220 same-ms dual ZOOM frames and √2 cur ping-pong from reading currentZoom back into pinch state. Flush once per microtask and embed gitSha in diagnostic meta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 03:44:10 +08:00
4f6fb69dee fix: soft-clamp pinch zoom and Krita-inspired brush opacity
All checks were successful
CI / Windows build (push) Successful in 10m28s
Hard SDROP avalanches froze lastRaw while zoom still crawled; soft-clamp
and re-anchor instead. Ballpoint is near-solid, pencil uses soft √p without
multiply stacking; PDF ink falls back to nearest page during zoom settle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:51:15 +08:00
2b1c6ba7e0 feat: OneNote-style notebooks, text fonts, and page navigation
All checks were successful
CI / Windows build (push) Successful in 8m42s
Add notebook.json containers with multi-member pages, fix PDF text
editing (size/bold/drag/double-tap), index SidecarText in search, and
share keyboard page shortcuts plus a PDF scrubber.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:27:35 +08:00
4a6fe7d05e fix: Surface pen pressure, zoom glitches, sticky notes, selection UX
All checks were successful
CI / Windows build (push) Successful in 8m19s
Wire Win32 pressure into Dart, tighten pinch guards, use geometric shape
strokes, expand the ink palette, and replace scratch-link split view with
an on-page sticky that shares the sidecar repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:52:15 +08:00
198da00ecd feat: vault-aligned server v1 + UX polish
All checks were successful
CI / Windows build (push) Successful in 7m47s
Redesign the optional FastAPI companion around vault files (manifest /
PUT/GET/DELETE + OCR jobs) instead of legacy strokes_json notes. Wire a
client Server settings panel for health/login. Polish shell UX: l10n for
settings/home/board, sticky-board empty state, and a narrow-screen
diagnostics FAB.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:04:48 +08:00
d346cc2670 feat: unified shell, diagnostics pack, native Office, sticky board
All checks were successful
CI / Windows build (push) Successful in 14m22s
Make Surface remote debugging and classroom workflows viable: always-on
structured logs with one-click zip export, a single AppShell chrome,
OOXML PPTX/DOCX annotation without LibreOffice, and a first-class sticky
board. Also drop spike/legacy ink widgets and tighten pen feel
(predictor, PenInfoHistory, page-tile layer).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:55:27 +08:00
3cabc7e074 feat(sync): WebDAV vault sync
All checks were successful
CI / Windows build (push) Successful in 12m32s
Two-way sync of the vault folder to a user-configured WebDAV server,
so annotations (which travel with the file) sync with the file.

- WebDavSyncService.syncNow: per-file decision — local-only uploads,
  remote-only downloads, and when BOTH sides changed since the last
  sync it keeps the loser as <file>.conflict-<mtime> on both sides
  (last-write-wins by mtime) so no data is ever lost. Creates dirs as
  needed; deletes are conservative.
- The decision logic is pure and unit-tested against a fake WebDAV
  client; the real client is a thin http adapter (no dio dependency).
- Settings: WebDAV URL / user / password / remote folder, Test
  connection, Sync now (with status + last-synced), and an auto-sync
  toggle (default OFF).

Real server round-trips are device/server-validated. Credentials are in
SharedPreferences for now (TODO secure-storage). analyze clean, 432 tests.
2026-06-25 01:35:13 +08:00
e939759458 feat(search): index PDF text, OCR scanned PDFs on import
All checks were successful
CI / Windows build (push) Successful in 15m50s
Search now covers handwriting, the PDF text layer, AND scanned
(rasterized) PDFs.

- PdfTextIndexer runs at import: sums the embedded text layer across
  pages; if present it stores that as the document body, otherwise the
  PDF is rasterized and its rendered pages are OCR'd in the background.
  The result lands in the sidecar `pageText` field (distinct from
  `ocrText`, the handwriting OCR). Idempotent (skips a sidecar that
  already has pageText); degrades gracefully with no OCR engine.
- pdfrx_page_text_source abstracts text/render so it's testable.
- VaultSearchIndex now harvests title + typed text + handwriting OCR +
  PDF pageText, so search finds notes, typed PDFs and scanned PDFs.

analyze clean, 409 tests green.
2026-06-25 00:23:19 +08:00
20add27a30 feat(pdf): typed-text tool (Windows-Ink friendly)
Some checks failed
CI / Windows build (push) Has been cancelled
Add a text-annotation tool to the PDF editor. With the text tool a
pen-tap, or a mouse double-click, drops a text box at that normalized
page point and focuses a real Flutter TextField — so the OS IME and the
Windows-Ink handwriting panel feed it (device-validated). Tapping an
existing box re-opens it; clearing it deletes it.

- SidecarText {nx, ny, text, fontSize (page-relative), color} per page,
  glued under zoom; stored in the sidecar `texts` field (back-compat
  missing -> none), saved via scheduleTextsSave and loaded on open.
- Rendered in pageOverlaysBuilder at the scaled position.

PDF editor only for now (note text later). analyze clean, 397 tests.
2026-06-25 00:11:45 +08:00
1d5ba05bb8 feat(pdf): paragraph-precise bookmarks
Some checks failed
CI / Windows build (push) Has been cancelled
Add a bookmark tool to the PDF editor. A bookmark anchors to a precise
location: when text is selected it captures the selection's normalized
rect + the start char index in the page text (the true paragraph
anchor); with no selection it falls back to the tapped page + point.

- Bookmark model gains optional normalized anchor rect + charIndex +
  label (all absent from JSON when null, so old sidecars still load).
- Bookmarks persist in the sidecar (scheduleBookmarkUpsert) and load on
  open; a bookmarks panel lists them and tapping one jumps to its page.
  Delete is persisted.

Scoped to the PDF editor (note bookmarks later); scroll-to-anchor is
page-level for now. analyze clean, 391 tests green.
2026-06-25 00:00:16 +08:00
c800295c12 feat(note): page background templates (rnote-style)
Some checks failed
CI / Windows build (push) Has been cancelled
A blank note can show a page-background template painted behind the
ink, picked from the toolbar and persisted per notebook.

- NoteBackground: blank / dots / ruled / grid / cornell, drawn in
  page space (scales with zoom), subtle grey. Cornell = left margin +
  bottom summary rule over a ruled body.
- Stored as the enum name in the notebook sidecar (back-compat:
  missing/unknown -> blank), saved/loaded via SidecarRepository so it
  restores on reopen.
- Picker added to the note tool palette.

PDF backgrounds skipped (PDFs have their own page content). analyze
clean, 386 tests green.
2026-06-24 23:47:29 +08:00
46589a4c87 feat(pen): persist brush kind so it survives reload
Some checks failed
CI / Windows build (push) Has been cancelled
Closes TODO(brush-persist). EditorStroke now serializes its brush as
the stable BrushKind name; sidecars written before this field, and any
unknown name, load as fountainPen (back-compat). PenStroke<->EditorStroke
carry brush both ways, so a ballpoint/highlighter/pencil stroke keeps
its opacity/blend after a document is closed and reopened.

Note: InkStroke (the note/scratchpad world-coord format) has no brush
field, so notes derive brush from the tool — highlighter is preserved,
ballpoint/pencil collapse to fountainPen on reload (TODO: extend
InkStroke). PDF documents persist brush fully. analyze clean,
379 tests green.
2026-06-24 23:40:40 +08:00
6c2dd71b82 feat(pen): brush opacity + highlighter multiply
Some checks failed
CI / Windows build (push) Has been cancelled
Honor each brush's opacity/blend so the brushes feel distinct
(closes TODO(brush-opacity)).

- Shared paint resolver: a stroke's color alpha is multiplied by its
  brush opacity; ballpoint/pencil opacity is tied to pressure
  (per-stroke average this increment) so a ballpoint reads lighter
  than a solid fountain pen.
- Highlighter paints with BlendMode.multiply and draws once, so
  cross-stroke overlap darkens like a real marker while self-overlap
  doesn't.
- Applied across BOTH render paths (PenCanvas static/live painters and
  the PDF _PageOverlayPainter).

Pencil paper-grain texture still deferred (TODO brush-texture); brush
kind is not yet serialized (TODO brush-persist — next). analyze clean,
tests green.
2026-06-24 23:27:11 +08:00
24d13642fd feat(storage): app-pause flush + vault search index
Some checks failed
CI / Windows build (push) Has been cancelled
Phase 6 (final storage phase).

- SidecarRepositoryRegistry tracks every open repo; SidecarFlushObserver
  (a WidgetsBindingObserver in main) flushes them all on
  inactive/hidden/paused/detached, awaiting each flush — the last
  strokes can't be lost on app close, not just on the 800ms timer.
- VaultSearchIndex rebuilds by scanning vault sidecars (the source of
  truth) — note titles, OCR text and document names — and search_provider
  queries it, so search spans notes + PDFs. Rebuilt on launch / after
  import.

The vault file-based storage migration (Phases 0-6) is complete:
annotations travel with the file, picked vault folder, atomic autosave,
one Import-file entry, SQLite migrated to sidecars. analyze clean,
tests green.
2026-06-24 23:19:21 +08:00
4886f1b2df feat(storage): one-time SQLite to sidecar migration
Some checks failed
CI / Windows build (push) Has been cancelled
Phase 5. On first launch with a valid vault, migrate legacy SQLite
data into vault sidecars so nothing is lost on upgrade.

- SqliteToSidecarMigrator: documents (+ per-page ink, scratch-links
  + scratchpads, bookmarks) -> notebook folder + <file>.badnote.json;
  notes (+ strokes) -> notebook.badnote.json. Reuses existing JSON.
- Idempotent (skips already-migrated targets); missing source files
  still get their annotations migrated.
- DB relocates to <vault>/.badnote/index.sqlite; the legacy DB is
  renamed to .premigration ONLY after a successful pass, so a failed
  migration leaves data intact and the run-once flag unset.
- main.dart runs it once, gated on vaultMigrationDone.

Golden migrator tests (seeded legacy DB -> sidecars, idempotent
re-run, legacy preserved). analyze clean, tests green.
2026-06-24 23:05:10 +08:00
f4f0853eae feat(storage): notes are vault sidecar notebooks
All checks were successful
CI / Windows build (push) Successful in 12m55s
Phase 4. Standalone notes move off SQLite into the vault, like the
PDF annotations.

- "Create notebook" makes a vault folder with a notebook.badnote.json
  (BadnoteSidecar docType 'notebook' + a title field), opened via
  SidecarRepository.
- PenNoteScreen loads/saves its strokes (page 0) + title to that
  sidecar instead of the SQLite Note model.
- note_provider lists notes from a vault scan (VaultService.scanNotes
  = folders with notebook.badnote.json and no source file); the doc
  scan still excludes them. Delete removes the folder.

PDF/slide editors unchanged; pre-existing SQLite notes migrate in
Phase 5. analyze clean, tests green.
2026-06-24 22:48:18 +08:00
2f0fda5f95 feat(import): one Import-file entry + vault notebooks
All checks were successful
CI / Windows build (push) Successful in 14m14s
Phase 3. Import becomes a single top-level action beside "Create
notebook" and the library is vault-backed.

- VaultService.createNotebook copies a picked file into a fresh
  (de-duplicated) notebook folder under the vault; its sidecar lives
  beside it, so annotations travel with the file.
- Home screen: one "Import file" action with a multi-extension picker
  (pdf / docx / pptx); routes to the editor by extension.
- The document list is now a vault scan (folders with a source file),
  not the SQLite documents table — no cache, always correct.
- PPTX soffice detection fix; DOCX convert-on-import is best-effort
  and fails gracefully when LibreOffice is unavailable.

analyze clean, tests green.
2026-06-24 21:12:38 +08:00
978111eeff feat(storage): PDF editor persists to per-file sidecar
Some checks failed
CI / Windows build (push) Has been cancelled
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.
2026-06-24 21:03:28 +08:00
953c7b700f feat(storage): sidecar model + atomic store (lib only)
Some checks failed
CI / Windows build (push) Has been cancelled
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.
2026-06-24 20:53:01 +08:00
9fcac47ef2 feat(vault): pick a notebook vault folder on first run
Some checks failed
CI / Windows build (push) Has been cancelled
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.
2026-06-24 20:45:16 +08:00
875dabcd89 feat(tools): rnote-style toolbar core writing batch
Some checks failed
CI / Windows build (push) Has been cancelled
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
fd102b5703 fix(pdf): live ink follows pen + stop zoom jump
All checks were successful
CI / Windows build (push) Successful in 12m51s
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.
2026-06-24 20:18:22 +08:00
9bb5c483d6 feat(pdf): anchored scratch links replace board
Some checks failed
CI / Windows build (push) Has been cancelled
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.
2026-06-24 20:02:12 +08:00
f757701391 feat(board): sticky-note board with backlinks
All checks were successful
CI / Windows build (push) Successful in 20m32s
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.
2026-06-24 17:15:39 +08:00
0feca74278 feat(pen): extensible brush model (4 brushes)
All checks were successful
CI / Windows build (push) Successful in 14m54s
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.
2026-06-24 11:13:43 +08:00
45a8931b64 docs(pen): rnote + krita brush algorithm spec
All checks were successful
CI / Windows build (push) Successful in 17m28s
Source-grounded spec for the pen-engine rebuild (P1):
rnote PressureCurve (quadratic Pow2), Catmull-Rom -> cubic
bezier smoothing, Google ink-stroke-modeler spring params,
Krita ballpoint vs fountain-pen sensor sets, and concrete
perfect_freehand option sets per brush.
2026-06-24 02:32:55 +08:00
db6e3842c7 feat(pdf): rebuild editor on vector PdfViewer
Replace the single-page PdfPageView bitmap with a pdfrx
PdfViewer: real vector text, continuous scroll, native
pinch-zoom (no custom zoom solver, so no zoom-jump here).

Ink is glued per-page via pageOverlaysBuilder; the pen is
captured at the viewer level by PenCaptureRegion while touch
falls through to scroll / pinch / text-select.

Add select-text -> highlight via PdfTextSelectionParams: the
selection's fragment rects are stored as normalized page rects
and drawn under the ink. In-memory only for now.

Per-page persistence, undo/redo, tools, colors, thumbnails and
pen settings are reused verbatim. analyze clean, 270 tests green.
2026-06-24 02:32:41 +08:00
f41df2033f feat(editors): expose pen settings on note + slide
All checks were successful
CI / Windows build (push) Successful in 13m39s
The note and slide palettes lacked the settings gear the PDF editor has,
so width / pressure / eraser-size+mode / palm-rejection were unreachable
there (the user's 'toolbar 少了很多东西'). Add the gear to both; it opens
the existing rich pen-settings sheet.

flutter analyze: 0 issues.
2026-06-23 16:55:31 +08:00
df389177e6 fix(split): frame scratchpad on existing ink
Some checks failed
CI / Windows build (push) Has been cancelled
The pen-first scratchpad opened at identity transform, showing only the
empty top-left corner of the 4000x4000 world — so existing ink (drawn
elsewhere) was off-screen and the pane looked blank ("草稿纸根本没看到").

On first layout, fit the strokes' world bounding box into the pane (padded,
scale clamped 0.15-1.5) so saved ink is immediately visible; an empty
scratchpad falls back to a 1:1 view near the origin.

flutter analyze: 0 issues.
2026-06-23 16:52:35 +08:00
8908f42f76 feat(windows): disable pen tap / press-hold visual feedback
On pen-down the OS drew the "Windows Ink" tap ripple / press-and-hold ring
under the nib — ugly and laggy-looking while writing. Set the tablet input
service's MicrosoftTabletPenServiceProperty on both the top-level window and
the Flutter child (where WM_POINTER lands) with the disable flags
(PENTAPFEEDBACK, PRESSANDHOLD, PENBARRELFEEDBACK, TOUCHUIFORCEON/OFF, FLICKS)
so the pen draws instantly with no OS animation.

Native-only change (windows/runner/flutter_window.cpp); built by CI.
2026-06-23 16:52:25 +08:00
eae4493954 fix(zoom): stop re-baseline scale oscillation
Device log showed the applied scale oscillating ~1.4x every frame while
the raw pinch was smooth (cur 1.116->0.797->1.074, raw ~0.46). Root cause:
on a pointer-count re-baseline (Windows touch flickers 2<->1<->2 mid-pinch)
the code set _scaleStart = matrix.getMaxScaleOnAxis() — a read-back captured
at a glitchy instant — so the absolute map K = scaleStart/rawScaleAtBaseline
jumped frame to frame.

Fix: anchor the re-baseline to the CLEAN tracked _lastAppliedScale instead
of the live matrix read-back, so the displayed scale is continuous across
the re-baseline regardless of any matrix transient. The math is already
covered by the pinch_scale_solver "same-instant re-baseline" test; this just
feeds it the right value.

flutter analyze: 0. pinch_scale_solver + pen_zoom: pass.
2026-06-23 16:52:14 +08:00
0ae2671f9b feat(split): scratchpad inks on the pen-first canvas
All checks were successful
CI / Windows build (push) Successful in 20m37s
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.
2026-06-23 15:52:52 +08:00
ffb9e35755 feat(slide): rebuild PPT annotator on the pen-first canvas
All checks were successful
CI / Windows build (push) Successful in 14m34s
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.
2026-06-23 10:27:09 +08:00
dfe5f2a477 feat(note): rebuild note editor on the pen-first canvas
Some checks failed
CI / Windows build (push) Has been cancelled
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.
2026-06-23 10:21:40 +08:00
3507e929b1 refactor: delete dead old PDF annotator screen
All checks were successful
CI / Windows build (push) Successful in 11m52s
Now that every PDF entry point (home import, home open, search jump)
routes to PenEditorScreen, the old SfPdfViewer-based annotator is
unreachable. Remove it and the two widgets it solely owned:
- screens/pdf_annotator_screen.dart (981 lines)
- widgets/page_thumbnail_sidebar.dart
- widgets/pdf_annotation_layer.dart

annotation_toolbar and ink_canvas stay (still used by the note/ppt/
split-view screens). No references remain to the deleted files.

flutter analyze: 0 issues. Full suite: 258/258.
2026-06-23 10:06:02 +08:00
96594fbe1b feat(route): search opens PDFs in pen editor too
The search-result document jump still opened the OLD PdfAnnotatorScreen,
the last live entry to it. Route it to PenEditorScreen instead, and add
an initialPage param to the editor so the jump lands on the hit's page
(clamped to the document range once it loads).

With this, PenEditorScreen is the ONLY reachable PDF surface; the old
annotator is now dead code (no remaining references).

flutter analyze: 0 issues. Full suite: 258/258.
2026-06-23 10:04:38 +08:00
a7d71e7cfd feat(i18n): localize settings + search screens
Some checks failed
CI / Windows build (push) Has been cancelled
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.
2026-06-23 10:03:02 +08:00
d1b265dc70 chore: clean analyzer to zero issues
Some checks failed
CI / Windows build (push) Has been cancelled
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.
2026-06-23 09:57:04 +08:00
2c9f6037f7 test: fix gamma default + localized home
Some checks failed
CI / Windows build (push) Has been cancelled
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.
2026-06-23 09:51:10 +08:00
4fb431727e feat(eraser): configurable size + stroke-eraser mode
Some checks failed
CI / Windows build (push) Has been cancelled
"优化橡皮擦工具,你优化在哪" — 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>
2026-06-23 09:47:58 +08:00
299b9546a8 feat(pen): apply pressure-response curve for natural feel
"手写笔就是一个带压感的手指,没有特殊适配" — 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>
2026-06-23 09:43:56 +08:00
1d70c029b3 feat(i18n): add English + Chinese localization
All checks were successful
CI / Windows build (push) Successful in 12m34s
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>
2026-06-23 09:35:26 +08:00
f48e43f13d fix(zoom): kill re-baseline pinch pop
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>
2026-06-23 09:30:41 +08:00
5db364d1fc feat(route): open PDFs in pen-first editor
The night's pen-first rebuild (PenEditorScreen + PenCanvas + zoom fix +
eraser + M3 tool palette + render cache) was unreachable from the running
app: home_screen opened the OLD PdfAnnotatorScreen, so the user saw zero
change. Wire both PDF-open sites (import + open-existing) to PenEditorScreen,
making the entire editor/* stack LIVE on the real PDF path.

flutter analyze: 0 issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:28:18 +08:00
22f27641ca test(p0.5): direct InkPictureCache coverage (build-once + LRU)
All checks were successful
CI / Windows build (push) Successful in 11m58s
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>
2026-06-23 03:54:29 +08:00
8a0c850f51 test: highlighter-kind + single-finger-pan widget guards
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:53:09 +08:00
a2df8ae68f feat(perf): compact strokes (RDP) on persist (R10)
Some checks failed
CI / Windows build (push) Has been cancelled
Wires simplifyStroke into the save conversion: a fast Surface-Pen stroke's
hundreds of near-collinear samples are thinned before hitting the DB, shrinking
the row + speeding reload re-rasterization (R10) with no perceptible change. The
live in-memory strokes are untouched — only what we PERSIST is simplified.
Makes the (unit-tested) RDP core load-bearing.

flutter analyze lib/editor clean; 238/238 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 03:51:44 +08:00
3cfb793803 test: ink coordinate mapping through zoom (device-independent)
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:49:44 +08:00
84d7dc8d24 test: eraser path widget guard (device-independent)
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:48:33 +08:00
c8579d3e86 test: behavioral regression guard for the absolute-snapshot pinch fix (6829076)
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:47:19 +08:00
458d6448d3 test: PenCanvas widget test — live drawing path, device-independent
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:45:53 +08:00
eda0ee6006 refactor: pen_editor_screen._centerPage uses shared centerOffset
Some checks failed
CI / Windows build (push) Has been cancelled
Wires the now-tested viewport_fit.centerOffset into the live editor, replacing
the inline ad-hoc arithmetic. Behavior-identical (same centering at scale 1) —
makes the pure core load-bearing and kills the duplicate math.

flutter analyze lib/editor clean; 228/228 tests pass (no behavior change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 03:43:44 +08:00
2edb897000 feat(f3): viewport fit/centering math (fit-width / fit-page / center)
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:42:44 +08:00
c1a35b3290 feat(f8): search ranking + multi-source aggregation
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:41:06 +08:00
4c8cc73106 feat(f7): infinite-board model (便利贴 cards) + derived 双链 backlinks
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:38:12 +08:00
77b6ee415d feat: host-agnostic StrokeHost (CoordinateSpaceHost, plan principle #2)
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:36:44 +08:00
b1f131814e feat(f1): per-tool settings memory (color/width per tool) pure model
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:35:10 +08:00
1e836d6e7d feat(perf): RDP stroke simplification for storage/render compaction (R1/R10)
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:33:39 +08:00
b359000991 feat: stroke spatial bounds + broad-phase visibility (board culling)
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:31:54 +08:00
0cf58fb67a feat(f8): CJK-safe search text normalization + matching
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:30:42 +08:00
1aa2865d6f feat(f2): two-page spread layout foundation (pair rows + row heights)
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:27:20 +08:00
3e0b81be92 feat(f8): search snippet extraction pure core
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:25:19 +08:00
18ebce6d73 feat(f6): one-notebook-per-PDF logical PageMap (insert-blank/reorder) pure core
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:23:58 +08:00
eedb52d2f5 feat(f5): configurable pen pressure curve (floor + gamma) pure core
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:20:59 +08:00
852eb389ee feat: 双链 link graph pure core — [[link]] parse + backlinks (F7)
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:19:08 +08:00
c31bfd3445 feat(p0.5): continuous-single navigation math (current-page + scroll clamp)
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:16:20 +08:00
c03513da4f feat(p0.5): pdfrx-backed PageDocumentSource — static API source-pin (step 12/SF4)
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:14:25 +08:00
6b7cc14836 feat(p0.5): PageDocumentSource seam + fit-to-width metrics glue (step 10)
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:10:05 +08:00
1a3d1065f9 feat(p0.5): continuous-single page windowing math (step 10, automatable slice)
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 03:08:03 +08:00
07b543f3e1 feat(p0.5): DPI-bucketed PageTileCache + dpiBucketFor (step 10, automatable slice)
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>
2026-06-23 03:05:52 +08:00
eca5141372 feat(p0): live canvas renders via revision-gated ui.Picture cache (step 3)
Some checks failed
CI / Windows build (push) Has been cancelled
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>
2026-06-23 02:59:30 +08:00
f64e6561a0 test(p0): SaveScheduler debounce/snapshot/flush/dispose coverage (step 8)
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>
2026-06-23 02:53:28 +08:00
d50087247c fix(export): single-source freehand recipe screen+export (P0 step 7, R7)
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>
2026-06-23 02:50:53 +08:00
a48c0e7e56 refactor(p0): extract pure input_arbiter from pen_canvas (step 4) + truth-table test
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>
2026-06-23 01:56:13 +08:00
682907614d fix(zoom): drive pinch absolutely from gesture-start snapshot; snappier pen
All checks were successful
CI / Windows build (push) Successful in 12m36s
Zoom flicker root cause (from the on-device badnote_input_log.txt): the pinch
computed its per-frame scale change as desiredScale / getMaxScaleOnAxis(), i.e.
it fed the LIVE matrix back into its own update. Consecutive frames in the log
show `cur` (the live read) dropping to 0.75-0.89 for a single frame while the
result track stayed smooth, so the code demanded a 1.3-1.4x correction that
popped the zoom bigger/smaller and snapped back. The >1.4 glitch guard missed it
because the spikes sat at 1.31-1.40.

Fix: the scale branch of PenInteractiveViewer now drives the transform
ABSOLUTELY from a gesture-start snapshot (_scaleStart, _referenceFocalPoint) plus
the recognizer's clean, monotonic cumulative details.scale. Each frame is fully
re-derived in closed form (pure scale+translate, no matrix inversion, no live
read-back), so a transient mis-read or interleaved write cannot survive into the
next frame. The per-frame glitch guard now keys on the recognizer's own
scale-ratio (the true finger motion) instead of the corrupted live read. 2-finger
pan still falls out of the same focal-anchor formula.

Pen feel: lower perfect_freehand streamline 0.5 -> 0.32 (new shared constants
kPenStreamline/kPenSmoothing, single-sourced across screen + export so the
parity test still holds). At 0.5 a quick flick lagged so far behind the pen that
short fast strokes collapsed toward their start and rendered as a dot
("写字识别成单击"); 0.32 tracks the real path for a crisper, lower-latency feel.

flutter analyze lib/editor clean; 66/66 tests pass (incl. screen==export parity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:45:54 +08:00
f9ec04fe86 feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject
All checks were successful
CI / Windows build (push) Successful in 12m44s
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
ae9e070b46 fix(pen): eraser lag/stuck-red/reliability; zoom glitch-reject; native input diag
All checks were successful
CI / Windows build (push) Successful in 17m58s
Eraser (regression from the preview I added):
- LAG: the preview did setState on every hover/erase-move (rebuilding the whole
  canvas) and recomputed perfect_freehand getStroke per overlapped stroke per
  frame. Now the cursor is a ValueNotifier driving the preview layer's repaint
  directly (no canvas rebuild), and the highlight is a plain polyline of the
  point-runs inside the radius (no getStroke).
- STUCK RED ("一直红着"): the cursor was never cleared. Preview is now
  active-erase-only and cleared on pen up/cancel.
- "选中了的笔画也不见得能删掉": radius was strokeWidth*2 (tiny) so a pass removed
  ~2 points and the stroke survived. Now a decisive fixed 0.02 (page-width
  fraction). The highlight traces exactly the point-run that splitStrokeByCircle
  removes, so what turns red is what gets deleted.

Zoom: replace the per-frame scale CLAMP with glitch REJECTION — drop a frame
demanding an implausible per-frame scale jump (>1.4x or <0.71x; a real pinch is
≲1.15x/frame). A dropped frame catches up the next frame (absolute tracking), so
no lag, but the Windows multi-touch spike never shows. Pairs with the existing
pointer-count re-baseline.

Native diagnostic: ObservePenMessage now counts WM_POINTER* / PT_PEN / legacy
mouse messages it sees and emits them on the channel; PenInputService exposes
`debugSummary` and the overlay shows `native ptr=… pen=… mouse=… msg=0x…`. This
will tell us on-device whether WM_POINTER ever reaches the observer (→ buttons
recoverable) or Flutter is on a non-pointer path (→ not).

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-22 22:02:17 +08:00
45d89b7790 fix(pen): re-baseline zoom on pointer-count change; observe pen on child HWND
All checks were successful
CI / Windows build (push) Successful in 13m24s
Zoom jumping (device: min 0.5 / max 2.47 while zooming near 1): the per-frame
scale clamp limited single-frame spikes but not multi-frame runs. Root cause is
pointer-count transitions — on Windows touch the two fingers land/lift at
different times and digitizers drop/re-acquire touches, and stock InteractiveViewer
keeps _scaleStart/_referenceFocalPoint from the OLD finger set, so the next frame
jumps. PenInteractiveViewer now re-baselines (and skips the transitional frame)
whenever details.pointerCount changes. The per-frame clamp stays as a secondary
guard.

Buttons (device evidence: btn=1 for tip-down, side-button, AND inverted; kind
never becomes invertedStylus): Flutter does NOT surface the barrel/eraser/inverted
state at all — unreachable from Dart. The only path is the native badnote/pen
plugin, which was SILENT because WM_POINTER is delivered to the Flutter CHILD
view window, not the top-level FlutterWindow where ObservePenMessage was hooked.
Fix: subclass the child HWND (SetWindowSubclass + comctl32) and observe its
WM_POINTER messages, passing every message through unchanged via DefSubclassProc
(observation-only, input behavior preserved). This is what should finally feed
GetPointerPenInfo penFlags + tilt to the channel — to be confirmed on-device with
the diagnostic (btn / kind / tilt readout).

Dart: analyze clean, 66/66 tests, linux build green. Native compiles on Windows CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:14:50 +08:00
7e405453e0 feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
All checks were successful
CI / Windows build (push) Successful in 18m35s
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
25400c8b82 feat(pen): eraser delete-preview + pre-disable IV pan on stylus hover
All checks were successful
CI / Windows build (push) Successful in 14m2s
Eraser preview (user request, "加一个淡一点的描边"): new EraserPreviewPainter
shows the eraser circle and a faint red outline over the committed strokes the
eraser currently overlaps, so you can see what is about to be deleted. Mounted
only in eraser mode (tool or barrel/inverted signal) with a live cursor that
follows the hovering/erasing pen; shares _eraserRadius/_pageAspect with the live
erase so preview and action always agree. Kept in its own RepaintBoundary.

Pen-feel fix (the "写字识别成单击" pan-steal): panEnabled now also requires the
last pointer to not be a stylus. Windows fires stylus HOVER before contact, so
_lastStylus is already true when the pen touches down -> the InteractiveViewer's
pan is disabled BEFORE the stroke's first move, instead of one frame late. A
2+ pointer pinch still always pans (focal translation); a finger/mouse down
flips _lastStylus back so finger-pan keeps working.

Grounded in Rnote + Saber research: Saber uses the same button detection we have
(buttons==kSecondaryButton || invertedStylus); the deeper zoom-flash / draw-vs-
pan robustness wants a Saber-style forked InteractiveViewer (single recognizer,
decide-at-start) -- scoped as the next step, not done here. 66/66 tests, linux
build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:03:50 +08:00
c045fdd3ca feat(pen): partial/segment erase + fix side-button while drawing
All checks were successful
CI / Windows build (push) Successful in 16m23s
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>
2026-06-22 12:42:26 +08:00
3295018ee3 feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons)
All checks were successful
CI / Windows build (push) Successful in 11m34s
W1 — Custom pen width + pressure sensitivity (Saber-style):
- Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure
  (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen
  force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated
  all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset).
- De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by
  the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity
  with a Pressure Sensitivity slider; live-applies via a config listener.

W3 — Native Windows pen plugin (tilt + barrel/eraser buttons):
- windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler
  (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read
  GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming.
- PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer
  correlation); graceful no-op off-Windows.
- pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd
  (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt.

W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim);
definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3).

Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md
(Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE).

Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline +
tilt-adapter round-trip. flutter analyze clean; linux debug build OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
e4a94d00c0 feat(editor): undo/redo, thumbnails, pen settings
All checks were successful
CI / Windows build (push) Successful in 8m39s
Per the full-refactor plan (P0/P1/P2 modules, all pressure-independent):
- engine/undo_stack: generic snapshot undo/redo (per page in the editor)
- ui/thumbnail_grid: Drawboard-style lazy thumbnail nav sheet (pdfrx)
- input/pen_config + ui/pen_settings_page: configurable side-button /
  eraser-end action mapping, pressure curve, palm sensitivity, finger
  drawing, widths (shared_preferences). Button-action mappings persist
  but consume in the input arbiter later; widths/finger consumed now.
Wired into the live editor (undo/redo + grid + settings buttons). 19
new tests.
2026-06-21 23:56:38 +08:00
28b7af7e72 feat(editor): persist strokes in live editor
Some checks failed
CI / Windows build (push) Has been cancelled
Wire the new EditorRepository + SaveScheduler into PenEditorScreen:
load strokes on open (keyed by a stable djb2 doc-id from the path),
save per page on commit/erase via the debounced diff-write scheduler
(synchronous snapshot before await), flush on dispose. Strokes now
survive close/reopen. PenStroke<->EditorStroke conversion at the
boundary.
2026-06-21 23:45:47 +08:00
914951afb7 feat(engine): P0 stroke engine + persistence
Per the full-refactor plan §9 (input-independent half of P0):
- engine: canonical EditorStroke (lossless InkStroke round-trip) +
  stroke_geometry (single getStroke outline) + revision-gated StrokeStore
- render: static/live ink painters + ink_picture_cache (revision-keyed)
  + annotation_layer (RepaintBoundary)
- persistence: DB v6 (ink, notebook_pages) + editor_repository diff-write
  (UPSERT changed / DELETE removed in one txn; id-set after commit) +
  save_scheduler
- pdf_service export now FILLS the getStroke outline (R7 hairline fix)
Not yet wired into the live editor (input relocation pending pen-pressure
diagnostic). 28 new tests pass.
2026-06-21 23:41:01 +08:00
216 changed files with 43151 additions and 5587 deletions

View File

@@ -0,0 +1,33 @@
# PUA Loop — status (BadNote 整体重构)
## Oracle: `flutter analyze lib/editor && flutter test` → GREEN, exit 0, 145/145.
## Delivered this loop (all committed + pushed, 6829076..eedb52d)
P0 completion + P0.5 automatable layer + bonus pure cores:
- 6829076 zoom absolute-snapshot fix + pen streamline (P0/live)
- a48c0e7 input_arbiter (P0 step4)
- d500872 export single-source recipe / R7 (P0 step7)
- f64e656 SaveScheduler tests (P0 step8)
- eca5141 live canvas → revision-gated ui.Picture cache (P0 step3)
- 07b543f PageTileCache DPI-bucketed (P0.5)
- 1a3d106 PageStackMetrics windowing (P0.5)
- 6b7cc14 PageDocumentSource + fit-to-width glue (P0.5)
- c03513d PdfrxPageDocumentSource + API source-pin (P0.5 step12/SF4)
- c31bfd3 navigation math current-page/scroll-clamp (P0.5)
- 852eb38 双链 link_graph pure core (F7)
- eedb52d pressure curve floor+gamma (F5)
## THE BLOCKER (honest)
The refactor's CRITICAL PATH is device validation, which only the user's Surface
can provide and which the plan ITSELF gates on:
- P0 step9: pen/palm/pinch on Surface (build eedb52d).
- P0.5 exit: crisp-at-4× + 60fps profile — no automatable acceptance test exists.
The remaining work (page_tile renderer, page_viewport WIDGET, perf bench) is
device/GPU-gated; writing it blind = a claim with no acceptance evidence.
## Options for the user
1. Device-test eedb52d (zoom/pen/render/export) → I wire the P0.5 pure pieces
into the viewport widget and push the P0.5 device gate.
2. Tell me to keep PRE-BUILDING unwired pure cores (F6 page-map, F8 snippet
extraction, more F5/F7) — real + tested, but NOT on the blocked critical path.
3. /pua:cancel-pua-loop to end the loop.

View File

@@ -0,0 +1,3 @@
{"iteration":0,"status":"init","verify_command":"flutter analyze","timestamp":"2026-06-23T01:25:48Z"}
{"iteration":1,"status":"continue","timestamp":"2026-06-23T01:57:58Z"}
{"iteration":2,"status":"continue","timestamp":"2026-06-23T02:07:19Z"}

View File

@@ -220,7 +220,13 @@ jobs:
HTTPS_PROXY: http://192.168.31.189:7890 HTTPS_PROXY: http://192.168.31.189:7890
http_proxy: http://192.168.31.189:7890 http_proxy: http://192.168.31.189:7890
https_proxy: http://192.168.31.189:7890 https_proxy: http://192.168.31.189:7890
run: flutter build windows --release shell: powershell
run: |
$sha = if ($env:GITHUB_SHA) { $env:GITHUB_SHA.Substring(0, [Math]::Min(12, $env:GITHUB_SHA.Length)) } else { "unknown" }
$built = Get-Date -Format "yyyy-MM-ddTHH:mm:ssK"
flutter build windows --release `
--dart-define="BADNOTE_GIT_SHA=$sha" `
--dart-define="BADNOTE_BUILD_TIME=$built"
- name: Show build output - name: Show build output
shell: powershell shell: powershell

3
.gitignore vendored
View File

@@ -60,3 +60,6 @@ server/.env
# M1 spike generated bench assets (regenerate via tool/gen_*.dart) # M1 spike generated bench assets (regenerate via tool/gen_*.dart)
/test/assets/large_300p.pdf /test/assets/large_300p.pdf
/test/assets/dense_strokes.json /test/assets/dense_strokes.json
# On-device input diagnostic capture (local only)
badnote_input_log.txt

View File

@@ -1,17 +1,20 @@
# BadNote # BadNote
Local-first Surface Pen note-taking app with PDF/PPT annotation. Local-first Surface Pen note-taking app with PDF / PPTX / DOCX annotation.
All notes, documents, search, and OCR run on your device. No server is required to use the app. All notes, documents, search, and OCR run on your device. No server is required to use the app.
## Features ## Features
- Ink notes with Surface Pen (pressure, stabilizer, undo/redo) - Unified shell: Library · Sticky board · Search · Settings
- PDF and PPT import with page-level annotation - Ink notes with Surface Pen (pressure, predictor, undo/redo)
- PDF annotation + native OOXML PPTX/DOCX viewers (no LibreOffice required)
- Infinite sticky board with `[[wikilinks]]` / backlinks
- Full-text search over note titles, typed text, and OCR results - Full-text search over note titles, typed text, and OCR results
- **Local OCR** — pluggable, fully on-device. An embedded ONNX recognition - Always-on diagnostics + one-click diagnostic pack export (Settings)
backend (cross-platform, CPU/iGPU) with a graceful fallback to the platform's - Optional self-hosted **BadNote Server** (`/api/v1`: vault assist + OCR jobs) — see [server/README.md](server/README.md)
built-in OCR (Windows). See [Local OCR](#local-ocr). - **Local OCR** — ONNX when bundled, else Windows WinRT
- WebDAV vault sync (NAS)
## Build (Windows) ## Build (Windows)

7888
badnote_input_log-2.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,446 @@
# BadNote — Full Refactor + Feature Roadmap (own-canvas engine)
**Status:** PLAN (ralplan consensus — Architect APPROVE-WITH-MUST-FIX + Critic-ITERATE fixes applied 2026-06-21: MF1MF3 + P0.5 slice + SF1SF5 + cache-split / diff-write-durability / erase-is-new-behavior / render-handle-lifecycle; pending final Critic confirm)
**Date:** 2026-06-21
**Mode:** DELIBERATE (high-risk: engine generalization, 60fps continuous/double-page, OCR/CAS feasibility, server/AI scope)
**Owner plan file:** `docs/plans/2026-06-21-badnote-full-refactor.md`
**Supersedes (in part):** `docs/plans/2026-06-21-badnote-phase1.md`
> **Grounding.** Written after reading the live code, not from memory. Verified sources:
> live editor `lib/editor/canvas/{pen_canvas,ink_painters,pen_stroke,pen_editor_screen}.dart`,
> `lib/editor/pdf/pen_capture_region.dart`, `lib/main.dart` (dynamic_color + `pdfrxFlutterInitialize` + `PenCaptureBinding`),
> `lib/services/{database_service,pdf_service,stroke_rasterizer,undo_manager}.dart`,
> `lib/services/ocr/onnx_recognition_backend.dart`, `lib/models/{ink_stroke,ink_point,pressure_curve}.dart`,
> `lib/providers/settings_provider.dart`, `lib/screens/home_screen.dart`,
> `server/badnote_server/{main,models,routers/sync_router}.py`, `server/README.md`,
> `pubspec.yaml`, existing `test/` + `integration_test/` + `tool/` assets.
>
> **CRITICAL DIRECTION CHANGE captured here (vs phase1 plan):** the live `lib/editor/canvas/` has **already abandoned** the phase-1 "pdfrx `pageOverlaysBuilder`-hosted ink + RenderProxyBox arena bypass" architecture. The live `PenCanvas` owns **one `InteractiveViewer` + a single `Listener`** that draws a `PdfPageView` *bitmap* and the ink in the **same** child subtree (Saber clean-room model). pdfrx is used only as a **page renderer / document API** (`PdfDocument.openFile`, `PdfPageView`), never for gestures. `PenCaptureRegion`/`PenCaptureBinding` are installed in `main()` but are **NOT wired into the live canvas** and are slated for retirement (§6, §9). This plan builds the full product on the **own-canvas** model and records that decision in the ADR (§10).
---
## 0. Vision & Scope
**BadNote** is a pen-first, performant note-taking app. Primary device: **Windows Tablet + Surface Pen** (Flutter ≥ 3.44 for WM_POINTER stylus/multitouch); desktop for review/复习/search. Differentiators the user explicitly wants to win on: **(1)** library-wide full-text search over PDF text + typed text + handwriting OCR with jump-to-location; **(2)** modern cohesive **Material You** UI; **(3)** truly **book-like reading** (page-flip, two-page spread, thumbnail grid, reader-vs-annotate modes). Plus a **双链 sticky-note infinite board**, **one-notebook-per-PDF** with insertable blank pages and never-rasterized source, and a later **server sync + AI refinement** and **ink CAS**.
**Existing user data MAY be reset** — no migration burden; prefer the cleanest schema. **Stack stays Flutter** (own gesture pipeline; pdfrx only renders pages). GFW-aware CI already solved (pdfium pre-fetch + Dev Mode + vendored sqlite3 + onnxruntime pre-fetch).
### 0.1 The 11 feature areas → phase map (full vision; nothing dropped, everything sequenced)
| # | Feature | Phase | Risk |
| --- | --- | --- | --- |
| F1 | Pen-first editor core (harden): pressure, palm rejection, perfect_freehand quality, eraser, tools, undo/redo, **DB persistence of strokes per doc/page** | **P0** | LowMed |
| F2 | Page layout modes: continuous-single/-double, paged-single/-double + switcher; lazy render @60fps | **P1** | **High** |
| F3 | Book-like reading: page-flip, two-page spread, reader vs annotate mode, quick jump | **P1** | Med |
| F4 | Thumbnail-grid navigation (Drawboard-style), slider, no keyboard input | **P1** | Low |
| F5 | Configurable pen: side-button + eraser-end mapping, pressure curve, palm sensitivity, finger-drawing toggle — Pen settings page | **P2** | Med |
| F6 | One-notebook-per-PDF: page-level binding, insert blank pages between PDF pages, keep PDF vector, portable bundle + relink | **P2** | Med |
| F7 | Infinite 便利贴 board + **双链** bidirectional links / backlinks | **P3** | MedHigh |
| F8 | Library-wide full-text search (PDF text + typed text + handwriting OCR) with snippets + jump | **P3** | **High (handwriting OCR)** |
| F9 | Server sync + AI refinement (llm_wiki, VLM/LLM organize) | **P4** | High (scope) |
| F10 | Ink CAS / formula recognition → searchable + solve toggle | **P5** | **Very High** |
| F11 | Modern UX polish: Material You, subtoolbars, drag-reorder thumbnails, hover pre-warm | **woven across P0P3** | Low |
### 0.2 Non-goals per phase
- **P0:** no layout modes, no board, no text boxes, no search UI, no settings page beyond what exists, no server. Single-page editor + persistence + eraser/undo only.
- **P1:** no 双链, no OCR-search, no server, no CAS. Layout/reading/thumbnails only.
- **P2:** no server, no CAS, no handwriting OCR. Pen-config + notebook/bundle only.
- **P3:** no CAS, no server-side AI. Board+双链 and **local** search (incl. best-effort handwriting OCR via existing text-line model; formula deferred to P5).
- **P4:** sync + AI organize; CAS still out.
- **P5:** formula OCR + CAS.
- **All phases:** never rasterize the source PDF; no on-screen keyboard reliance (slider/grid nav).
---
## 1. Target Architecture
### 1.1 Layered module decomposition (target `lib/editor/`)
```
lib/editor/
├─ engine/ # host-agnostic ink engine (pure-ish, testable)
│ ├─ coordinate_space_host.dart # CoordinateSpaceHost + NormalizedPageHost + BoardHost
│ ├─ stroke_model.dart # EditorStroke/EditorPoint (canonical; replaces PenStroke split — §3, §4)
│ ├─ stroke_store.dart # StrokeStore { committed, int revision } (O(1) shouldRepaint gate)
│ ├─ stroke_geometry.dart # buildStrokeOutline(getStroke) — single source for screen+export (kills hairline bug)
│ ├─ stroke_eraser.dart # pure splitStroke / eraseHits (extract from pen_canvas._eraseAt)
│ └─ undo_stack.dart # global, commit-time-ordered, host-tagged (generalize undo_manager)
├─ input/
│ ├─ input_arbiter.dart # pure state machine: pointerCount>=2→pan/zoom, stylus→draw, palm reject
│ └─ pen_config.dart # button/eraser-end action mapping, pressure curve, palm sensitivity (F5)
├─ render/
│ ├─ annotation_layer.dart # Stack(StaticInkPainter, LiveInkPainter, TextBoxLayer) over a host
│ ├─ static_ink_painter.dart # reads ink_picture_cache, keyed by revision
│ ├─ live_ink_painter.dart # in-progress stroke only
│ └─ ink_picture_cache.dart # LRU<revision, ui.Picture> for STATIC INK — resolution-INDEPENDENT, NO DPI bucket (vector ink re-rasterizes crisp at composite)
├─ layout/ # F2/F3 — page layout + reading
│ ├─ page_layout.dart # enum {continuousSingle, continuousDouble, pagedSingle, pagedDouble}
│ ├─ page_viewport.dart # lazy windowed page hosting (visible + cache extent), recenter
│ └─ reader_controller.dart # reader vs annotate mode, page-flip, spread, quick-jump
├─ pdf/
│ ├─ pdf_document_source.dart # wraps PdfDocument (open/dispose/pageSize/render), notebook page-map (F6)
│ ├─ page_tile.dart # PdfPageView host widget (bitmap) sized to host rect; drives tile DPI from transform scale (R11)
│ └─ page_tile_cache.dart # LRU<TileKey{hostId, dpiBucket}, ui.Image> for rendered PAGE BITMAPS — DPI-bucketed (tiles blur on upscale); owns native-handle dispose (R11)
├─ board/ # F7
│ ├─ board_host_pane.dart # infinite InteractiveViewer board, reuses engine
│ └─ link_graph.dart # 双链 backlink model + queries
├─ text/ # OneNote-like editable text boxes (P3+, optional in P2 notebook)
│ ├─ text_box_model.dart
│ └─ text_box_layer.dart
├─ persistence/
│ ├─ editor_repository.dart # batched load/save per document; maps DB ↔ engine
│ └─ save_scheduler.dart # debounced, synchronous-snapshot-before-await
├─ search/ # F8 (P3)
│ ├─ search_indexer.dart # PDF text + typed text + OCR → document_fts/library index
│ └─ ocr_ingest.dart # bridge to lib/services/ocr (text-line; formula later)
├─ sync/ # F9 (P4)
│ └─ sync_client.dart # talks to server/ (already has push/pull/auth)
└─ ui/
├─ editor_screen.dart # top-level editor (replaces pen_editor_screen + pdf_annotator_screen)
├─ editor_toolbar.dart # Material You subtoolbars (F11)
├─ thumbnail_grid.dart # F4 nav
└─ editor_shortcuts.dart # tool numbers, Ctrl+Z/Y/F/S (mouse/desktop)
```
### 1.2 Dependency graph (acyclic)
```
ui/ ─────────────► layout/ ─► pdf/ ─► (pdfrx PdfDocument/PdfPageView)
│ │
│ ├──────► render/ ─► engine/ (geometry, store, hosts)
│ └──────► input/ ─► engine/
├──► board/ ─────────────► render/, engine/
├──► text/ ─────────────► engine/(hosts)
├──► search/ ────────────► persistence/, services/ocr/
├──► sync/ ────────────► persistence/, server API
└──► persistence/ ───────► services/database_service.dart, engine/(stroke_model)
```
**engine/** depends on nothing in editor/ except itself (and `perfect_freehand`). **render/input/layout/** depend on engine. **ui/** is the only place wiring Riverpod controllers. This is the seam that lets PDF-page host, infinite board, and (P5) CAS overlay all reuse one renderer.
### 1.3 Coordinate model — single source of truth
**Truth = host content coordinates** (already the live convention; generalize it):
- **NormalizedPageHost:** points ∈ `[0,1]` of the page rect (today `PenPoint(nx, ny)`); width as fraction of page width (today `PenStroke.width`). Stored per `(documentId, pageIndex)`.
- **BoardHost:** absolute logical px on an unbounded canvas; `InteractiveViewer` supplies pan/zoom.
`CoordinateSpaceHost` exposes `toContent(deviceLocal, deviceSize)`, `toDevice(content, deviceSize)`, `applyContentToCanvas(canvas, deviceSize)`. The live painters already scale `nx*pageSize.width` at paint time — formalize that into `applyContentToCanvas` (a `canvas.scale`) so no transformed-point copies are ever allocated. Text boxes are a **sanctioned widget-space exception** (Positioned widgets multiply by current zoom; stored truth stays content coords for search).
### 1.4 How today's `lib/editor/canvas/` evolves into this (no rewrite)
| Today (live) | Becomes | Action |
| --- | --- | --- |
| `pen_stroke.dart` `PenStroke`/`PenPoint` (in-memory only) | `engine/stroke_model.dart` `EditorStroke`/`EditorPoint` **persistable** (freezed/JSON, normalized) | **generalize + add JSON**; converge with `InkStroke` (§4) |
| `ink_painters.dart` `StaticInkPainter`/`LiveInkPainter` + `buildStrokePath` | `render/static_ink_painter.dart` (+ `render/ink_picture_cache.dart`), `render/live_ink_painter.dart`, `engine/stroke_geometry.dart` | **move + add ink `ui.Picture` cache (resolution-independent, revision-keyed) + revision gate**; `buildStrokePath``buildStrokeOutline` shared with export |
| `pen_canvas.dart` `_PenCanvasState` gesture logic (`_activePointers`, `_shouldDraw`, palm reject, `_eraseAt`) | `input/input_arbiter.dart` (pure SM) + `render/annotation_layer.dart` (widget) | **extract** SM out of the widget; eraser→`engine/stroke_eraser.dart` |
| `pen_editor_screen.dart` single-page + `_strokesByPage` Map + `_transform` + slider | `ui/editor_screen.dart` + `layout/page_viewport.dart` + `persistence/editor_repository.dart` | **generalize** single-page → windowed multi-page; `_strokesByPage` → repository-backed `HostState`s |
| `pen_capture_region.dart` + `PenCaptureBinding` (unused by live canvas) | — | **retire** after P0 confirms the `Listener`-in-shared-transform model holds on device (§6, §9) |
| `pen_editor_screen` single `PdfPageView` bitmap inside the `InteractiveViewer` (1× layout-sized bitmap, matrix-scaled by pinch) | `pdf/page_tile.dart` **multi-resolution tile** whose render DPI tracks the transform scale | **NEW work (not relocation): own multi-resolution tiling** — pinch GPU-upscales a 1× bitmap → blurry at high zoom (R11). On zoom-settle, re-instantiate the tile (or `PdfPage.render()`) at a DPI matching the current scale into `pdf/page_tile_cache.dart` (DPI-bucketed page-bitmap cache, distinct from the ink cache). Budgeted P1/P0.5. |
Net: the four live files are **promoted, not thrown away**. P0 is mostly *relocation + persistence + revision-gated Picture cache*, which is low risk and immediately shippable. The one genuinely **new** burden the own-canvas choice imposes (beyond layout/windowing) is **multi-resolution page-tile rendering** — see R11 (§7) and the P0.5 gate (§5/§9): we own crisp-on-zoom re-rasterization that pdfrx's own viewer would have given for free.
---
## 2. Per-feature design (file-level)
### F1 — Pen-first editor core + persistence (P0)
**Add/modify:**
- `engine/stroke_model.dart` **[ADD]** — `EditorStroke { id, List<EditorPoint> points, EditorTool tool, int color, double width, bool filled, String? textContent, double fontSize }`, `EditorPoint { double x, y; double? pressure; double? tilt; int? timestamp; InputDeviceKind? pointerDeviceKind }`. freezed + `toJson/fromJson`. (Normalized; `width` = fraction of page width, matching live `PenStroke`.) **SF1 — non-lossy superset of `InkStroke`/`InkPoint`:** `InkPoint` carries `tilt`/`timestamp`/`pointerDeviceKind` (database_service.dart:346362) that the live `PenPoint` drops; `EditorPoint` includes them now (nullable, cheap with freezed) so the `InkStroke``EditorStroke` adapter (for OCR/export reuse) is **lossless in both directions**. The live `PenCanvas` simply leaves the extra fields null at capture; existing `InkStroke` data round-trips intact. (Avoids a silent data-loss footgun for OCR, which keys on `pointerDeviceKind`/`pressure`.)
- `engine/stroke_store.dart` **[ADD]** — `class StrokeStore { List<EditorStroke> committed; int revision; add/removeAt/replace bump revision; }`. Fixes the live `pen_editor_screen._commitStroke` "new list identity" hack by making revision explicit.
- `engine/stroke_geometry.dart` **[ADD]** — `Path buildStrokeOutline(EditorStroke, Size, {bool isComplete})` lifted verbatim from `ink_painters.buildStrokePath` (the proven `getStroke(thinning: hl?0:0.7, smoothing:.5, streamline:.5, simulatePressure: !hasRealPressure && !hl)` recipe). **Single source** for screen + export.
- `engine/stroke_eraser.dart` **[ADD]** — pure `eraseHits(strokes, point, radius)` (extract live `_eraseAt`) + `splitStroke` for partial/segment erase. **Critic note — `splitStroke` is NEW behavior, not an extraction:** the live `pen_canvas._eraseAt` is **whole-stroke** (it removes the entire stroke on the first proximity hit and returns). There is no segment-split logic in the live canvas to port — `undo_manager.removeStroke` only *records* replacements, it does not compute them. So `splitStroke` (point-run splitting → 0/1/2 sub-strokes) is greenfield code; its tests (§8) exercise new functionality, not a regression port. `eraseHits` (whole-stroke) IS an extraction of the live behavior and stays available as the default erase mode.
- `engine/undo_stack.dart` **[ADD]** — generalize `lib/services/undo_manager.dart` to host-tagged, commit-time-ordered global stack (entries carry `hostId`+`pageIndex`).
- `render/{annotation_layer,static_ink_painter,live_ink_painter,ink_picture_cache}.dart` **[ADD]** — relocate live painters; `StaticInkPainter` reads `ink_picture_cache` (`LRU<revision, ui.Picture>`) keyed by `revision` (`shouldRepaint = old.revision != revision` — O(1)); per-host `RepaintBoundary`. **`ink_picture_cache` is for STATIC VECTOR INK only — resolution-INDEPENDENT, NO DPI bucket** (a `ui.Picture` of vector strokes re-rasterizes crisp at composite time at any zoom). It is a **different cache from the page-bitmap `page_tile_cache`** (§2/F2, R11), which IS DPI-bucketed because raster page tiles blur on upscale. Do not conflate the two.
- `input/input_arbiter.dart` **[ADD]** — pure SM extracted from `pen_canvas` (`idle→inking→erasing`, `pointerCount>=2`→cancel+pan, palm reject = touch dropped while stylus active, eraser = `kSecondaryButton || invertedStylus`). Keeps the live pressure-normalization logic (`_normalizedPressure`).
- `persistence/editor_repository.dart` **[ADD]** — `loadDocument(documentId) → Map<int,StrokeStore>` (one batched query), `saveHost(documentId, hostId, EditorStroke list)`.
- **MF3 — write contract (load-bearing for the per-row choice):** `saveHost` MUST **diff by stroke id** against the rows already on disk for that host — **UPSERT only changed/new rows, DELETE only removed rows**. It must NOT delete-all-rows-for-host then re-insert (that is exactly what the live notes path does at database_service.dart:281306, and at 2,000 strokes it is **slower** than a single blob rewrite — re-inserting 2,000 rows per save). The per-stroke-row schema (§3) is justified **only** under this diff contract: erasing 1 of 2,000 strokes ⇒ 1 DELETE, 0 re-inserts; adding 1 stroke ⇒ 1 INSERT. The `SaveScheduler` hands `saveHost` the synchronously-captured stroke snapshot; the repository keeps a last-persisted id-set per host to compute the diff. If diffing proves fiddly under churn, the fallback is a per-page blob (NOT delete-all+reinsert) — but the diff path is the default and is what makes per-row worthwhile.
- **Durability invariant (Critic):** the UPSERT(s) + DELETE(s) for one `saveHost` MUST run inside **one `sqflite` transaction**, and the in-memory **per-host last-persisted id-set is updated ONLY after that transaction commits** (in the `then`/post-await success path) — never optimistically before the write. A crash or interruption mid-diff must leave memory and disk consistent: either the whole diff applied (and the id-set advances) or none of it did (and the id-set is unchanged, so the next save re-derives the same diff and retries). This mirrors the existing `updateNote` interruption warning (database_service.dart:281306: "an interruption mid-way would permanently lose strokes, so the whole sequence must run inside one transaction").
- `persistence/save_scheduler.dart` **[ADD]** — debounced ~800ms; **serialize JSON synchronously before any await**; flush on page-leave/dispose.
- `ui/editor_screen.dart`, `ui/editor_toolbar.dart` **[ADD]** — port `pen_editor_screen` UI (floating Material You palette + page pill already good).
**Public interfaces (key):**
```dart
abstract class CoordinateSpaceHost {
String get hostId; int get pageIndex;
Offset toContent(Offset deviceLocal, Size deviceSize);
Offset toDevice(Offset content, Size deviceSize);
void applyContentToCanvas(Canvas c, Size deviceSize);
}
class EditorController extends ChangeNotifier { // ui-facing, Riverpod-provided
void beginStroke(CoordinateSpaceHost h, EditorPoint p);
void extendStroke(EditorPoint p);
void commitStroke(); // getStroke once → append → revision++ → undo push → scheduleSave
void eraseAt(CoordinateSpaceHost h, Offset content, double radius);
void undo(); void redo();
void setTool(EditorTool t); void setColor(Color c); void setLayout(PageLayout l);
}
```
**Acceptance:** draw/erase/undo/redo on a page; close+reopen → strokes persisted (DB); `StaticInkPainter.shouldRepaint==false` while drawing a new stroke on a 2,000-stroke page (revision constant); no per-frame point allocation for committed strokes.
### F2 — Page layout modes (P1)
- `layout/page_layout.dart` **[ADD]** — `enum PageLayout { continuousSingle, continuousDouble, pagedSingle, pagedDouble }`.
- `layout/page_viewport.dart` **[ADD]** — windowed lazy hosting: only pages in `[firstVisible - cacheExtent, lastVisible + cacheExtent]` mount an `AnnotationLayer` + `PageTile`; others are disposed (Picture evicted). Continuous = scrollable column/two-column; paged = `PageView`. Recenter on layout switch.
- `pdf/pdf_document_source.dart` **[ADD]** — `PdfDocument` wrapper exposing `pageSize(i)`, `renderTile(...)`, page count; owns dispose. Replaces ad-hoc `PdfDocument.openFile` in `pen_editor_screen`.
- `pdf/page_tile.dart` **[ADD] — multi-resolution tile (R11 mitigation).** Under one shared `InteractiveViewer`, a `PdfPageView` renders a bitmap sized to its **layout constraints × devicePixelRatio** and the matrix scales that 1× bitmap, so pinch-zoom GPU-**upscales** → blurry text/rules at high zoom (unlike pdfrx's own viewer, which re-renders crisp tiles per zoom level). `page_tile` watches the transform scale and on **zoom-settle** (debounced) re-instantiates its render at a DPI matching the current scale — via either a re-laid-out `PdfPageView` at the new pixel size or `PdfPage.render(width/height at target DPI)` into `page_tile_cache`. **Cap retained DPI** (e.g. ≤ 3× base) to bound memory; downscale path stays matrix-only (sharp enough). This is **new own-canvas work**, not relocation.
- `pdf/page_tile_cache.dart` **[ADD]** — `LRU<TileKey{hostId, dpiBucket}, ui.Image>` of rendered **page bitmaps** (NOT ink). Key includes the **DPI bucket** so a page re-rendered at higher DPI replaces (not duplicates) its lower-DPI tile. **Owns the native-handle dispose lifecycle** of each `PdfPage.render()` result (`PdfImage` → backing `ui.Image`), deferred to post-frame so the raster thread is done with an evicted tile before disposal. **Distinct from `render/ink_picture_cache.dart`** (§2/F1) which holds resolution-independent ink `ui.Picture`s with no DPI bucket.
- `ui/editor_toolbar.dart` **[MODIFY]** — layout switcher control.
**Two separate caches + memory budgets (Critic — do not conflate):**
- **`ink_picture_cache` (vector ink):** `LRU<revision, ui.Picture>`, ~812 mounted-page Pictures, dispose deferred to post-frame. Budget ~ a few MB (vector op-lists are cheap). No DPI bucket.
- **`page_tile_cache` (raster page bitmaps):** `LRU<TileKey, ui.Image>`. **This is the heavy one.** A single A4 page rendered at 3× DPI is ~**1030 MB** (≈ 1785×2526 px × 4 bytes ≈ 18 MB at 3× of a 595×842 pt page @ ~2 dppt). So **812 tiles at 3× would be ~150350 MB — the old "≤64 MB / 812 pages" figure (R10) was unit-confused** (it conflated ink Pictures with page bitmaps). **Resolution:** size the *tile* window to the device memory budget, not a fixed page count — e.g. keep **full-DPI tiles only for the visible + ±1 pages (≈ 4 in double-page), and downgrade off-window pages to a 1× thumbnail tier** (matrix-upscaled, accepted as blurry only while scrolling). The ink cache keeps its wider ~812 window (cheap). R10's ≤64 MB now applies to **ink + downgraded-tier tiles**; the small high-DPI tile set is budgeted separately (~64128 MB depending on device), tuned in P0.5/P1.
**Data structures:** `PageWindow { int first, last }`; `Map<int, HostState> mountedHosts`; `TileKey { hostId, dpiBucket }`. **Acceptance:** all four modes render; switching recenters; double-page shows two pages side-by-side; ink tracks scroll/zoom; **page text/rules stay crisp at 4× zoom (no GPU-upscale blur) — DPI refreshes on zoom-settle** (R11); combined cache memory stays within the device budget under a full scroll; perf gate (§7) holds.
### F3 — Book-like reading (P1)
- `layout/reader_controller.dart` **[ADD]** — `ReaderMode { read, annotate }`; in `read` the arbiter never draws (pen ignored); page-flip animation for `pagedSingle/Double` via `PageView` physics; spread layout from `pagedDouble`.
- `ui/editor_screen.dart` **[MODIFY]** — mode toggle in toolbar; quick-jump via slider/grid (no keyboard).
**Acceptance:** read mode blocks ink; annotate mode draws; page-flip animates; two-page spread aligns facing pages; quick-jump scrolls/animates to target.
### F4 — Thumbnail-grid navigation (P1)
- `ui/thumbnail_grid.dart` **[ADD]** — Drawboard-style grid; tiles render via `pdf/pdf_document_source.renderTile` (replaces `lib/services/thumbnail_service.dart` syncfusion path). Tap → jump; **drag-reorder** for notebook page order (F6). Slider remains the linear scrubber (already in `pen_editor_screen._buildPagePill`).
- `lib/services/thumbnail_service.dart` **[MODIFY/RETIRE]** — migrate to pdfrx render; drop `syncfusion_pdfviewer_platform_interface` dep once ported.
**Acceptance:** grid shows all pages; tap jumps; drag reorders (F6); no text-field page input.
### F5 — Configurable pen (P2)
- `input/pen_config.dart` **[ADD]** — `PenConfig { ButtonAction sideButton; ButtonAction eraserEnd; PressureCurveType curve; double palmSensitivity; bool fingerDrawing; }`; `enum ButtonAction { eraser, undo, toggleTool, pan, lasso, none }`. `InputArbiter` consults it (replaces hardcoded `_isEraserSignal`).
- `lib/providers/settings_provider.dart` **[MODIFY]** — persist `PenConfig` (extend existing `SharedPreferences` notifier; it already stores pressure curve + stabilization).
- `lib/screens/settings_screen.dart` **[MODIFY]** / **[ADD]** `ui/pen_settings_page.dart` — mapping UI (SpeedyNote-style dialog).
- Reuse `lib/models/pressure_curve.dart` (already has linear/soft/hard/custom + `apply`).
**Acceptance:** remapping side-button to undo makes the barrel button undo; pressure curve changes stroke taper; palm sensitivity changes touch-cooldown; finger-drawing toggle works; all persist across restart.
### F6 — One-notebook-per-PDF (P2)
- `pdf/pdf_document_source.dart` **[MODIFY]** — a **page-map**: logical notebook pages → either a source-PDF page index or a synthetic blank page. Insert-blank adds a synthetic page **without rasterizing or editing the source bytes** (keep source vector + searchable). Ink/text bind to the **logical** page id (stable UUID per logical page), not raw PDF index, so inserts don't reshuffle annotations.
- `lib/services/pdf_service.dart` **[MODIFY]** — keep headless syncfusion export/mutate; export walks the page-map. **Fix the hairline bug**: `_renderStrokes` must build a `PdfPath` from `buildStrokeOutline` points and **fill** it (currently strokes line-segments → hairline). Shape/line/arrow keep stroke semantics.
- DB **[MODIFY]** — `notebook_pages(id, document_id, ordinal, source_page_index INTEGER NULL, kind)`; ink keyed by `notebook_page_id` (§4). Portable bundle = zip {source.pdf, badnote.json(strokes/text/links/page-map)} + relink-on-open (match by content hash, fall back to picker).
**Acceptance:** insert blank page between PDF pages → source PDF untouched (still vector/searchable in another viewer); reorder pages keeps ink attached; export `.pdf` shows filled ink matching screen (golden); bundle round-trips on another machine.
### F7 — Infinite board + 双链 (P3)
- `board/board_host_pane.dart` **[ADD]** — `BoardHost` (absolute px) in a constrained-false `InteractiveViewer`; reuses `AnnotationLayer` + engine. Migrates `lib/screens/split_view_screen.dart` + `scratchpads` table.
- `board/link_graph.dart` **[ADD]** — `Link { srcRef, dstRef }` where a ref is `(kind: notebookPage|board|note, id)`; `backlinksOf(ref)`. Wiki-style `[[...]]` parsing in text boxes; sticky-note = a small board region or text box that can link to a page/note. Backlink panel queries `links` table.
- DB **[ADD]** — `links(id, src_kind, src_id, dst_kind, dst_id, created_at)` + indexes both directions; `boards(id, document_id NULL, strokes_json, ...)` (generalize `scratchpads`).
**Acceptance:** create a sticky linking page 3 → note X; open note X shows a backlink to page 3; board draws at perf target; link graph survives restart; deleting a target leaves a dangling-link indicator (no crash).
### F8 — Library-wide full-text search (P3)
- `search/search_indexer.dart` **[ADD]** — index three sources into a unified library index: **(a)** PDF embedded text (pdfrx `PdfPage.loadText`/`charRects` text API — source-pinned in P0.5 per SF4), **(b)** typed text boxes (`content`), **(c)** handwriting OCR (existing `services/ocr` text-line ONNX over rasterized strokes via `StrokeRasterizer`). Reuse existing `document_fts` FTS5 + a new `library_fts` spanning notebooks/boards/notes with `(ref_kind, ref_id, page, snippet)`.
- `search/ocr_ingest.dart` **[ADD]** — batch handwriting OCR per logical page (best-effort; set expectations: text-line only, no math). Background isolate; debounced after ink idle. **The OCR backend itself no-ops when the model is unavailable (onnx_recognition_backend.dart header: "verify on-device") — search MUST never block on, nor be gated by, OCR results.**
- `lib/screens/search_screen.dart` **[MODIFY]** — unified results with snippets + **jump-to-location** (open editor at the page + scroll, or board at the region).
**Acceptance — split into two exit tiers (SF2):**
- **COMMITTED (P3 exit blocker):** PDF embedded text + typed-text-box search returns results with snippets; tapping a result opens and scrolls to the page/region. This tier alone satisfies the P3 search exit (it has no unverified-recall dependency).
- **ADDITIVE (best-effort, NOT a P3 exit blocker):** handwriting OCR contributes hits when the text-line model is available and confident; a word handwritten on page 5 may be returned. **Expectation set:** handwriting recall is limited by the text-line model and may return nothing on cursive/handwriting; math/formula is explicitly P5. P3 ships even if handwriting recall is poor, with UI copy stating the limit.
### F9 — Server sync + AI refinement (P4)
- `sync/sync_client.dart` **[ADD]** — wire the **already-built** FastAPI endpoints (`/api/auth`, `/api/sync/push|pull`, `/api/notes`, `/api/documents`, `/api/ocr`). Last-writer-wins by `updated_at` (server already implements this). Sync notes + (later) notebooks/boards.
- `server/badnote_server/` **[MODIFY]** — extend `sync_router`/`models.py` to cover notebooks/boards/links (today only notes). Add an **AI-refine** endpoint that runs llm_wiki/VLM/LLM over a note/page → returns organized markdown (server-side, heavy deps gated like the OCR worker).
- `lib/providers/` **[ADD]** sync state provider; settings page server URL/token.
**Acceptance:** push from device A, pull on device B reproduces notes; AI-refine returns organized text for a selected note; offline still fully functional (server optional, per README).
### F10 — Ink CAS / formula (P5)
- `search/ocr_ingest.dart` **[MODIFY]** — add a **formula/math recognition** backend (new model; the current PP-OCRv4 is text-line only — this is the known hard sub-problem). Behind a feature flag.
- `engine/` **[ADD]** `cas/` — recognized formula → CAS (compute/solve) behind a toggle; renders result near the ink. Reuses the `CoordinateSpaceHost` seam for overlay placement.
**Acceptance (stretch):** a handwritten `2+3=` toggled → shows `5`; recognized formulas become searchable. Gated, optional, lowest priority.
### F11 — Modern UX polish (woven P0P3)
- Material You already wired (`main.dart` `DynamicColorBuilder` + harmonized schemes + Inter). Continue: subtoolbars in `editor_toolbar`, drag-reorder thumbnails (F4/F6), **hover pre-warm** (warm the next page tile + Picture on stylus hover to cut pen-down latency — reuse the live `_onPointerHover` seam), animated mode/layout transitions.
---
## 3. Data model & persistence (clean schema — data may reset)
Bump DB to a fresh version with `_onCreate` only (keep `_onUpgrade` harmless). Today: notes/strokes, documents/annotations(JSON-per-page), bookmarks, ocr_results, document_fts(FTS5), scratchpads (DB v5, `lib/services/database_service.dart`).
**Target tables (additions/changes in bold):**
- `documents` **[KEEP]** (drop editor reliance on `rotation`; page-map owns rotation).
- **`notebook_pages(id PK, document_id FK, ordinal INTEGER, source_page_index INTEGER NULL, kind TEXT, created_at)`** **[ADD]** — F6 logical pages.
- **`ink(id PK, host_kind TEXT, host_id TEXT, stroke_json TEXT, ordinal INTEGER, updated_at)`** **[ADD]** — strokes addressed by host (`host_kind ∈ {page, board}`, `host_id` = `notebook_page_id` or `board_id`). **Per-stroke rows** (not per-page blob) so a 2,000-stroke page doesn't rewrite on every save — **but ONLY valid under the MF3 diff-write contract (§2/F1 `editor_repository.saveHost`): UPSERT changed rows + DELETE removed rows, never delete-all+re-insert.** Without diffing, per-row is *worse* than a blob; the contract is what makes this schema correct. Index `(host_kind, host_id)`.
- **`text_boxes(id PK, host_kind, host_id, content TEXT, rect_json, font_size, color, updated_at)`** **[ADD]** — F2-area text + F8 indexing.
- **`boards(id PK, document_id FK NULL, title, created_at, updated_at)`** **[ADD]** — generalize `scratchpads`; board strokes live in `ink` with `host_kind='board'`.
- **`links(id PK, src_kind, src_id, dst_kind, dst_id, created_at)`** **[ADD]** — 双链; indexes on `(src_kind,src_id)` and `(dst_kind,dst_id)`.
- `bookmarks` **[KEEP]**.
- `ocr_results` **[KEEP/EXTEND]** — per logical page handwriting OCR text.
- **`library_fts` (FTS5)** **[ADD]** — `(ref_kind, ref_id, page, content)` unified search over PDF text + typed text + OCR. `document_fts` **[KEEP]** for PDF-page text.
- `notes`/`strokes`/`notes_fts` **[KEEP]** (existing ink-note path; eventually folded into boards, but not deleted in P0).
- **`sync_state(entity_kind, entity_id, last_pushed_at, last_pulled_at, dirty)`** **[ADD, P4]**.
**Coordinate semantics on disk:** page ink = normalized `[0,1]` unrotated; board ink = absolute logical px. (Matches live conventions.)
**Stroke-model convergence (important):** there are currently **two** stroke models — `PenStroke/PenPoint` (live canvas, in-memory only) and `InkStroke/InkPoint` (freezed/JSON, DB+OCR+export). P0 introduces **one** canonical `EditorStroke/EditorPoint` (freezed/JSON, normalized) and adapters to/from `InkStroke` for OCR/export reuse during transition; old screens keep `InkStroke` until retired (§6).
---
## 4. Refactor strategy (evolve, don't rewrite)
**Principle: every phase ships; the engine generalizes under load.**
1. **P0 = relocation + persistence.** Move the 4 live canvas files into `engine/` + `render/` + `input/` with minimal logic change; add `StrokeStore.revision` + `ui.Picture` cache + DB persistence via `editor_repository`. The live single-page editor keeps working throughout. **Keep** `PenStroke` as a thin alias of `EditorStroke` until callers migrate.
2. **P0.5 = vertical slice (continuous-single only) — the new perf/crispness gate (see §5/§9).** Stand up `layout/page_viewport` + `pdf/page_tile` rendering continuous-SINGLE only, with the **rewritten** perf bench (targeting `ui/editor_screen`, not the spike) and the zoom-DPI refresh proven crisp at 4× on the Surface. This proves the own-canvas multi-page + multi-resolution model on the real device before any double/paged/spread work.
3. **P1 = full multi-page windowing.** Wrap the (now-relocated) `AnnotationLayer` in `page_viewport`; `_strokesByPage` Map → repository-backed mounted hosts. Single-page path stays as `pagedSingle`. Continuous-double / paged / spread land here, AFTER P0.5 passes.
4. **Old-code retirement (gated on parity, then delete in one step):**
- **[DELETE after P1 parity]** `lib/screens/pdf_annotator_screen.dart`, `lib/widgets/pdf_annotation_layer.dart`, `lib/widgets/ink_canvas.dart` (extract any unique draw/erase logic to `engine/` first), `lib/editor/pdf/spike_*.dart` (throwaway M1 spike — includes the spike-based perf bench, replaced in P0.5), `lib/editor/pdf/pen_capture_region.dart` + `PenCaptureBinding` in `main.dart` (own-canvas `Listener` model won — remove the unused arena-bypass binding once P0 device-confirms).
- **[DELETE in P3, NOT P1 (SF3)]** `lib/screens/split_view_screen.dart` — its replacement is the **P3** infinite board (F7); deleting it in P1 would leave a 2-phase functionality gap (no scratchpad between P1 and P3). It stays live until the board lands.
- **[KEEP]** `thumbnail_service` until F4 ports it; `stroke_rasterizer` (OCR), `ctc_decoder`, `onnx_recognition_backend`, OCR assets; `pdf_service` (export/mutate, with the fill fix); `pptx_service`/`ppt_annotator_screen` (PPT is separate; not in scope but not deleted).
5. **Navigation swap:** `home_screen` `_openDocument`/`_importPdf` currently push `PdfAnnotatorScreen`; `openM1Spike` pushes `PenEditorScreen`. Repoint both to `ui/editor_screen.dart` once P1 parity passes (checklist below).
6. **Parity checklist before any delete:** page rotate/delete/insert-blank/insert-image (`pdf_service`), bookmark add/toggle/jump, undo/redo across pages, save-on-leave, zoom in/out/fit, export-matches-screen (golden). Mirrors phase-1 §10 M3 checklist C1C11.
---
## 5. Phased delivery (each = shippable milestone with exit criteria)
> Sequence de-risks: pen core + persistence first (P0), then the 60fps multi-mode layout (P1), then config/notebook (P2), then board/双链/search (P3), then sync/AI (P4), then CAS (P5).
**P0 — Pen core hardened + persisted (own-canvas).** *Exit:* draw/erase/undo/redo on a single PDF page persist to DB and reload; `EditorStroke` canonical model (superset of `InkPoint`, SF1) + revision-gated `ui.Picture` cache; `editor_repository.saveHost` honors the MF3 diff-write contract (test: erase 1 of 2,000 ⇒ 1 DELETE, 0 re-inserts); pure `InputArbiter` + `stroke_eraser` unit-tested; **on-device Surface Pen confirms** pressure + palm rejection + pinch-zoom in the live shared-transform model (the one device gate). Export hairline bug fixed (fill). No regression to existing screens.
**P0.5 — Vertical slice gate (own-canvas multi-page + multi-resolution), continuous-SINGLE only. [NEW — SYNTHESIS]** Stand up `layout/page_viewport` + `pdf/page_tile` rendering **continuous-single only** on the real document path (`ui/editor_screen`), and:
- (a) **REWRITE the perf bench** (§7/§8): a new `integration_test/editor_scroll_bench.dart` drives `ui/editor_screen` + `layout/page_viewport`; **DELETE the spike-based `integration_test/perf_scroll_bench.dart`** (it imports `spike_editor_pane.dart` / `pageOverlaysBuilder` = the invalidated Option B; it CANNOT validate own-canvas).
- (b) **Prove crisp-on-zoom (R11):** page-tile DPI refresh on zoom-settle renders crisp text/rules at 4× on the Surface (no GPU-upscale blur).
- (c) **Source-pin the pdfrx render/text APIs (SF4):** confirm `PdfPage.loadText`/`charRects`/`PdfPage.render()`/`PdfPageView` signatures present in pdfrx 2.4.4; smoke-test each, so F4/F8 don't hit drift late.
- *Exit (hard gate before any double/paged/spread):* **continuous-single median build+raster ≤ 16.6ms, p95 ≤ 22ms** on the 300-page asset via the REWRITTEN bench, **AND** a manual crisp-on-zoom-at-4× PASS on the Surface, **AND** the four pdfrx APIs source-pinned + smoke-tested. Continuous-double / paged / spread do NOT begin until P0.5 is GREEN.
**P1 — Full layout modes + book-like reading + thumbnails (60fps). Precondition: P0.5 GREEN.** *Exit:* the remaining `PageLayout`s (continuous-double, pagedSingle, pagedDouble); reader vs annotate; page-flip + two-page spread; thumbnail-grid jump + drag-reorder; windowed lazy hosting; **perf gates met** (§7) incl. continuous-double on a 300-page PDF via the rewritten bench; old PDF screens deleted after parity checklist (split_view retirement deferred to P3 per SF3); nav repointed.
**P2 — Configurable pen + one-notebook-per-PDF.** *Exit:* Pen settings page (button/eraser mapping, curve, palm sensitivity, finger toggle) persists + drives arbiter; insert-blank/reorder pages keep source vector + ink attached to logical pages; portable bundle round-trips; export walks page-map.
**P3 — Infinite board + 双链 + library search.** *Exit:* board reuses engine at perf; sticky-notes + `[[links]]` produce backlinks; **library search COMMITTED tier** (PDF embedded text + typed-text-box) returns snippets + jump-to-location (this tier is the exit blocker, SF2); **handwriting-OCR tier is ADDITIVE/best-effort and does NOT block P3 exit**; `split_view_screen` retired into board (the SF3 deletion point).
**P4 — Server sync + AI refinement.** *Exit:* push/pull notes+notebooks across two devices (LWW); AI-refine endpoint returns organized markdown; app fully functional offline.
**P5 — Formula OCR + ink CAS.** *Exit (stretch, gated):* formula recognition backend; searchable formulas; CAS solve toggle for simple expressions.
Every milestone ends with verifier/critic pass + perf-results doc updated with commit hash.
---
## 6. Refactor of input transport (record the pivot)
The live `PenCanvas` proves the **own-canvas** model: a single `Listener` over an `InteractiveViewer` whose child is `Stack(PdfPageView bitmap, StaticInk, LiveInk)`. Because the pen, page bitmap, and ink share **one** transform and the `Listener` arbitrates by `_activePointers.length` + `kind`, there is **no gesture-arena fight** — pdfrx never sees gestures (it only renders). This **removes** the phase-1 need for `PenCaptureRegion`/`PenCaptureBinding` (RenderProxyBox arena bypass), which exist for the abandoned `pageOverlaysBuilder` approach. **Retirement gate:** delete them once P0 confirms on the physical Surface Pen that the `Listener` model handles stylus draw + single-finger pan + pinch-zoom + palm rejection (the live code is built for exactly this; confirm, then remove the dead binding).
---
## 7. Risks & mitigations
| # | Risk | L | I | Mitigation / trigger |
| --- | --- | --- | --- | --- |
| R1 | **60fps continuous + double-page on a 300-page PDF.** Two columns × windowed tiles × ink Pictures may blow frame budget. | Med | High | Windowed lazy hosting (only visible+cacheExtent mounted), bounded LRU + post-frame dispose for BOTH the `ink_picture_cache` and the `page_tile_cache` (R10), per-page `RepaintBoundary`, revision-gated static Picture. **Gate continuous-SINGLE in P0.5, continuous-double in P1** (§5/§9). **MF1 — the existing `integration_test/perf_scroll_bench.dart` is UNUSABLE here: it imports `spike_editor_pane.dart` and benchmarks `pageOverlaysBuilder` (Option B, the INVALIDATED architecture) — it cannot validate own-canvas. P0.5 REWRITES the bench against `ui/editor_screen` + `layout/page_viewport` and DELETES the spike-based one.** Uses `large_300p.pdf`. |
| R2 | **Pen feel only verifiable on the user's Surface** (CI has no pen). | High | Med | P0 device gate (pressure/palm/pinch) + manual checklist in perf-results doc; synthesized-stylus widget tests as interim signal only. |
| R3 | **Handwriting/formula OCR feasibility.** Current model is text-line PP-OCRv4; math is unsolved. | High | Med | F8 ships text-line best-effort with **explicit expectation-setting**; formula isolated to P5 behind a flag; never block search on OCR quality. |
| R4 | **双链 graph scale** (thousands of links/sticky-notes). | Low | Med | Indexed `links` table (both directions), lazy backlink queries, no in-memory full graph. |
| R5 | **Server/AI scope creep.** | Med | Med | Server stays optional (README); P4 wires existing endpoints + one AI-refine route; AI heavy deps gated like OCR worker. |
| R6 | **Windows pen edge cases** (no advertised pressure range; barrel-button eraser; inverted stylus). | Med | Med | Live `_normalizedPressure` already handles degenerate ranges; F5 makes button/eraser mappable; test matrix in arbiter unit tests. |
| R7 | **Export fidelity** (hairline bug today). | High (today) | Med | `stroke_geometry.buildStrokeOutline` shared screen+export; `pdf_service._renderStrokes` **fills** a `PdfPath`; golden test (P0). |
| R8 | **Stroke-model convergence churn** (two models today). | Med | Low | One canonical `EditorStroke` + adapters; old `InkStroke` retained only where old screens/OCR/export still use it, deleted with them. |
| R9 | **pdfrx page-render / text API drift** (thumbnails, search text, double-page sizing). | Med | Low | **Source-pin `PdfDocument`/`PdfPageView`/`PdfPage.loadText`/`charRects`/`render()` signatures + smoke-test in P0.5 (pulled forward, SF4)** so F4/F8 don't hit drift late; keep syncfusion export until verified. |
| R10 | **Cache memory budget vs many mounted pages (TWO caches — Critic).** | Med | Med | **`ink_picture_cache`** (vector `ui.Picture`, no DPI bucket): ~812 mounted pages, a few MB. **`page_tile_cache`** (raster `ui.Image`, DPI-bucketed): the heavy store — a 3×-DPI A4 tile is ~1030 MB, so 812 high-DPI tiles would be ~150350 MB. **The old "≤64MB / 812 pages" figure was unit-confused (conflated ink with page bitmaps).** Fix (§2/F2): keep full-DPI tiles only for visible ±1 pages, downgrade off-window pages to a 1× thumbnail tier; ≤64MB applies to ink + downgraded tiles, the small high-DPI tile set budgeted separately (~64128MB, device-tuned). Sample BOTH caches during the scroll bench. |
| R11 | **Blurry page at high zoom under the shared transform (MF2).** One `InteractiveViewer` matrix-scales a `PdfPageView` bitmap that was rendered at **layout-constraint × devicePixelRatio** (1×); pinch-zoom GPU-**upscales** it → blurry text/rules at 4×, whereas pdfrx's own viewer re-renders crisp tiles per zoom level. We **own multi-resolution tiling**, not just layout/windowing. | Med | High | `pdf/page_tile.dart` drives render DPI from the transform scale: on zoom-settle re-instantiate the tile (re-laid-out `PdfPageView` at the new pixel size, or `PdfPage.render()` at target DPI) into the **`pdf/page_tile_cache.dart`** store (`LRU<TileKey{hostId, dpiBucket}, ui.Image>` — NOT the ink cache); cache key includes a DPI bucket; **cap retained DPI** (~3× base) to bound memory; downscale stays matrix-only. **`PdfPage.render()` returns an async `PdfImage` owning a native handle → `page_tile_cache` owns its post-frame dispose** (see Open Questions; confirm in the SF4 P0.5 source-pin). **Proven crisp at 4× on the Surface in the P0.5 gate** (§5). Budgeted P0.5/P1, not relocation. |
### Pre-mortem (DELIBERATE — 3 scenarios)
1. **"Continuous double-page janks / pages are blurry at 4× on the user's big scanned PDF."** Cause: built layout modes before profiling two-column windowing, and matrix-scaled a 1× bitmap (R11). *Prevention:* the **P0.5 vertical-slice gate** profiles continuous-SINGLE first with a **rewritten** bench against `ui/editor_screen` (the spike-based `perf_scroll_bench.dart` is deleted — it benchmarks the invalidated Option B) AND proves crisp-on-zoom DPI refresh at 4× on the Surface; continuous-double/paged/spread merge only after P0.5 is GREEN, and the P1 double-page gate reuses the rewritten bench.
2. **"Search returns nothing for handwriting."** Cause: over-promised OCR. *Prevention:* F8 ships PDF-text + typed-text search first (reliable), handwriting OCR as additive best-effort with UI copy stating limits; formula explicitly P5.
3. **"Insert-blank-page silently rasterized / detached annotations."** Cause: editing source bytes or keying ink to raw PDF index. *Prevention:* logical page-map + ink keyed to `notebook_page_id`; golden test that the source PDF bytes are unchanged after insert and remains selectable-text in an external viewer.
---
## 8. Testing strategy
> **sqlite workaround:** DB-touching tests run via `tool/test.sh` (system sqlite + `LD_LIBRARY_PATH`); pure-logic tests avoid the DB. Reusable assets: `tool/gen_bench_pdf.dart`, `tool/gen_dense_strokes.dart`, `test/assets/large_300p.pdf`, `test/assets/dense_strokes.json`, `integration_test/coordinate_assertion_test.dart`. **NOT reusable: `integration_test/perf_scroll_bench.dart` — it imports `spike_editor_pane.dart` (`pageOverlaysBuilder`, invalidated Option B) and is DELETED + replaced by `integration_test/editor_scroll_bench.dart` (targets `ui/editor_screen`) in P0.5 (MF1).**
- **Unit (no DB/widgets):**
- Coordinate transforms: `NormalizedPageHost`/`BoardHost` round-trip `toContent(toDevice(x))≈x`.
- Stroke geometry: `buildStrokeOutline` non-empty for ≥1 point; live ⊆ committed bounds (no "pop").
- Eraser: `eraseHits` whole-stroke removal (extraction of live `pen_canvas._eraseAt` — regression port). **`splitStroke` segment-erase is NEW behavior, not a port** (the live canvas only does whole-stroke erase; `undo_manager` records but never computes replacements): mid-erase ⇒ 2 segments, endpoint ⇒ 1 segment, full-erase ⇒ empty, <2-pt result dropped — these exercise greenfield code. (`undo_manager_test.dart` informs the replacement-bookkeeping discipline only.)
- Revision gating: `StrokeStore.add` bumps revision; `StaticInkPainter.shouldRepaint` iff revision changed.
- `InputArbiter` SM: table-driven over the device×mode matrix incl. palm rejection (touch dropped while stylus active) and `pointerCount>=2`→cancel.
- Undo: global commit-time order, host-tagged reversal.
- `SaveScheduler`: mutating "current host" after schedule but before write completes does not change persisted snapshot.
- **`editor_repository.saveHost` diff-write (MF3, fake DB counting statements):** erase 1 of 2,000 strokes ⇒ exactly **1 DELETE, 0 INSERT**; add 1 stroke ⇒ **1 INSERT, 0 DELETE**; no-op save ⇒ 0 statements. (Guards against the live notes-path delete-all+re-insert anti-pattern at database_service.dart:281306.)
- `EditorStroke``InkStroke` adapter round-trip (SF1): `tilt`/`timestamp`/`pointerDeviceKind` survive both directions (no lossy OCR/export conversion).
- `link_graph.backlinksOf` (P3); `search_indexer` snippet/jump-ref mapping for the COMMITTED tier — PDF-text + typed-text (P3).
- **Widget:** stylus→stroke committed, touch→not consumed (synthesized pointers); layout-mode switch recenters; reader-mode blocks ink; thumbnail tap jumps; text-box place/move/edit persists (fake DB).
- **Perf:** the **rewritten** `integration_test/editor_scroll_bench.dart` (targets `ui/editor_screen` + `layout/page_viewport`; the spike-based `perf_scroll_bench.dart` is deleted, MF1) on `large_300p.pdf`**continuous-single in P0.5 (gate), continuous-double in P1**, profile mode, N≥120 frames warm, median build+raster ≤16.6ms / p95 ≤22ms; `StaticInkPainter` no-rebuild assertion; Picture-memory ≤64MB sample. Archived in `docs/plans/full-refactor-perf-results.md` with commit hash. Not a hard CI gate (no GPU) but required for milestone sign-off.
- **Manual on-device pen checklist (Surface Pen, P0 + each milestone):** pressure varies width; barrel/inverted = erase; palm rest doesn't mark; single-finger scroll; two-finger pinch; **page text/rules stay crisp at 4× zoom — no GPU-upscale blur, DPI refreshes on zoom-settle (R11, P0.5 gate)**; hover pre-warm reduces first-stroke latency; page-flip feels book-like. Recorded with device + commit.
- **Regression:** keep `ctc_decoder_test.dart`, `undo_manager_test.dart` green; update `widget_test.dart` to boot `editor_screen`.
- **Export golden (P0):** annotate a known page → export → image-compare filled ink matches screen (R7).
- **Server (P4):** existing `server/tests/` (`test_sync.py` etc.) green; add notebook/board sync tests.
---
## 9. Milestones / sequencing — immediate next chunk (concrete)
**Next chunk = P0 (engine relocation + persistence), executable now; followed by the P0.5 vertical-slice gate (steps 1013) before any double/paged/spread layout work:**
1. **[ADD]** `lib/editor/engine/stroke_model.dart``EditorStroke`/`EditorPoint` (freezed + JSON), normalized; `fromPenStroke`/`toInkStroke` adapters. Run `build_runner`.
2. **[ADD]** `lib/editor/engine/stroke_geometry.dart` — lift `buildStrokePath``buildStrokeOutline` (verbatim recipe from `ink_painters.dart`).
3. **[ADD]** `lib/editor/engine/stroke_store.dart` + `lib/editor/render/{static_ink_painter,live_ink_painter,ink_picture_cache,annotation_layer}.dart` — relocate live painters; add the resolution-independent ink `ui.Picture` cache keyed by `revision` (NO DPI bucket; the DPI-bucketed `page_tile_cache` is a separate P0.5 file, step 10).
4. **[ADD]** `lib/editor/input/input_arbiter.dart` + `lib/editor/engine/stroke_eraser.dart` — extract from `pen_canvas.dart` (pure, unit-tested).
5. **[ADD]** `lib/editor/persistence/{editor_repository,save_scheduler}.dart` + DB additions (`ink`, `notebook_pages` minimal) in `database_service.dart` (fresh version). `saveHost` implements the **MF3 diff-write contract** (UPSERT changed + DELETE removed, by stroke id; NO delete-all+re-insert) with a per-host last-persisted id-set.
6. **[MODIFY]** `lib/editor/canvas/pen_editor_screen.dart` (or new `ui/editor_screen.dart`) to load/commit/save through the repository instead of the in-memory `_strokesByPage` Map.
7. **[MODIFY]** `lib/services/pdf_service.dart` `_renderStrokes` → fill `buildStrokeOutline` path (R7) + export golden test.
8. **Tests:** arbiter SM, eraser, geometry, revision-gating, save-scheduler snapshot, **diff-write statement-count (MF3: erase 1/2000 ⇒ 1 DELETE 0 INSERT)**, `EditorStroke↔InkStroke` lossless round-trip (SF1), export golden. Run via `tool/test.sh`.
9. **Device gate:** build Windows package (CI), confirm pen/palm/pinch on Surface Pen; record in perf-results doc. → unblocks P0.5.
**Then P0.5 (vertical-slice gate — must pass before P1's double/paged/spread):**
10. **[ADD]** `lib/editor/layout/page_viewport.dart` (continuous-single only) + `lib/editor/pdf/{pdf_document_source,page_tile,page_tile_cache}.dart` with zoom-settle DPI refresh (R11) + the **DPI-bucketed `page_tile_cache`** (`LRU<TileKey, ui.Image>`, page bitmaps; owns native-handle dispose). (The resolution-independent `render/ink_picture_cache.dart` for vector ink lands in P0, step 3 — it is a separate cache, no DPI bucket.)
11. **[ADD]** `integration_test/editor_scroll_bench.dart` targeting `ui/editor_screen`; **[DELETE]** `integration_test/perf_scroll_bench.dart` + `lib/editor/pdf/spike_*.dart` (the spike pane the old bench imports). Run the rewritten bench on a **scanned-image** 300-page asset (see Open Questions — `large_300p.pdf` is synthetic/vector and may not honestly stress raster re-render at 3× DPI) → continuous-single median ≤16.6ms / p95 ≤22ms; sample both caches' memory.
12. **[VERIFY]** source-pin pdfrx `PdfPage.loadText`/`charRects`/`render()`/`PdfPageView` in 2.4.4 (SF4) + smoke test; **confirm `PdfPage.render()`'s `PdfImage`/native-handle ownership + dispose semantics** so `page_tile_cache` can manage post-frame disposal; record signatures in perf-results doc.
13. **Device gate:** crisp-on-zoom at 4× PASS on the Surface (R11). → unblocks P1.
Each subsequent milestone (P1…P5) follows §5 exit criteria; verifier/critic + perf-results update per milestone.
---
## 10. RALPLAN-DR
### Principles (35)
1. **Single source of truth = host content coordinates.** Screen mapping is a paint-time `canvas` transform; never store/duplicate transformed geometry. (Already the live convention — generalize it.)
2. **One host-agnostic ink engine.** PDF page, infinite board, and (P5) CAS overlay are `CoordinateSpaceHost`s behind one renderer — never fork the stroke pipeline.
3. **Own the gesture pipeline; pdfrx only renders.** A single `Listener` over a shared `InteractiveViewer` arbitrates draw/pan/zoom/palm by pointer kind + count — no gesture-arena fights (proven live).
4. **De-risk performance AND crispness before features.** A **P0.5 vertical-slice gate** (continuous-single, rewritten bench, crisp-at-4× on the Surface) precedes all double/paged/spread work; continuous-double is gated again in P1.
5. **Ship every phase; generalize under load.** Relocation-first refactor keeps the editor working at all times; old screens deleted only after parity.
### Decision Drivers (top 3)
1. **D1 — Pen feel + palm rejection + pinch on Windows Surface Pen** is the make-or-break primary-device requirement (own-canvas model already targets it).
2. **D2 — 60fps across all layout modes** on big PDFs with thousands of strokes.
3. **D3 — One engine reused across PDF page, infinite board, and CAS**, forward-compatible with search/双链/sync.
### Viable options (≥2) with bounded pros/cons
**Option A — Own-canvas engine (single `Listener` + `InteractiveViewer`, pdfrx as renderer). CHOSEN (already live).**
- Pros: D1 solved structurally (one transform, no arena fight — already working in `pen_canvas.dart`); D3 trivial (hosts share the transform); minimal new deps; matches the proven Saber model the user cited.
- Cons: we own page layout/windowing/tiling **AND multi-resolution re-rasterization** (more code than a stock viewer); double-page perf is on us (R1) and crisp-on-zoom is on us (R11 — pdfrx's own viewer re-renders crisp tiles per zoom for free; we must re-derive tile DPI from the transform); thumbnails/text-extraction still need pdfrx page APIs (R9).
**Option B — pdfrx `pageOverlaysBuilder`-hosted ink + RenderProxyBox arena-bypass (the phase-1 plan).**
- Pros: pdfrx gives continuous scroll/tiling/text-extraction for free; ink-follows-page is structural via page overlays.
- Cons: requires a custom `PenCaptureBinding` arena bypass that fights pdfrx's greedy scale recognizer (the live code already **abandoned** this — `PenCaptureRegion` is unused); pdfrx owns the transform so double-page/board reuse is awkward; D1 proven harder than Option A in practice. **Invalidated** — see below.
**Option C — Flutter shell + Rust hot-path (rnote-style) for ink/render.**
- Pros: maximal ink perf headroom.
- Cons: rejected in memory (`badnote-flutter-344-windows-pen`) — Windows pen is weaker in the Rust/GTK stack; huge FFI surface; contradicts "stay Flutter." **Invalidated.**
### Invalidation rationale
- **B invalidated (as the INPUT model):** the live codebase already moved off it; `pageOverlaysBuilder`+arena-bypass made stylus/touch arbitration fight pdfrx's recognizer, whereas Option A's single-`Listener`-over-shared-transform sidesteps the arena entirely and is already drawing with pressure/pinch/palm. **Correction (MF2): B had TWO edges, not one** — (1) free continuous scroll/windowing, AND (2) free **per-zoom crisp re-rasterization** (pdfrx re-renders tiles at each zoom level). Edge (1) is recoverable in A via windowed hosting (needed for double-page anyway); edge (2) is **NOT free in A** — Option A must own multi-resolution tiling (R11), which is the genuinely new cost of this choice. We accept that cost (gated in P0.5) because A's structural D1/D3 wins outweigh it. B retained only as a fallback page-**render** strategy, not the input model.
- **C invalidated:** documented Windows-pen regression in the Rust/GTK path + "stay Flutter" hard constraint; the 3.44 WM_POINTER fix already unblocked Flutter pen, removing C's motivation.
- Net: **A chosen; B retained as a partial fallback (page rendering only); C rejected.**
### ADR
- **Status:** Architect APPROVE-WITH-MUST-FIX applied (2026-06-21): MF1 (rewrite perf bench off the invalidated spike), MF2 (R11 zoom re-rasterization + multi-resolution tiling), MF3 (diff-write contract); P0.5 vertical-slice synthesis gate; SF1SF5. Pending Critic. Records the live pivot from the superseded phase-1 input architecture.
- **Decision:** Build the full BadNote vision on an **own-canvas, host-agnostic ink engine** (single `Listener` + shared `InteractiveViewer`; pdfrx as page renderer/text source). Generalize the live `lib/editor/canvas/` into `engine/render/input/layout/...`; persist a single canonical `EditorStroke`; phase features P0→P5 with a **P0.5 vertical-slice gate**.
- **Drivers:** D1 Surface-Pen feel, D2 60fps multi-mode, D3 one reusable engine.
- **Alternatives considered:** B (pdfrx-overlay + arena bypass — invalidated as input model, kept as render fallback; had two free edges — scroll AND per-zoom crispness — the latter is the new cost we take on), C (Flutter+Rust — rejected).
- **Why chosen:** A is already proven live for pen/palm/pinch and gives D1+D3 structurally; the remaining risks (D2 multi-mode perf AND R11 crisp-on-zoom) are gated up front in **P0.5** then re-gated for double-page in P1.
- **Consequences:** we own layout/windowing/tiling **AND multi-resolution page-tile re-rasterization** (R11 — not just layout, MF2); `PenCaptureRegion`/`PenCaptureBinding` retired (dead under own-canvas); the spike-based perf bench is deleted and rewritten against `ui/editor_screen` (MF1); per-stroke-row persistence is valid ONLY under the diff-write contract (MF3); `EditorPoint` is a non-lossy superset of `InkPoint` (SF1); export must fill (not stroke) ink; handwriting OCR is additive/non-blocking and formula is P5 (SF2); `split_view_screen` deletion deferred to P3 (SF3); server stays optional.
- **Follow-ups:** P0 device gate confirms pen model; **P0.5 gate** proves continuous-single perf + crisp-at-4× + source-pinned pdfrx APIs (SF4) before any double/paged/spread; resolve open questions below.
---
## Open Questions (persist to `.omc/plans/open-questions.md`)
- [ ] Continuous-double-page on a real 300-page scanned PDF — does windowed two-column hosting hold 60fps, or do we need tile pre-rasterization? — *P0.5 gates continuous-single first; P1 perf gate decides double; affects R1/R10.*
- [ ] **How is page-tile DPI refreshed on zoom under the shared transform (SF5)?** — re-laid-out `PdfPageView` at the new pixel size, or `PdfPage.render()` at target DPI into `page_tile_cache`? What scale-change threshold + debounce triggers a refresh, and what is the retained-DPI cap? — *Load-bearing for the 60fps + crisp-at-4× goal; resolved in P0.5 (R11).*
- [ ] **`PdfPage.render()` native-handle lifecycle (Critic 4a):** `render()` returns an async `PdfImage` owning a native handle backing a `ui.Image``page_tile_cache` must own its dispose (post-frame, after the raster thread is done) on eviction. *Confirm exact ownership + dispose API in the SF4 P0.5 source-pin; affects page_tile_cache + R10/R11.*
- [ ] **Honest R11 raster stress asset (Critic 4b):** does the synthetic/vector `large_300p.pdf` actually force RASTER re-render cost at 3× DPI, or does a **scanned-image** asset better exercise the crispness + tile-memory gate? — *Add a scanned 300-page asset (`tool/gen_bench_pdf.dart` image mode or a real scan) for the P0.5/P1 perf+crispness gates; affects §8 / R11 honesty.*
- [ ] Stroke-model convergence: fold `notes`/`InkStroke` ink-note path into `boards`/`EditorStroke`, or keep notes separate long-term? — *Affects §3/§4 churn.*
- [ ] Handwriting OCR: is the existing PP-OCRv4 text-line model usable on cursive/handwriting at all, or do we need a handwriting-specific model even for non-formula text? — *Affects F8 expectation-setting / R3.*
- [ ] Portable bundle relink: match source PDF by content-hash only, or also store original path + size? — *Affects F6.*
- [ ] Server AI-refine: run llm_wiki/VLM server-side only, or allow a local LLM path for offline users? — *Affects F9 scope / R5.*
- [ ] Keep `pptx_service`/`ppt_annotator_screen` in the new engine, or freeze PPT support? — *Out of the 11 features; decide before P1 nav swap.*

View File

@@ -0,0 +1,134 @@
# BadNote — Pen-Polish + Native-Pen Addendum (ralplan consensus)
**Status:** APPROVED (ralplan consensus 2026-06-22 — Architect APPROVE-WITH-MUST-FIX M1M4 applied; Critic ITERATE→APPROVE after C1 PenPoint.tilt wrong-model fix + C2 eraser-race re-grounded on M1 native ordering + no-hover-down test)
**Date:** 2026-06-22
**Mode:** DELIBERATE (native Windows plugin = new platform code; can only be device-verified)
**Owner plan file:** `docs/plans/2026-06-22-badnote-pen-polish.md`
**Extends (does NOT supersede):** `docs/plans/2026-06-21-badnote-full-refactor.md`
> **Grounding (read live code 2026-06-22):** `lib/editor/canvas/{pen_canvas,pen_editor_screen,ink_painters,pen_stroke}.dart`,
> `lib/editor/engine/stroke_geometry.dart`, `lib/editor/input/pen_config.dart`,
> `windows/runner/{ocr_channel.cpp,flutter_window.cpp,win32_window.cpp,main.cpp}`,
> `windows/flutter/generated_plugin_registrant.cc`.
---
## 0. Scope (4 user asks, mapped to the roadmap)
| # | User ask (verbatim intent) | Root cause (verified) | Roadmap fit |
| --- | --- | --- | --- |
| **W1** | 添加自定义笔粗; `thinning` 肯定不能写死, 学习 Saber | `penWidth`/`highlighterWidth` already read from `PenConfig` (pen_editor_screen.dart:456459); **but `thinning` is a hardcoded literal `0.85`** in `ink_painters.buildStrokePath` (line 38) AND `stroke_geometry.buildStrokeOutline` (line 52). | Pull **F5** (configurable pen) width+pressure slice forward to **now** |
| **W2** | 缩放的时候会闪一下 (zoom flickers once) | `PdfPageView` lives inside the `InteractiveViewer` child subtree (pen_canvas.dart:354); pdfrx re-rasterizes its page bitmap when the effective scale changes, showing a **one-frame white gap** during the async re-render = the flicker. | This IS **R11 / `pdf/page_tile`** (P0.5); add a **minimal double-buffer fix now**, full fix in P0.5 |
| **W3** | 修好 tilt 和笔按键映射 (fix tilt + pen button mapping) | **No native pen plugin exists** (only `badnote/ocr` MethodChannel). Flutter 3.44 Windows delivers `pressure` but NOT barrel→`buttons`, NOT eraser/inverted→`invertedStylus`, NOT `tilt`. So `_isEraserSignal` (pen_canvas.dart:134) never fires; `event.tilt` is always `0.0`. | **NEW work** the full-refactor plan did not budget: a native Windows pen plugin. Gates F5 button-mapping. |
| **W4** | 继续推进整体重构, 完成我所有的需求 | Roadmap exists & is approved; task #7 (P0 engine+persistence) `in_progress`. | Resume `2026-06-21-badnote-full-refactor.md` P0 → P0.5 → … after W1W3. |
**Non-goals here:** no new layout modes, board, search, server, or CAS (those stay in the parent roadmap's P1P5). This addendum is only the pen-feel polish + the native-pen unblock that the user is blocked on *today*, sequenced so it feeds the parent plan's engine (`stroke_geometry`, `input_arbiter`, `pen_config`) rather than the throwaway live widgets.
---
## 1. RALPLAN-DR
### Principles
1. **Touch the canonical engine, not the live widgets.** Width/thinning changes land in `engine/stroke_geometry.dart` (the single source for screen+export, §2/F1 of the parent plan), so the fix survives the P0 relocation and exports match the screen. Do not fork logic into `ink_painters.dart` only.
2. **No hardcoded feel constants.** `thinning`, `size`, taper, and pressure-sensitivity are `PenConfig` fields with sane defaults — mirroring Saber's `StrokeOptions`-per-pen model.
3. **Native pen is additive + degrades gracefully.** The plugin enriches pointer events with barrel/inverted/tilt; if it is absent or returns nothing, the canvas behaves exactly as today (Flutter pressure still works). Never make drawing depend on the plugin.
4. **Device-gate the un-CI-testable.** Tilt/buttons/flicker can only be confirmed on the Surface; each ships behind a CI package + a manual checklist, never claimed "done" from a green analyze.
### Decision Drivers
1. **D1 — Unblock the user's primary device today** (eraser-end + side-button + tilt are dead; pen feel needs a real size/pressure control).
2. **D2 — Don't derail the approved refactor** — every change feeds `engine/`+`input/`+`pen_config`, not the soon-retired widgets.
3. **D3 — Crisp, flicker-free zoom** without prematurely building the whole multi-resolution tiler (that's P0.5).
### Viable options
**W1 — configurable thinning/size**
- **Option A (CHOSEN): thread `thinning`/`size` as parameters from `PenConfig` through `buildStrokeOutline`/`buildStrokePath`; add `pressureSensitivity` (→thinning) + reuse existing `penWidth`/`highlighterWidth` (→size); Pen-settings sliders.** Pros: matches Saber (`StrokeOptions(size, thinning, …)` per pen); one source of truth; tiny diff. Cons: 2 signatures change + every caller.
- Option B: keep literals, expose only `penWidth`. Rejected — user explicitly says thinning 不能写死.
**W2 — zoom flicker**
- **Option A (CHOSEN, now): double-buffer the page bitmap** — keep the last successfully-rendered page image painted underneath `PdfPageView` (or wrap in a tiny `RawImage` cache) so the async re-render never exposes a white frame; **then** the full R11 `page_tile` in P0.5 replaces it. Pros: kills the visible flicker immediately with a small, localized change; forward-compatible (becomes `page_tile`'s double-buffer). Cons: a stopgop that P0.5 supersedes.
- Option B: jump straight to the full `pdf/page_tile` + `page_tile_cache` now. Rejected for *now* — it's the P0.5 gate; pulling all of it forward derails P0. (We DO confirm the flicker root cause via systematic-debugging before coding either.)
**W3 — native pen (tilt + buttons)**
- **Option A (CHOSEN): in-app Windows plugin in `windows/runner/` (pen_channel.cpp) subclassing the window proc to handle `WM_POINTER*`, call `GetPointerPenInfo`/`GetPointerPenInfoHistory` for `penFlags` (BARREL/INVERTED/ERASER) + `tiltX/tiltY`, key state by `pointerId`, forward to Dart via an `EventChannel('badnote/pen')`.** Dart `PenInputService` exposes the latest per-pointer pen state; `InputArbiter`/canvas reads it to set eraser + tilt. Pros: smallest footprint (mirrors existing `ocr_channel.cpp` pattern); no new pub package; full control of WM_POINTER. Cons: native C++ to maintain; CI-build-only, device-verify-only.
- Option B: standalone federated Flutter plugin package. Rejected — heavier scaffolding for a single-platform need; `ocr_channel.cpp` proves the in-runner pattern works here.
- Option C: wait for Flutter engine to deliver penFlags/tilt upstream. Rejected — unbounded; user blocked now.
### Pre-mortem (3 scenarios)
1. **"Tilt/buttons still dead after the plugin ships."** Cause: WM_POINTER not reaching our handler, or Flutter's own `FlutterWindow` consumes the message first. *Prevention:* before writing the EventChannel, add a WM_POINTER **logging probe** in the window proc and confirm on-device that `GetPointerPenInfo` returns non-zero `penFlags`/tilt (systematic-debugging Phase-1 evidence at the component boundary). Only then wire the channel.
2. **"Zoom flicker fix made scrolling janky / doubled memory."** Cause: keeping a full-res second bitmap per page. *Prevention:* hold exactly ONE last-good image for the *current* page only; drop it on page change; measure frame cost on the Surface before/after.
3. **"Width/thinning change broke export goldens."** Cause: only `ink_painters.dart` was updated, `stroke_geometry.dart` (export path) drifted. *Prevention:* change BOTH via the shared `kDefaultPenThinning`; default stays `0.85` (M4) so the existing golden is unchanged; add a unit test that both builders read identical thinning for the same `PenConfig`. If a golden must be regenerated, name + commit the new baseline explicitly.
4. **"Eraser end doesn't erase on a direct pen-down (no hover)."** Cause: correctness was hung on hover-precedence instead of the M1 native ordering. *Prevention:* re-grounded above on observer-before-`HandleTopLevelWindowProc`; **on-device test: tap the eraser end straight onto the page with no prior hover — first contact must erase, not draw.**
---
## 2. Work items (file-level)
### W1 — Configurable pen width + pressure sensitivity (Saber-style)
**Modify:**
- `lib/editor/input/pen_config.dart` **[MODIFY]** — add `double pressureSensitivity` (maps to perfect_freehand `thinning`; range `[0,1]`, **default `0.85` = the current live value, M4**, so existing stroke feel and the export golden are preserved; `0` = constant width). Keep `penWidth`/`highlighterWidth` as size. Add to `copyWith`/`toJson`/`fromJson`/`==`/`hashCode` + `PenConfigController.setPressureSensitivity` (clamped). Additive persisted field (default-filled on missing key — no migration).
- `lib/editor/engine/stroke_geometry.dart` **[MODIFY]** — define `const double kDefaultPenThinning = 0.85;` (M4 — NOT 0.6; preserves goldens). `buildStrokeOutline(..., {required bool isComplete, double thinning = kDefaultPenThinning})`; remove the `0.85` literal in favour of the named const. Highlighter still forces `0.0`.
- `lib/editor/canvas/ink_painters.dart` **[MODIFY]** — `buildStrokePath(..., {required bool isComplete, double thinning = kDefaultPenThinning})`; import `kDefaultPenThinning` from `stroke_geometry.dart` so screen+export share ONE default and can never diverge.
- `lib/editor/canvas/pen_canvas.dart` **[MODIFY]** — accept a `thinning` field on `PenCanvas`; pass it to the painters (the painters need the value at paint time → pass via the painter constructors `StaticInkPainter`/`LiveInkPainter`, add `shouldRepaint` check on `thinning`).
- `lib/editor/canvas/pen_editor_screen.dart` **[MODIFY]** — feed `_penConfig?.value.pressureSensitivity ?? kDefaultPenThinning` into `PenCanvas.thinning`.
- `lib/editor/ui/pen_settings_page.dart` **[MODIFY]** — add a **pen size** slider (drives `penWidth`, e.g. `0.0020.02`), a **highlighter size** slider, and a **pressure sensitivity** slider (drives `pressureSensitivity` `01`). Live-preview stroke swatch optional.
**Acceptance:** changing pressure-sensitivity to 0 yields constant-width strokes; to ~0.8 makes light/hard press sweep width visibly; pen-size slider changes base width; values persist across restart; a unit test asserts `buildStrokeOutline` and `buildStrokePath` use the same thinning for a given config; export golden still matches screen (R7).
### W2 — Zoom flicker — **ROOT-CAUSE FIRST, fix is probe-gated (M3)**
The flicker fix is NOT pre-committed to a double-buffer. The Architect notes the page is rendered **once** and matrix-scaled (`pen_editor_screen.dart:466474`), so the cause may be **R11-class** (a re-raster swap / matrix blur on zoom-settle) rather than an async white-gap — and the right fix differs per cause.
**Step 1 — Phase-1 probe (systematic-debugging, MANDATORY before any fix):** instrument one zoom on the Surface and determine which of these the "闪一下" is:
- (a) **async white-gap**`PdfPageView` blanks for a frame while it re-rasterizes at the new scale; OR
- (b) **re-raster swap** — pdfrx renders a fresh higher-DPI bitmap and swaps it in (brief tone/size pop) = R11 territory; OR
- (c) **rebuild flash** — a `setState`/`_needsCenter` post-frame callback (`pen_editor_screen.dart:441448`) or `ValueKey(_pageIndex)` re-mounts the subtree.
Record the verdict + a frame capture in the perf-results doc.
**Step 2 — fix chosen by cause:**
- If **(a)**: keep the last good `ui.Image` painted as an underlay for the **current page only**, swapped atomically when the new render is ready (the seed of P0.5 `page_tile`'s double-buffer — link the TODO so it's replaced, not duplicated). **Caveat (Architect): this can race pdfrx's own internal raster cache** — verify the underlay sits *below* `PdfPageView` and is only shown while the live raster is absent.
- If **(b)**: this is R11 — do NOT build a bespoke fix; the cheap interim is "keep the page painted across the swap" and the real fix is the P0.5 `page_tile` DPI-on-settle. Defer, note in the roadmap.
- If **(c)**: remove the spurious rebuild (gate `_needsCenter`, avoid re-keying on zoom) — cheapest of all.
**Acceptance:** root cause documented; pinch-zoom on the Surface shows **no flash**; frame cost unchanged (≤16.6ms median) before/after; if deferred to P0.5, that decision is recorded with evidence (not silently dropped).
### W3 — Native Windows pen plugin (tilt + barrel/eraser → buttons)
**Hook point (MUST-FIX M1):** `FlutterWindow::MessageHandler` calls `flutter_controller_->HandleTopLevelWindowProc(...)` **first** and returns early when Flutter handles the message (`windows/runner/flutter_window.cpp:5664`). Flutter 3.44 itself consumes WM_POINTER to synthesize stylus events, so a `switch` *after* that call (line 66) — and the `ocr_channel` registration site at `:29`**never see consumed pen messages**. The observer therefore reads pen info at the **TOP of `MessageHandler`, before** `HandleTopLevelWindowProc`, observing without consuming (do not `return` a result; fall through so Flutter still processes its event).
**Correlation (MUST-FIX M2):** do NOT build `Map<Win32 pointerId → state>` and join it to Flutter's `event.pointer` — they are **different id spaces**. Only one pen is active at a time, so latch a **single "current stylus hardware state"** natively, update it on every observed WM_POINTER (incl. hover/`WM_POINTERENTER`), and read it at the decision points in Dart.
**Why the eraser decision is NOT racy (re-grounded per Critic #2 — do NOT rely on hover-precedence):** the guarantee is the **M1 native ordering**, not "hover precedes down." A pen that contacts the screen directly with no hover dwell delivers `WM_POINTERDOWN` as its first message. Our observer runs at the **top of `MessageHandler`** (M1), so it latches that down's `penFlags` **before** `HandleTopLevelWindowProc` synthesizes the corresponding Flutter pointer-down — therefore when Dart's `_onPointerDown` reads `PenInputService.current`, the latch already reflects this exact contact. The EventChannel push is async, but the **native latch is updated synchronously in the same window-proc pass that precedes Flutter's event**; so the Dart service must source the eraser flag from a value guaranteed fresh by that ordering (i.e. the channel delivers the down-flags before/with the Dart down event because both originate from the same WM_POINTERDOWN, observer-first). Tilt is per-point and tolerant of one-frame lag. **This must be proven on-device with a pen-down-without-hover test (see pre-mortem #4).**
**Threading (Architect):** the observer runs on the **platform (UI) thread** inside the window proc. Do **NOT** copy `ocr_channel.cpp`'s MTA worker-thread model (`ocr_channel.cpp:73118`) — that pattern is for the long-running OCR call, wrong for low-latency per-event pen state. Latch + post to the channel sink directly on the platform thread.
**Add/modify:**
- `windows/runner/pen_channel.{h,cpp}` **[ADD]** — `ObservePenMessage(message, wparam, lparam)`: if `message ∈ {WM_POINTERENTER, WM_POINTERDOWN, WM_POINTERUPDATE, WM_POINTERUP}`, `GET_POINTERID_WPARAM(wparam)``GetPointerType` → if `PT_PEN`, `GetPointerPenInfo(id, &POINTER_PEN_INFO)`; read `penFlags` (`PEN_FLAG_BARREL`, `PEN_FLAG_INVERTED`, `PEN_FLAG_ERASER`) + `tiltX`/`tiltY`. Latch into a native singleton AND push `{flags, tiltX, tiltY}` over `EventChannel('badnote/pen')`. Returns void; never consumes.
- `windows/runner/flutter_window.cpp` **[MODIFY]** — call `ObservePenMessage(message, wparam, lparam)` at the **top of `MessageHandler` (before** the `HandleTopLevelWindowProc` block, M1); register the channel alongside `RegisterOcrChannel` in `OnCreate` (`:29`).
- `lib/editor/input/pen_input_service.dart` **[ADD]** — listens to `EventChannel('badnote/pen')`; holds the **latest single** `PenHardwareState{barrel, inverted, eraser, tiltX, tiltY}` (not keyed by pointerId, M2). No-op / empty on non-Windows or a silent channel.
- `lib/editor/canvas/pen_stroke.dart` **[MODIFY] (Critic #1 — wrong-model fix):** the live canvas captures `PenPoint{x, y, pressure}` (`pen_stroke.dart:15-21`) which has **no `tilt` field**`EditorPoint.tilt` lives in the *separate* engine model the live widget does not use. Add `double? tilt` to `PenPoint` now, populate it at capture, and map it through `EditorStroke.fromPenStroke``EditorPoint.tilt` (SF1) so tilt is lossless end-to-end on the live path. (When the canvas migrates to `EditorStroke` in P0, `PenPoint` retires and this collapses to `EditorPoint.tilt` directly.)
- `lib/editor/canvas/pen_canvas.dart` **[MODIFY]** — replace the dead `_isEraserSignal` (`pen_canvas.dart:134136`, `kSecondaryButton||invertedStylus` never fires on Windows): on hover/down read `PenInputService.current` and resolve eraser/undo/etc. through the configured `PenConfig.sideButton`/`eraserEnd` mapping; stash `tiltX/Y` into the captured `PenPoint.tilt` (added above).
- `lib/editor/input/pen_config.dart``sideButton`/`eraserEnd` `PenButtonAction` already exist; the canvas now actually consults them for plugin-delivered flags.
**Phase-1 probe before wiring (pre-mortem #1):** first land ONLY a WM_POINTER logging line in the observer and confirm on the Surface that `GetPointerPenInfo` returns non-zero `penFlags`/tilt. Only then add the EventChannel + Dart wiring.
**Acceptance (device-gated):** on the Surface, the eraser end erases; the barrel button performs its mapped `PenButtonAction`; remapping side-button→undo makes the barrel undo; the diagnostic readout shows non-zero `tilt`; with the channel silent the app still draws with pressure (graceful degradation). CI builds the Windows package green; correctness confirmed only on-device + recorded with commit hash.
---
## 3. Sequencing
1. **W1** (pure Dart, CI-testable, low risk) — ship first; unblocks "pen feel" immediately.
2. **W2 investigate → minimal fix** — systematic-debugging Phase 1 evidence, then the double-buffer.
3. **W3 native plugin** — biggest/native; (a) WM_POINTER logging probe → device-confirm `GetPointerPenInfo` returns flags+tilt; (b) EventChannel + Dart service; (c) wire eraser/tilt + `PenConfig` mappings.
4. **W4** — resume the parent roadmap P0 (task #7) → P0.5. W1's `stroke_geometry` change and W3's `pen_input_service` are written to land in `engine/`+`input/` so the P0 relocation absorbs them rather than re-doing them.
Each of W1/W2/W3 is an independently shippable CI package; W2/W3 carry a manual Surface checklist before "done."
---
## 4. ADR
- **Decision:** Pull the **width+pressure-sensitivity** slice of F5 forward (de-hardcode `thinning` in the canonical geometry), fix the zoom flicker with a **double-buffer stopgap** that seeds the P0.5 `page_tile`, and add a **native Windows pen plugin** (`pen_channel.cpp` + `EventChannel('badnote/pen')`) to recover barrel/eraser/tilt that the Flutter 3.44 engine drops — all feeding the approved `engine/`/`input/` seams, then resume the parent roadmap.
- **Drivers:** D1 unblock primary device today; D2 don't derail the refactor; D3 flicker-free zoom without prematurely building the full tiler.
- **Alternatives:** literals-only width (rejected — user), full `page_tile` now (deferred to P0.5), federated plugin package (rejected — `ocr_channel` in-runner pattern suffices), wait-for-upstream (rejected — blocked now).
- **Consequences:** one new native file (Windows-only, device-gated); a temporary page double-buffer superseded by P0.5; `PenConfig` gains `pressureSensitivity`; `thinning` defaults centralized as `kDefaultPenThinning`.
- **Follow-ups:** W2 stopgap deleted when `page_tile` lands; W3 eraser/tilt wiring moves into `input_arbiter` during P0; confirm `GetPointerPenInfo` tilt units + sign on-device.

View File

@@ -0,0 +1,602 @@
# BadNote — File-Based Storage Re-Architecture (Obsidian-style vault)
**Status:** DESIGN (not yet implemented)
**Date:** 2026-06-24
**Owner plan file:** `docs/plans/2026-06-24-file-based-storage.md`
**Mode:** DELIBERATE (touches persistence of a working pen editor — must not lose ink)
> Grounding note: written after reading the actual current sources —
> `lib/services/database_service.dart`, `lib/editor/persistence/editor_repository.dart`,
> `lib/editor/persistence/save_scheduler.dart`, `lib/editor/canvas/pen_editor_screen.dart`,
> `lib/screens/split_view_screen.dart`, `lib/screens/home_screen.dart`,
> `lib/services/pdf_service.dart`, `lib/services/pptx_service.dart`,
> `lib/providers/document_provider.dart`, `lib/providers/settings_provider.dart`,
> `lib/main.dart`, and the stroke models
> (`lib/editor/engine/stroke_model.dart`, `lib/models/ink_stroke.dart`,
> `lib/models/ink_point.dart`, `lib/editor/canvas/pen_stroke.dart`,
> `lib/models/scratch_link.dart`, `lib/models/bookmark.dart`,
> `lib/models/document.dart`). `file_picker` `getDirectoryPath` (Windows desktop
> support) confirmed via Context7 against the installed `file_picker: ^8.0.0`.
---
## 0. The user's intent (verbatim)
1. **"Import file" is a TOP-LEVEL home-screen action**, sibling of "Create
notebook", accepting **multiple file types** (docx, pptx, pdf, …).
2. **Annotations travel WITH the file** ("跟着文件走") — stored as JSON
**sidecar files** next to the source file, **NOT in SQLite**. Sync follows the
file.
3. After importing a PDF, the file is placed inside a **notebook folder**, then
synced to a **server**. User believes this is **more robust than SQLite**.
4. On first launch, the user **picks a notebook ROOT folder** (an Obsidian-style
vault).
5. **Robust auto-save.**
---
## 1. What is stored in SQLite TODAY (inventory)
Read from `lib/services/database_service.dart` (schema version **8**). This is the
data that must move to files or be re-homed.
| Table | Written/read by | Holds | Coordinate format | Disposition |
|---|---|---|---|---|
| `documents` | `DocumentListNotifier` (`document_provider.dart`), `DatabaseService.insertDocument/getAllDocuments/deleteDocument` | id (uuid), filename, doc_type (`pdf`/`pptx`), **absolute** file_path, page_count, rotation, timestamps | — | → derived from vault scan; becomes a cache/index only |
| `ink` | `EditorRepository` (`editor_repository.dart`), `SaveScheduler` | committed PDF-editor strokes; one row/stroke; `host_id = "doc:<documentId>:page:<pageIndex>"`, `stroke_json` = `EditorStroke.toJson()` | normalized [0,1], width = fraction of page width | → **sidecar `strokes[pageIndex]`** |
| `scratch_links` | `PenEditorScreen._placeScratchLink`, `DatabaseService.saveScratchLink/loadScratchLinks` | anchor id, document_id, page_index, nx, ny | normalized [0,1] | → **sidecar `scratchLinks[]`** |
| `scratchpads` | `SplitViewScreen` (keyed by **anchor id**), `DatabaseService.saveScratchpad/loadScratchpad` | one row per anchor id; `strokes_json` = list of `InkStroke.toJson()` | **absolute world pixels** (infinite canvas) | → **sidecar `scratchLinks[].scratchpad.strokes[]`** |
| `bookmarks` | `DatabaseService.insertBookmark/getBookmarks` | id, document_id, page_number, label, color | page index | → **sidecar `bookmarks[]`** |
| `annotations` | `DatabaseService.saveAnnotations/getAnnotations` | legacy per-page `annotation_json` blob | per-page | **DEAD in new editor** — migrate if present, else ignore |
| `notes` + `strokes` | `note_provider.dart` via `insertNote/updateNote`; `Note` model | free ink notebooks (not file-backed); strokes are `InkStroke` rows | normalized | → **standalone notebook sidecars** (see §A.4) |
| `board_cards` | `DatabaseService.saveBoardCards/loadBoard` (F7 sticky board) | board cards geometry+text | absolute | → **per-notebook `board.json`** (or keep in SQLite short-term, §B) |
| `document_fts`, `notes_fts`, `ocr_results`, `notebook_pages` | FTS + OCR | search index, OCR text | — | **stays in SQLite as a rebuildable cache** (never the source of truth) |
**Key existing facts the new format must preserve (cite):**
- `EditorStroke.toJson()` (freezed/json_serializable, `stroke_model.dart`) emits:
`{ "id", "points":[{ "x","y","pressure","tilt","timestamp","pointerDeviceKind" }], "tool":"pen"|"highlighter"|"eraser", "color":<int ARGB>, "width":<double>, "filled":<bool>, "textContent":<String?>, "fontSize":<double> }`.
**`brush` is deliberately NOT serialized** (`@JsonKey(includeFromJson:false,includeToJson:false)` in `stroke_model.dart`), so loaded strokes default to `BrushKind.fountainPen`. The sidecar format inherits this limitation (see §A.5 "brush TODO").
- `InkStroke.toJson()` (`ink_stroke.dart`) emits the same point shape plus
`createdAt` and `strokeWidth` (note: `strokeWidth`, not `width`). This is the
format already persisted for scratchpads and free notes.
- `ScratchLink.toJson()` (`scratch_link.dart`, hand-written): `{id, documentId, pageIndex, nx, ny}`.
- The editor derives its **documentId from the file path** via a djb2 hash
(`_documentIdFromPath` in `pen_editor_screen.dart`). Once strokes live in a
sidecar next to the file, **the path-hash document id becomes irrelevant**
the sidecar IS the identity. This removes a class of bugs (moving a file
orphaned its SQLite rows).
---
## 2. Decision summary (what we are building)
- A **vault** = a user-picked root folder, path stored in `SharedPreferences`.
- Each imported document becomes a **notebook folder** inside the vault:
`<vault>/<notebook-name>/` containing the **source file** + one **sidecar**
`<file>.badnote.json` holding all annotations for that file.
- **The sidecar is the source of truth.** SQLite is demoted to a
**rebuildable index/cache** (FTS + thumbnails + recent list). Recommendation:
**keep SQLite, but only as cache** (§B explains why dropping it entirely is
more work than it's worth right now).
- **Auto-save** writes the sidecar atomically (temp + rename), debounced, with
flush on pause/close.
- **Sync** = file-level sync of the whole vault folder to a server; sync unit =
notebook folder; conflict policy = last-write-wins per file + `.conflict` copy.
---
## A. On-disk layout + JSON schema
### A.1 Vault layout
```
<vault root>/ ← user-picked, persisted in SharedPreferences
├─ .badnote/ ← vault-level app metadata (hidden)
│ ├─ vault.json ← { "schemaVersion": 1, "vaultId": "<uuid>", "createdAt": ... }
│ └─ index.sqlite ← OPTIONAL local cache/index (FTS, thumbnails). Rebuildable. NOT synced.
├─ Calculus Lecture 3/ ← a notebook folder (one per imported doc)
│ ├─ Calculus Lecture 3.pdf ← the source file (pdf/docx/pptx/…)
│ ├─ Calculus Lecture 3.pdf.badnote.json ← the sidecar (annotations travel with the file)
│ └─ .badnote-assets/ ← optional: rendered page PNGs for pptx/docx, thumbnails
│ ├─ slide_1.png …
│ └─ thumb_0.png …
├─ My freehand notes/ ← a NON-file-backed notebook (free ink, no source doc)
│ └─ notebook.badnote.json ← strokes-only notebook (replaces SQLite notes/strokes)
└─ …
```
Rules:
- **Notebook folder name** = sanitized source filename (basename without
extension), de-duplicated with a numeric suffix on collision.
- **Sidecar name** = `<source filename incl. ext>.badnote.json`. Keeping the full
source extension in the sidecar name means a folder with both `slides.pdf` and
`slides.pptx` never collides.
- **Sidecar lives in the same folder as its file** → moving/copying/syncing the
folder moves the annotations with it ("跟着文件走"). This is the whole point.
- `.badnote/index.sqlite` is **per-vault** and **excluded from sync** (it is a
cache; each device rebuilds its own). It replaces today's
`getApplicationDocumentsDirectory()/badnote.db`.
### A.2 Sidecar JSON schema (file-backed document)
`<file>.badnote.json` (UTF-8, pretty-printed for diff-friendliness / git sync):
```json
{
"badnoteSidecarVersion": 1,
"sourceFile": "Calculus Lecture 3.pdf",
"docType": "pdf",
"pageCount": 42,
"rotation": 0,
"createdAt": "2026-06-24T10:00:00.000Z",
"updatedAt": "2026-06-24T10:32:11.500Z",
"strokes": {
"0": [ <EditorStroke.toJson()>, <EditorStroke.toJson()>, ],
"3": [ <EditorStroke.toJson()>, ]
},
"highlights": {
"0": [ { "l": 0.12, "t": 0.20, "r": 0.88, "b": 0.235, "color": 1714657595 } ]
},
"bookmarks": [
{ "id": "<uuid>", "pageNumber": 5, "label": "Proof", "color": 4283215696, "createdAt": "…" }
],
"scratchLinks": [
{
"id": "<uuid>",
"pageIndex": 7,
"nx": 0.83, "ny": 0.41,
"createdAt": "…",
"scratchpad": {
"canvasWidth": 4000,
"canvasHeight": 4000,
"strokes": [ <InkStroke.toJson()>, ] // absolute world pixels (unchanged format)
}
}
]
}
```
Schema notes, tied to existing code:
- **`strokes`** is a map keyed by **string page index** → list of
`EditorStroke.toJson()` objects. This is byte-for-byte the JSON already written
to the `ink` table's `stroke_json` column by `EditorRepository.saveHost`
(`jsonEncode(stroke.toJson())`). Loading just calls `EditorStroke.fromJson`.
- Drop the `host_id = "doc:<id>:page:<i>"` convention entirely; the sidecar key
IS the page index. `EditorRepository.pageHostId` /
`_pageIndexFromHostId` (in `pen_editor_screen.dart`) become obsolete for the
file path.
- **`highlights`** persists what is TODAY in-memory only — see
`_highlightsByPage` and `TODO(persist-highlights)` in `pen_editor_screen.dart`.
Each rect stored normalized [0,1] (`l/t/r/b`) exactly as `_highlightSelection`
computes it. This closes that TODO as a side benefit.
- **`bookmarks`** mirrors `Bookmark.toJson()` (`bookmark.dart`); `documentId`
field is dropped (the sidecar already scopes it).
- **`scratchLinks[]`** merges today's TWO tables: `scratch_links` (anchor
geometry) + `scratchpads` (the anchor's private ink, currently keyed by anchor
id). Each anchor now **embeds** its scratchpad. `scratchpad.strokes` keep the
`InkStroke.toJson()` format in **absolute world pixels** — unchanged from
`SplitViewScreen._saveImmediate`, so the infinite-canvas logic
(`_checkCanvasExpansion`, `penStrokesFromInk`) needs no change. We persist
`canvasWidth/Height` so the world size restores (today it always resets to
4000×4000).
### A.3 Why normalized vs absolute coords are preserved verbatim
- PDF-editor strokes (`EditorStroke`) are normalized to the page rect; width is a
fraction of page width (`stroke_model.dart` header comment). Sidecar stores them
unchanged → no re-projection, no rounding drift, export
(`PdfService.exportAnnotatedPdf`) keeps working untouched.
- Scratchpad strokes (`InkStroke`) are absolute world pixels (infinite canvas).
Sidecar stores them unchanged.
- This is a **format-preserving** migration: same `toJson`/`fromJson`, different
container (file vs row). That is what makes it low-risk.
### A.4 Standalone (non-file) notebooks — the "Create notebook" path
Free-ink notes today live in `notes` + `strokes` (`Note` model, `note_provider.dart`).
In the vault they become a notebook folder with **no source file**, holding a
`notebook.badnote.json`:
```json
{
"badnoteSidecarVersion": 1,
"docType": "notebook",
"title": "My freehand notes",
"tags": ["math"],
"createdAt": "…", "updatedAt": "…",
"pages": [
{ "ordinal": 0, "strokes": [ <InkStroke.toJson()>, ] }
]
}
```
(`InkStroke.toJson()` is the exact format `Note.strokes` already serialize to.)
### A.5 Brush persistence caveat (honest limitation)
`EditorStroke.brush` and `PenStroke.brush` are **not serialized** today
(`@JsonKey(includeFromJson:false,includeToJson:false)`, see `stroke_model.dart`
and `TODO(brush-persist)` in `pen_editor_screen.dart`). The sidecar inherits this:
a reloaded pen stroke renders as `fountainPen`; a highlighter is recovered from
`tool == highlighter`. **Recommendation:** add an optional `"brush"` field to the
sidecar `EditorStroke` JSON in a later increment by flipping the `@JsonKey` — the
sidecar schema is forward-compatible (unknown fields ignored on read), so this is
non-breaking. Not required for this storage migration.
---
## B. Migration: SQLite → sidecars (no data loss)
**Recommendation: KEEP SQLite, demote it to a rebuildable cache. Do NOT drop it.**
Reasons:
- FTS5 (`document_fts`, `notes_fts`) and OCR (`ocr_results`) are non-trivial and
query-shaped; re-implementing search over flat JSON files is a separate project.
Keep them in `.badnote/index.sqlite`, rebuilt by scanning sidecars.
- The home screen's "recent documents" list (`getAllDocuments`) wants fast sorted
access; a cache table is the pragmatic backing for it (the source of truth is
still the vault scan).
- Dropping SQLite forces rewriting `note_provider`, `document_provider`,
`search_provider`, OCR, and the board in one shot — high blast radius. Demoting
is incremental and reversible.
### B.1 One-time migration on first launch after upgrade
Guarded by a `SharedPreferences` flag `vaultMigrationDone` (and only runs once a
vault root exists — see §C). Algorithm:
1. Open the legacy DB at `getApplicationDocumentsDirectory()/badnote.db` (the path
`DatabaseService._initialize` uses today). If absent → nothing to migrate.
2. For each row in `documents`:
a. Resolve the legacy `file_path` (absolute). If the file still exists, **copy**
it into a new notebook folder `<vault>/<sanitized filename>/`.
b. Build the sidecar:
- `strokes`: query `ink WHERE host_id LIKE 'doc:<documentId>:page:%'`
(the `EditorRepository.loadDocument` query), group by page index parsed
from host_id, write each `stroke_json` straight through (it's already
`EditorStroke` JSON — no re-encode).
- `bookmarks`: `getBookmarks(documentId)`.
- `scratchLinks`: `loadScratchLinks(documentId)`; for each, `loadScratchpad(anchorId)`
→ embed as `scratchpad.strokes` (re-encode via `InkStroke.toJson`).
- `annotations` (legacy per-page blob): if any rows exist, attempt to decode
and fold into `strokes`; if format is unrecognized, copy the raw blob into
a `legacyAnnotations` field so nothing is silently dropped.
c. Write the sidecar **atomically** (§F).
3. For each `notes` row → write a standalone notebook sidecar (§A.4) under a
notebook folder.
4. For `board_cards`: SHORT TERM leave them in SQLite (board is self-contained and
not part of the "files" intent). LATER, write a `board.json` per notebook.
5. **Do not delete the legacy DB.** Rename it to `badnote.db.premigration` as a
safety net. Set `vaultMigrationDone = true`.
6. Rebuild `.badnote/index.sqlite` (FTS + recent list) by scanning the new vault.
### B.2 Crash safety of migration
- Process sidecars one notebook at a time; each sidecar write is atomic.
- The migration is **idempotent**: re-running skips notebook folders whose sidecar
already exists and validates. If it dies halfway, relaunch resumes.
- Because the legacy DB is preserved until the flag flips, a failed migration
loses nothing.
---
## C. Folder picker on init (the vault prompt)
Use **`file_picker`** — already a dependency (`file_picker: ^8.0.0` in
`pubspec.yaml`, already used by `PdfService.pickPdfFile` /
`PptxService.openPptxFile`). Its **`FilePicker.platform.getDirectoryPath()`** is
**Desktop/Windows supported** (confirmed via Context7). **No new package needed.**
Do NOT add `file_selector``file_picker` already covers both file and directory
picking and is wired in.
### C.1 Persistence + flow
- New `VaultService` (singleton, like `DatabaseService`):
- `Future<String?> getVaultRoot()` — reads `SharedPreferences` key `vaultRoot`.
- `Future<void> setVaultRoot(String path)` — writes it.
- `Future<bool> vaultRootValid()` — true iff the stored path exists and is a
writable directory.
- `main.dart` change: after `SharedPreferences.getInstance()`, check
`vaultRootValid()`.
- If **valid** → go to `HomeScreen` as today.
- If **missing/invalid** → show a `VaultSetupScreen` (a gate before
`HomeScreen`) that explains "Pick a folder to store your notebooks (like an
Obsidian vault)" and calls `getDirectoryPath(dialogTitle: 'Choose your BadNote vault', lockParentWindow: true)`.
On selection: create `<root>/.badnote/vault.json`, persist the path, then
enter `HomeScreen`.
- **Re-prompt if missing:** if the stored path later disappears (external drive
unplugged, folder deleted), `vaultRootValid()` returns false → the gate shows
again with a "your vault folder is missing — relocate or pick a new one"
message. Never silently fall back to app-documents (that would scatter data).
- A **"Change vault"** entry in `SettingsScreen` re-runs the picker.
### C.2 Windows specifics
- Pass `lockParentWindow: true` so the native dialog is modal (Context7 note).
- Wrap in try/catch (Context7 shows `getDirectoryPath` can throw on Windows for
permission/system issues) and surface a retry.
- Validate the chosen folder is writable by writing+deleting a probe file before
committing it as the vault.
---
## D. Multi-format import (docx / pptx / pdf)
This is the genuinely hard part. Be honest about it.
### D.1 The home-screen entry (requirement #1)
Replace today's two separate `IconButton`s (`_importPdf`, `_importPptx` in
`home_screen.dart`) and the empty-state buttons with **one top-level "Import
file" action**, a sibling of "Create notebook" (the FAB / `_createAndOpenNote`).
A single `FilePicker.pickFiles(type: FileType.custom, allowedExtensions: ['pdf','docx','pptx','ppt'])`
call; route on extension. Both actions sit at the same visual level (e.g. two
primary buttons in the empty state, and two entries in the app bar / a small
"+ New" menu with "Create notebook" and "Import file").
### D.2 Per-format strategy
| Format | Annotate how | Mechanism | Windows-viable? |
|---|---|---|---|
| **PDF** | Directly, as today | `pdfrx` `PdfViewer` + normalized ink overlay (`PenEditorScreen`, unchanged) | YES — already shipping |
| **PPTX/PPT** | **Convert to images, annotate as slides** | `PptxService.convertToImages` (LibreOffice headless → PNG, fallback placeholders) + `PenSlideScreen` | PARTIAL — needs LibreOffice |
| **DOCX** | **Convert to PDF, then annotate as PDF** (recommended) | LibreOffice headless `--convert-to pdf`, then the PDF path flows into `PenEditorScreen` | PARTIAL — needs LibreOffice |
### D.3 The realistic recommendation
- **PDF:** unchanged. Place the picked file into a notebook folder, open
`PenEditorScreen` on the **vault copy** (not the original picked path).
- **DOCX → PDF (convert-on-import):** the cleanest path is to **convert DOCX to a
PDF once, at import time**, store the **PDF** as the notebook's annotatable
artifact (keep the original `.docx` alongside it for fidelity/round-trip). Then
everything downstream is the existing, working PDF pipeline. This is far simpler
than rendering Word layout natively in Flutter (there is no good pure-Dart DOCX
renderer).
- **PPTX:** keep the existing slide-image path (`PptxService` + `PenSlideScreen`),
but **cache the rendered PNGs into the notebook's `.badnote-assets/`** instead of
a temp dir (today `convertToImages` writes to `getTemporaryDirectory()`, so
slides re-render every open — see `home_screen.dart` `_openDocument`). Caching
also makes the notebook self-contained for sync.
### D.4 The hard truth about conversion (call it out)
- Both DOCX→PDF and PPTX→PNG currently depend on **LibreOffice headless** being on
`PATH` (`PptxService._convertViaLibreOffice` runs `which libreoffice` then
`libreoffice --headless --convert-to …`). On a **Windows tablet, LibreOffice is
usually NOT installed**, and `which` is a POSIX tool that won't resolve `soffice.exe`.
**This path will silently fail today on Windows** and fall back to placeholder
slides.
- **Proposed fallbacks, in order:**
1. **Detect LibreOffice/`soffice.exe`** at the standard Windows install paths
(`C:\Program Files\LibreOffice\program\soffice.exe`) in addition to `PATH`;
invoke `soffice` (not `libreoffice`) on Windows. Fix the `which` assumption.
2. If absent, **prompt the user** ("Install LibreOffice to import Word/PowerPoint,
or convert to PDF first"), and offer a **"locate soffice.exe" picker** that we
persist in SharedPreferences.
3. **Bundle/ship nothing heavy.** Do not attempt to embed a converter. For a
single-user tablet app, requiring LibreOffice (or pre-export to PDF) is an
acceptable, honest constraint.
- **Minimal first cut:** ship **PDF import end-to-end on the new vault**, plus the
one-file "Import file" entry that *accepts* docx/pptx, but for docx/pptx route
through the existing (LibreOffice-dependent) converters with the Windows
`soffice.exe` fix. Treat full docx/pptx fidelity as a known limitation, not a
blocker for the storage re-architecture.
---
## E. Server sync (file-level, single user)
Keep it pragmatic. The data is now plain files in one folder, which is exactly what
makes simple sync viable.
### E.1 Sync unit & approach
- **Sync unit = the notebook folder** (source file + sidecar + assets). A notebook
is self-contained, so syncing the folder syncs the annotations with it.
- **Exclude** `.badnote/index.sqlite` and `.badnote-assets/` from sync if desired
(assets are re-derivable; the index is per-device). Source file + sidecar are the
must-sync pair.
- **Recommended concrete approach (single-user): WebDAV** to a self-hosted/Nextcloud
endpoint, OR a **simple REST blob sync** if the user controls the server.
- WebDAV is the lowest-effort robust option (PUT/GET/PROPFIND, mtime-based),
works against Nextcloud/ownCloud/rclone-serve, and there are Dart HTTP clients.
- If the user already uses Nextcloud/Dropbox/OneDrive **and** the vault folder
lives inside that synced folder, BadNote needs **zero sync code** — the OS sync
client handles it. This is the cheapest path and worth recommending as option 0.
- **git** is possible (text JSON diffs nicely) but binary PDFs bloat history and
conflict UX is poor for a tablet — not recommended as the default.
### E.2 Conflict policy
- **Last-write-wins per file**, using a manifest of `{ relativePath, sha256, mtime }`
per notebook (store in the sidecar's `updatedAt` + a small per-vault
`.badnote/sync-manifest.json`, NOT synced).
- On pull, if remote and local both changed a file since last sync (both differ
from the last-synced hash): **keep local, write the remote copy as
`<file>.badnote.json.conflict-<timestamp>`** next to it, and surface a
non-blocking notice. No silent overwrite, no merge attempt. For a single user on
≤2 devices this is rare and acceptable.
- The **source PDF/docx is effectively immutable** after import (annotations live in
the sidecar), so the only file that realistically conflicts is the sidecar JSON —
which is small and human-readable, making `.conflict` copies easy to reconcile.
### E.3 "Annotations follow the file" with sidecars
Because the sidecar sits in the same folder as the source file and shares its
basename, any sync that moves the folder moves both together. There is **no
database to keep in lockstep** — that is the robustness the user asked for. The
SQLite index is rebuilt locally from the synced files, never synced.
---
## F. Robust auto-save
### F.1 Atomic sidecar write
Single helper, e.g. `SidecarStore.writeAtomic(File target, String json)`:
1. Write to `target.path + '.tmp'` with `flush: true`
(`File.writeAsString(..., flush: true)` — same pattern `PdfService` already uses
with `writeAsBytes(flush: true)`).
2. `await tmp.rename(target.path)` — rename is atomic on the same filesystem on
Windows/NTFS and POSIX, so a reader never sees a half-written sidecar.
3. Keep a one-deep backup: before rename, if `target` exists copy it to
`target.path + '.bak'` (cheap insurance against a corrupt write taking out the
last good copy). On load, if the main file fails to parse, fall back to `.bak`.
### F.2 Debounce + scheduler (reuse existing machinery)
- The editors already debounce: `SaveScheduler` (800 ms, `save_scheduler.dart`) for
the PDF editor, and `SplitViewScreen`'s own 3 s `Timer`. **Reuse this exact
shape**, but the scheduler's sink becomes the sidecar writer instead of
`EditorRepository.saveHost`.
- Concretely: introduce a `SidecarRepository` with the same method surface the
`SaveScheduler` expects, so `_schedulePageSave` /
`PenEditorScreen._initPersistence` change only their wiring, not their control
flow. The scheduler still captures a synchronous snapshot before the async gap
(it already does — `save_scheduler.dart` comment).
- The unit of debounce stays "the whole document sidecar" (write the full JSON;
sidecars are small — strokes are sparse normalized points). One write per
debounce window, atomic.
- **Snapshot discipline:** capture the in-memory `_strokesByPage` /
`_highlightsByPage` / `_scratchLinks` into a plain JSON map synchronously in
`schedule(...)`, exactly as `SaveScheduler.schedule` captures strokes today, so a
later edit can't corrupt an in-flight write.
### F.3 Flush on pause/close (robustness)
- `PenEditorScreen.dispose` already calls `scheduler.flush()` then `dispose()`
(`pen_editor_screen.dart`); `SplitViewScreen.dispose` already calls
`_saveImmediate()`. Keep both, pointing at the sidecar writer.
- **Add an app-lifecycle flush** (today missing): register a
`WidgetsBindingObserver` (in `BadNoteApp` or each editor) and on
`AppLifecycleState.inactive/paused/detached` call `flush()`. On a Windows tablet,
app suspend/close is the main data-loss window; this closes it.
- Optionally, also flush on a short idle timer so a hard power-off loses ≤1 debounce
window.
---
## G. Phased migration plan
Each phase is independently shippable and testable, ordered to minimize risk to the
working editors. "Files changed" lists the primary touch points.
### Phase 0 — Vault root + setup gate (no data move yet)
- Add `VaultService` (SharedPreferences-backed) + `VaultSetupScreen`.
- `main.dart`: gate `HomeScreen` behind `vaultRootValid()`; `SettingsScreen`:
"Change vault".
- **No editor or DB change.** Editors still read/write SQLite. Vault path is merely
recorded.
- **Ship/test:** first-run prompt appears, path persists, re-prompts when folder
missing, Windows `getDirectoryPath` works (manual + a `VaultService` unit test).
- Files: `lib/services/vault_service.dart` (new), `lib/screens/vault_setup_screen.dart`
(new), `lib/main.dart`, `lib/screens/settings_screen.dart`.
### Phase 1 — Sidecar format + atomic store + read/write library (no UI swap)
- Define `BadnoteSidecar` model (toJson/fromJson) per §A, reusing
`EditorStroke`/`InkStroke`/`ScratchLink`/`Bookmark` JSON.
- `SidecarStore.writeAtomic` (§F.1) + `.bak` fallback loader.
- Pure unit tests: round-trip a sidecar with strokes/highlights/links/scratchpads;
atomic-write crash simulation; `.bak` recovery.
- **No runtime behavior change yet** (library only).
- Files: `lib/storage/badnote_sidecar.dart` (new), `lib/storage/sidecar_store.dart`
(new), tests.
### Phase 2 — PDF editor reads/writes sidecar (the core swap)
- Introduce `SidecarRepository` implementing the `SaveScheduler` sink; rewire
`PenEditorScreen._initPersistence`, `_loadPersistedStrokes`,
`_schedulePageSave`, `_loadScratchLinks`, `_placeScratchLink`,
`_confirmDeleteScratchLink` to the sidecar instead of `EditorRepository` /
`DatabaseService.*ScratchLink`. Persist highlights (closes `TODO(persist-highlights)`).
- `SplitViewScreen` reads/writes its scratchpad from the sidecar's
`scratchLinks[].scratchpad`.
- The document's identity becomes its **vault path**, not the djb2 path-hash;
`_documentIdFromPath` retired for this path.
- **Ship/test:** import a PDF (into vault), draw, place scratch links, reopen →
everything restored from sidecar; SQLite `ink`/`scratch_links`/`scratchpads` no
longer written for new docs. Add a widget/integration test.
- Files: `lib/editor/persistence/sidecar_repository.dart` (new),
`lib/editor/canvas/pen_editor_screen.dart`, `lib/screens/split_view_screen.dart`.
### Phase 3 — Vault-backed import + top-level "Import file"
- `VaultService.createNotebook(sourceFilePath)` → makes the folder, copies the file
in, returns the vault paths.
- Home screen: collapse `_importPdf`/`_importPptx` into one **"Import file"** action
(sibling of "Create notebook"); multi-extension picker; route by extension.
- Documents list now comes from a **vault scan** (folders with sidecars), not
`documents` table; `document_provider` reads the vault (cache table optional).
- Windows `soffice.exe` detection fix in `PptxService`; PPTX assets cached into
`.badnote-assets/`; DOCX→PDF convert-on-import (best-effort, with the LibreOffice
caveat surfaced to the user).
- **Ship/test:** one Import button accepts pdf/docx/pptx; imported files land in
vault folders with sidecars; reopening reads from the vault.
- Files: `lib/screens/home_screen.dart`, `lib/providers/document_provider.dart`,
`lib/services/vault_service.dart`, `lib/services/pptx_service.dart`,
`lib/services/pdf_service.dart` (open vault copy).
### Phase 4 — Standalone notebooks + free notes on sidecars
- "Create notebook" writes a `notebook.badnote.json` (§A.4) instead of `notes`/`strokes`.
- `note_provider` reads/writes the vault; `home_screen` note tiles come from the scan.
- Files: `lib/providers/note_provider.dart`, `lib/editor/canvas/pen_note_screen.dart`,
`lib/screens/home_screen.dart`.
### Phase 5 — One-time SQLite→sidecar migration (§B)
- Migrator runs on first launch with a valid vault and `vaultMigrationDone == false`.
- Demote SQLite to `.badnote/index.sqlite` cache; preserve legacy DB as
`.premigration`.
- **Ship/test:** install over an old DB → all docs/strokes/scratchpads/bookmarks/notes
appear in the vault; idempotent re-run; legacy DB preserved. Golden-file tests
with a seeded legacy DB.
- Files: `lib/storage/sqlite_to_sidecar_migrator.dart` (new),
`lib/services/database_service.dart` (relocate DB path, expose raw read helpers),
`lib/main.dart` (invoke migrator).
### Phase 6 — Lifecycle-flush hardening + index rebuild
- `WidgetsBindingObserver` app-pause flush (§F.3) across editors.
- `.badnote/index.sqlite` (FTS + recent list + OCR) rebuilt by scanning sidecars;
`search_provider` queries the cache.
- Files: `lib/main.dart` (or a shared observer), the editors, `lib/providers/search_provider.dart`.
### Phase 7 — Server sync (optional, last)
- `SyncService`: WebDAV (or "vault lives in an OS-synced folder → no-op") +
per-notebook manifest + last-write-wins `.conflict` policy (§E).
- Settings UI to configure endpoint/credentials; manual "Sync now" + periodic.
- Files: `lib/services/sync_service.dart` (new), `lib/screens/settings_screen.dart`.
### Risk-minimization rationale
- Phases 01 add code without changing runtime behavior (lowest risk first).
- Phase 2 is the one delicate swap; it is isolated to the persistence wiring of two
screens and is backed by Phase-1 round-trip tests — the on-screen stroke models
and painters are untouched.
- The destructive step (Phase 5 migration) ships **after** the new format is proven
by Phases 24, and never deletes the legacy DB.
---
## Appendix: exact files inspected
`lib/services/database_service.dart` (schema v8; `documents`, `ink`, `scratch_links`,
`scratchpads`, `bookmarks`, `annotations`, `notes`, `strokes`, `board_cards`, FTS),
`lib/editor/persistence/editor_repository.dart` (`saveHost`, `loadDocument`,
`pageHostId`, host_id scheme), `lib/editor/persistence/save_scheduler.dart`
(800 ms debounce, synchronous snapshot, `flush`), `lib/editor/engine/stroke_model.dart`
(`EditorStroke.toJson`, brush not serialized), `lib/models/ink_stroke.dart`,
`lib/models/ink_point.dart`, `lib/editor/canvas/pen_stroke.dart`,
`lib/models/scratch_link.dart`, `lib/models/bookmark.dart`, `lib/models/document.dart`,
`lib/editor/canvas/pen_editor_screen.dart` (`_documentIdFromPath`,
`_initPersistence`, `_schedulePageSave`, `_highlightsByPage` +
`TODO(persist-highlights)`, scratch-link flow), `lib/screens/split_view_screen.dart`
(scratchpad keyed by anchor id, absolute world pixels, 3 s autosave),
`lib/screens/home_screen.dart` (`_importPdf`/`_importPptx`, empty-state buttons),
`lib/services/pdf_service.dart` (`pickPdfFile`, `exportAnnotatedPdf`, `writeAsBytes(flush:true)`),
`lib/services/pptx_service.dart` (LibreOffice headless, `which libreoffice`,
temp-dir output), `lib/providers/document_provider.dart`,
`lib/providers/settings_provider.dart` (SharedPreferences pattern), `lib/main.dart`
(`DatabaseService.getInstance`, `SharedPreferences.getInstance`, `HomeScreen` home).

View File

@@ -0,0 +1,45 @@
# Surface 验收清单(诊断包驱动)
AI 无法坐在 Surface 前时,用本清单 + **设置 → 诊断 → 导出诊断包** 闭环。
## 准备
1. `flutter build windows --release` 或 profile 安装包
2. 打开 BadNote → 设置 → 确认「导出诊断包」可用
3. 准备Surface Pen、一份 20+ 页 PDF、空白笔记
## MUST #3 — 笔 / 触控仲裁(约 1 分钟)
| 步骤 | 期望 |
|------|------|
| Pen 在 PDF 上书写 | 出墨,压感可见 |
| 单指上下滚 | 滚动页面,不画线 |
| 双指捏缩放 | 缩放,不画线 |
| 手掌搁在屏幕上同时用笔写 | 掌不画线palm |
导出诊断包。包内 `pen_events.json` 应出现 `arbiter` 行:`decision=draw`(笔)与 `decision=pan`(指)。
## W3 — 硬件笔按钮
| 步骤 | 期望 |
|------|------|
| 无悬停,直接用笔尾点按 | 擦除而非画线 |
| 按侧键(按你的笔设置) | 触发橡皮擦/平移/撤销 |
| 倾斜笔身书写 | `pen_events` / native summary 中 tilt 非全 0 |
查看 `meta.json``penNative``orPenFlags` / `orPtrFlags` 在按键时应有非零位;`historyCount` 可 >1。
## MUST #4 / #5 — 流畅profile
1. `flutter run --profile`
2. 打开大 PDF快速 fling + 捏缩放 30 秒
3. 导出诊断包 → `frame_samples.json``overBudget` 占比主观可接受;体感不掉帧
## 手感主观
- 快速甩笔:笔尖无明显拖尾/点状塌缩
- 缩放后页面不长时间白闪(若仍闪,在包内搜 `zoom` / `rebaseline`
## 回传
`badnote_diag_*.zip` 发回即可;无需录屏(可选)。

View File

@@ -0,0 +1,323 @@
# Pen/Ink Engine Spec — rnote + Krita algorithms, ported to Flutter/Dart
Concrete, implementable spec for a drawing-grade, Krita-compatible, extensible brush model
using the `perfect_freehand` Dart package plus a custom variable-width polygon path where
needed. All formulas are verbatim from primary source. Sources cited at the end.
---
## 1. rnote pressure → width (QUADRATIC + all PressureCurve options)
**Source:** `crates/rnote-compose/src/style/mod.rs``PressureCurve` enum and its `apply()` method.
Repo: https://github.com/flxzt/rnote
```rust
pub enum PressureCurve { Const = 0, Linear, Sqrt, Cbrt, Pow2, Pow3 } // default = Linear
pub fn apply(&self, width: f64, pressure: f64) -> f64 {
match self {
Self::Const => width, // w
Self::Linear => width * pressure, // w·p
Self::Sqrt => width * pressure.sqrt(), // w·p^0.5
Self::Cbrt => width * pressure.cbrt(), // w·p^(1/3)
Self::Pow2 => width * pressure.powi(2), // w·p^2 <-- QUADRATIC
Self::Pow3 => width * pressure.powi(3), // w·p^3
}
}
```
- `pressure ∈ [0,1]`; `width` = configured max stroke **width** (full width, not radius).
- It is a **pure power law** `width = baseWidth · p^n`, with `n ∈ {0, 1, 0.5, 1/3, 2, 3}`.
There are no other coefficients.
### The QUADRATIC the user wants = `Pow2`
```
width(p) = baseWidth · p² (p ∈ [0,1])
```
| Variant | Exponent | Formula | Feel |
|---------|----------|--------------------|----------------------------------------|
| Const | — | `w` | constant width (no pressure) |
| Linear | 1 | `w · p` | proportional (PF native model) |
| Sqrt | 0.5 | `w · √p` | thickens fast then plateaus (firm pen) |
| Cbrt | 1/3 | `w · p^(1/3)` | thickens very fast then plateaus |
| **Pow2**| **2** | **`w · p²`** | **thin at low p, ramps steeply (fountain/brush)** |
| Pow3 | 3 | `w · p³` | very thin until high p (expressive) |
Dart:
```dart
double rnoteWidth(double baseWidth, double p, PressureCurve c) => switch (c) {
PressureCurve.constc => baseWidth,
PressureCurve.linear => baseWidth * p,
PressureCurve.sqrt => baseWidth * math.sqrt(p),
PressureCurve.cbrt => baseWidth * math.pow(p, 1/3),
PressureCurve.pow2 => baseWidth * p * p, // QUADRATIC
PressureCurve.pow3 => baseWidth * p * p * p,
};
```
**Recommended floored variant** (real pens never reach zero width):
```
width(p) = baseWidth · (wMin + (1 - wMin) · p²), wMin ≈ 0.15 .. 0.35
```
---
## 2. rnote stroke building / smoothing (step by step)
Output unit — `Segment` (`crates/rnote-compose/src/penpath/segment.rs`):
```rust
enum Segment {
LineTo { end: Element }, // Element = { pos: Vec2, pressure: f64 }
QuadBezTo { cp: Vec2, end: Element },
CubBezTo { cp1: Vec2, cp2: Vec2, end: Element },
}
```
rnote has two pen builders. **Pick one to port.**
### 2a. Curved builder — uniform Catmull-Rom → cubic Bézier (RECOMMENDED FIRST; simple, deterministic)
**Source:** `crates/rnote-compose/src/builders/penpathcurvedbuilder.rs`
and `crates/rnote-compose/src/shapes/cubbez.rs::new_w_catmull_rom`.
Algorithm:
1. Buffer raw input `Element`s into a `Vec<Element>`.
2. **Start state:** emit plain `LineTo` segments until ≥ 4 points are buffered.
3. While ≥ 4 buffered points remain, take a **sliding window of 4 consecutive points**
`(p0, p1, p2, p3)` and emit ONE `CubBezTo` that draws the **middle span p1 → p2**.
Advance `i += 1` (windows overlap by 3 points → C1 continuity).
4. **Control points** (Catmull-Rom → cubic-Bézier conversion, **tension = 1.0 fixed, divisor = 6.0**):
```
cp1 = p1 + (p2 - p0) / (6.0 * tension)
cp2 = p2 - (p3 - p1) / (6.0 * tension)
// cubic Bézier: start = p1, cp1, cp2, end = p2
```
5. If the construction degenerates (coincident points), fall back to `LineTo`.
This is a **uniform (non-centripetal) Catmull-Rom spline expressed as a chain of cubic Béziers.**
The `1/6` factor is the standard Catmull-Rom→Bézier identity `cp = Pk ± (Pk+1 Pk1)/6`.
**There is NO separate streamline / position-averaging step in this builder** — all smoothing
comes from the spline. Per-point width still comes from each Element's pressure via §1.
Dart (per emitted cubic, tension = 1.0):
```dart
final cp1 = p1 + (p2 - p0) / 6.0;
final cp2 = p2 - (p3 - p1) / 6.0;
path.cubicTo(cp1.dx, cp1.dy, cp2.dx, cp2.dy, p2.dx, p2.dy);
```
### 2b. Modeled builder — Google ink-stroke-modeler spring-mass-damper ("physics" path)
**Source:** `crates/rnote-compose/src/builders/penpathmodeledbuilder.rs`, which wraps the
`ink-stroke-modeler-rs` crate (Rust binding of Google C++ `ink-stroke-modeler`).
The rendered tip is a **mass on a spring** anchored to the raw input, with drag — giving
smoothing plus the slight realistic "catch-up" lag of good ink.
Pipeline per input event (`Down`/`Move`/`Up`, each with pos + pressure + time):
1. **Wobble smoothing** — speed-gated moving average that kills high-frequency jitter (only when slow).
2. **Resampling** — upsample to a fixed output rate so curvature is even regardless of input rate.
3. **Position modeling** — spring-mass-damper integrates the tip toward each resampled anchor.
4. **Stylus-state modeling** — interpolate pressure/tilt onto resampled points (last N input samples).
5. **Prediction** — `predict()` extends the tip ahead of the latest real input to hide latency; cleared on `Up`.
6. Emit dense `Segment::LineTo` points (and prediction points while drawing).
rnote's `MODELER_PARAMS` (overrides on `ModelerParams::suggested()`):
- `sampling_min_output_rate = 120.0` Hz
- `sampling_max_outputs_per_call = 200`
- `sampling_end_of_stroke_stopping_distance = 0.01`
- `stylus_state_modeler_max_input_samples = 20`
Google `suggested()` defaults (`ink_stroke_modeler/params.cc`) — the actual spring constants:
- wobble_smoother: `timeout = 0.04 s`, `speed_floor = 1.31`, `speed_ceiling = 1.44`
- position_modeler: **`spring_mass_constant = 11/32400 ≈ 0.00033951`**, **`drag_constant = 72.0`**
- sampling: `min_output_rate = 180`, `end_of_stroke_stopping_distance = 0.001`, `end_of_stroke_max_iterations = 20`
- stylus_state_modeler: `max_input_samples = 20`
Spring update (Euler, fixed dt = 1/output_rate):
```
F = (x_anchor - x_tip)/spring_mass_constant - drag_constant * v_tip
v_tip += F * dt
x_tip += v_tip * dt
```
Higher `drag_constant` = more damping/lag; smaller `spring_mass_constant` = stiffer/snappier.
**Porting call:** ship 2a now (trivial, looks great for notes). Add 2b later as a "smooth mode"
tip filter for premium feel + latency hiding.
---
## 3. Krita: ballpoint vs fountain pen parameter sets
**Source:** Krita Manual 5.3 —
- Sensors: https://docs.krita.org/en/reference_manual/brushes/brush_settings/tablet_sensors.html
- Opacity vs Flow: https://docs.krita.org/en/reference_manual/brushes/brush_settings/opacity_and_flow.html
- Inking: https://docs.krita.org/en/tutorials/inking.html
**Model:** The Pixel brush stamps **dabs** along the stroke; each property (Size, Opacity, Flow,
Rotation, …) is driven by a **sensor** through an editable **response curve** (x = sensor 0..1 →
y = output multiplier 0..1).
**Sensors & ranges:** Pressure 0..1 (PressureIn = ratchet, ignores decreasing pressure);
Speed 0..1; Tilt-elevation 0°(flat)..90°(vertical); Tilt-direction 180°..+180° (azimuth);
Rotation; Fade (over brush-size lengths); Distance (px); Time (s).
**Opacity vs Flow** (multiply together since 4.2): Opacity = whole-stroke transparency
(clamped per stroke in *Wash* mode); Flow = per-dab transparency (in *Build-up* mode overlapping
dabs accumulate). Ink wants Flow=1 / Opacity=1 (solid); marker wants Flow≈0.5 build-up.
| Property | **Fountain pen** | **Ballpoint** |
|-----------------------|---------------------------------------------------|--------------------------------------------|
| Size sensor | Pressure (+ optional Tilt-elevation) | Pressure |
| Size curve | concave / ease-in, **γ ≈ 2 (≈ p²)** | nearly flat (constant) |
| Size output range | **0.15 → 1.0** of nominal | **0.90 → 1.0** (barely varies) |
| Opacity sensor | Pressure | Pressure |
| Opacity curve | slight concave γ ≈ 1.5 (or constant) | **linear γ ≈ 1**, range **0.6 → 1.0** |
| Flow | 1.0 | 1.0 |
| Tilt usage | Tilt-elevation → broaden Size; Tilt-direction → tip Rotation (calligraphic) | none |
| Net character | **strong pressure→width**, near-opaque, calligraphic edge | **near-constant width**, pressure→**opacity** (the ballpoint "tell") |
Optional ballpoint nicety: Speed→Opacity (faster = slightly lighter, mimics ink skipping).
---
## 4. perfect_freehand option sets + where it is insufficient
### What perfect_freehand actually computes (so the knobs are unambiguous)
**Source:** `getStrokeRadius.ts` — https://github.com/steveruizok/perfect-freehand
Per-point radius:
```
radius = size * easing( 0.5 - thinning * (0.5 - pressure) )
```
Default easing = identity (linear). Therefore:
- `p = 0 → radius = size * (0.5 - 0.5·thinning)`
- `p = 1 → radius = size * (0.5 + 0.5·thinning)`
- `p = 0.5 → radius = size * 0.5` (always)
⇒ PF's pressure→width is **strictly LINEAR** (rnote `Linear`), symmetric about `0.5·size`,
slope set by `thinning ∈ [-1,1]`. `size` = **diameter**. `streamline ∈ [0,1]` = EMA low-pass on
input positions. `smoothing ∈ [0,1]` = corner-softening on the **outline** polygon (not the
centerline). `simulatePressure:true` fakes pressure from velocity (slower = thicker).
**perfect_freehand Dart defaults:** `size=16, thinning=0.5, smoothing=0.5, streamline=0.5,
simulatePressure=true, isComplete=true, start.cap=true, end.cap=true, taperEnabled=false`.
Source: https://pub.dev/packages/perfect_freehand
### Getting rnote's quadratic out of PF: pre-warp the per-point pressure
PF is linear internally, but feed it warped pressure and the *width* curve becomes whatever you
want — **no custom polygon needed** for width-only brushes:
```dart
double warpPressure(double p, PressureMode m) => switch (m) {
PressureMode.linear => p,
PressureMode.quadratic => p * p, // rnote Pow2 — fountain pen
PressureMode.cubic => p * p * p, // rnote Pow3
PressureMode.sqrt => math.sqrt(p), // firm pen / pencil
};
// points.add(PointVector(x, y, warpPressure(rawPressure, mode)));
// StrokeOptions(thinning: ~0.9, simulatePressure: false);
```
### Where perfect_freehand is INSUFFICIENT → custom variable-width polygon (rnote-style)
| Need | PF enough? |
|---------------------------------------------------|-----------------------------------------------------|
| Linear pressure → width | ✅ via `thinning` |
| Quadratic / Sqrt pressure → width | ⚠️ pressure pre-warp (above), `thinning≈0.9`, `simulatePressure:false` |
| **Tilt → width or tip rotation** (calligraphy) | ❌ **custom polygon**: per point `w=f(pressure,tilt)`, normal `n=perp(tangent)`, emit `P ± n·w/2`, triangulate (rnote-style left/right offsetting) |
| Pressure → **opacity** (ballpoint/pencil/marker) | ❌ PF is geometry-only — render with per-segment / per-stroke alpha yourself |
| True spring-mass smoothing + latency prediction | ❌ PF `streamline` is only an EMA — port ink-stroke-modeler (§2b) or use §2a first |
| Per-point opacity along one stroke | ❌ split into short sub-strokes by pressure band, paint each with its own alpha |
### Concrete per-brush option sets (logical-px diameters; scale by zoom)
**Fountain pen** — strong pressure→width, soft taper, solid ink:
```dart
// per-point pressure pre-warped to p² (quadratic)
StrokeOptions(
size: 6.0, // tune 48
thinning: 0.9, // wide dynamic range
smoothing: 0.55,
streamline: 0.45, // smooth but responsive
simulatePressure: false,
start: StrokeEndOptions.start(taperEnabled: true, cap: true),
end: StrokeEndOptions.end(taperEnabled: true, cap: true),
);
// opacity = 1.0 (solid). Add Tilt → custom polygon only if you want calligraphic edge.
```
**Ballpoint** — near-constant width, pressure → opacity:
```dart
// raw per-point pressure (NOT warped); used for OPACITY, not width
StrokeOptions(
size: 2.2, // thin, fixed
thinning: 0.15, // almost no width variation
smoothing: 0.5,
streamline: 0.55, // ballpoints glide
simulatePressure: false,
);
// opacity = 0.55 + 0.45 * pressureAvg (per-stroke; or per-segment sub-strokes by pressure band)
```
**Highlighter** — flat width, translucent, build-up, blunt caps:
```dart
StrokeOptions(
size: 22.0, // broad
thinning: 0.0, // constant width
smoothing: 0.4,
streamline: 0.5,
simulatePressure: false,
start: StrokeEndOptions.start(cap: false), // square ends
end: StrokeEndOptions.end(cap: false),
);
// Paint: BlendMode.multiply (or .darken), color.withOpacity(0.35).
// Draw the WHOLE stroke once on pointer-up so self-overlap doesn't darken (Krita "Wash");
// cross-stroke overlap darkens via multiply = real marker.
```
**Pencil** — slight width + opacity from pressure, grainy:
```dart
// per-point pressure pre-warped to sqrt(p) (firm, quick-darkening)
StrokeOptions(
size: 3.0,
thinning: 0.5, // moderate width range
smoothing: 0.5,
streamline: 0.4, // scratchy -> less smoothing
simulatePressure: false, // if no real stylus pressure, set true for velocity-thinning
);
// opacity = 0.35 + 0.55 * pressure
// overlay a paper-noise texture via BlendMode.multiply for graphite grain (PF can't do texture)
```
### Krita-compatible, extensible brush model (recommended struct)
Mirror Krita's sensor→curve design, then translate to PF + your own opacity/compositing layer:
```
BrushProfile {
sizeBase, sizeSensor (pressure/tilt/speed), sizeCurve (power-law exponent or LUT), sizeRange (min,max),
opacitySensor, opacityCurve, opacityRange, flow,
tiltToWidth, tiltToRotation, // any of these -> custom variable-width polygon path
smoothingMode (catmullRom §2a | spring §2b),
pfThinning, pfStreamline, pfSmoothing, cap, taper, pressureWarp
}
```
- Width = `sizeBase · curve(sizeSensor)`; `curve` = power law for exact Krita/rnote parity (`p^n`).
- Width-only brushes (fountain/ballpoint/highlighter/pencil) go through PF via pressure pre-warp.
- Any brush with `tiltToWidth`/`tiltToRotation` switches to the custom variable-width polygon renderer.
- Opacity/flow are ALWAYS handled by your compositing layer, never by PF.
---
## Sources
- rnote `PressureCurve` + `apply`: `crates/rnote-compose/src/style/mod.rs` — https://github.com/flxzt/rnote
- rnote `Segment`: `crates/rnote-compose/src/penpath/segment.rs`
- rnote Catmull-Rom curved builder: `builders/penpathcurvedbuilder.rs`,
`shapes/cubbez.rs::new_w_catmull_rom` (`cp = P ± Δ/(6·tension)`, tension = 1.0)
- rnote modeled builder: `builders/penpathmodeledbuilder.rs` (wraps ink-stroke-modeler-rs)
- Google ink-stroke-modeler params (spring_mass = 11/32400, drag = 72.0, …):
https://github.com/google/ink-stroke-modeler/blob/main/ink_stroke_modeler/params.h and `params.cc`
- perfect-freehand radius `size·easing(0.5 thinning·(0.5 pressure))`: `getStrokeRadius.ts` —
https://github.com/steveruizok/perfect-freehand
- perfect_freehand Dart defaults: https://pub.dev/packages/perfect_freehand
- Krita sensors: https://docs.krita.org/en/reference_manual/brushes/brush_settings/tablet_sensors.html
- Krita opacity vs flow: https://docs.krita.org/en/reference_manual/brushes/brush_settings/opacity_and_flow.html
- Krita inking: https://docs.krita.org/en/tutorials/inking.html

View File

@@ -1,166 +1,13 @@
// integration_test/coordinate_assertion_test.dart // Coordinate gate previously used spike_editor_pane (retired).
// // Re-run on Surface via docs/plans/surface-diagnostic-checklist.md
// M1 MUST #2 (plan §2.1 / §10): a marker painted at normalized (0.5, 0.5) on a // once a PenEditorScreen-based harness is restored.
// PDF page MUST land at the visual page-center pixel across 3 zoom levels (fit,
// 2×, 4×). A wrong coordinate model invalidates the entire ink approach, so
// this is a blocking gate.
//
// RUN (on a device/desktop with a display + working pdfium):
// flutter test integration_test/coordinate_assertion_test.dart
// or, on the Windows tablet via a driver:
// flutter drive --driver=test_driver/integration_test.dart \
// --target=integration_test/coordinate_assertion_test.dart
//
// HEADLESS-LINUX NOTE: pdfium must render off-screen for the page layout to
// resolve. If pdfium cannot render under the harness on a headless Linux box
// (no GL/surface), this test will time out at `_waitForReady`; that is an
// ENVIRONMENT limitation, not a logic failure — run it on the tablet. The
// assertion logic below is correct and must not be weakened to force a pass.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:syncfusion_flutter_pdf/pdf.dart' as sf;
import 'package:badnote/editor/pdf/spike_editor_pane.dart';
void main() { void main() {
// Standard integration binding. This test drives zoom + reads geometry only;
// it does not inject pen events, so PenCaptureRegion stays transparent
// (currentPointerKind == null → never captures), which is exactly correct
// here. (Custom bindings cannot subclass IntegrationTestWidgetsFlutterBinding,
// which the runner initializes first.)
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
pdfrxFlutterInitialize();
late File pdfFile; testWidgets('coordinate_assertion retired with spike pane', (tester) async {
}, skip: true);
setUpAll(() async {
pdfFile = await _writeTinyPdf();
});
tearDownAll(() async {
if (await pdfFile.exists()) await pdfFile.delete();
});
testWidgets('marker at normalized (0.5,0.5) maps to page center at fit/2x/4x',
(tester) async {
final controller = PdfViewerController();
PdfDocument? readyDoc;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SpikeEditorPane(
pdfPath: pdfFile.path,
controller: controller,
onViewerReady: (doc, _) => readyDoc = doc,
),
),
),
);
// Wait for pdfrx to load + lay out the page.
final ready = await _waitForReady(tester, controller);
if (!ready) {
fail(
'pdfrx did not become ready (page layout unavailable). This is almost '
'certainly the headless-Linux pdfium limitation described in the file '
'header — run on the Windows tablet:\n'
' flutter drive --driver=test_driver/integration_test.dart '
'--target=integration_test/coordinate_assertion_test.dart',
);
}
expect(readyDoc, isNotNull);
// The page-center in DOCUMENT space is the layout rect center of page 1.
final pageRect = controller.layout.pageLayouts.first;
final pageCenterDoc = pageRect.center;
Future<void> assertCenterAtCurrentZoom(String label) async {
await tester.pumpAndSettle();
// Project the page-center document point to viewer-local (== screen,
// since the viewer fills the Scaffold body) coordinates.
final localCenter = controller.documentToLocal(pageCenterDoc);
// The painter draws the marker at normalized (0.5,0.5) of the page, i.e.
// exactly pageCenterDoc. So localCenter is where the marker pixel must be.
// Cross-check: globalToDocument(localCenter-as-global) round-trips back to
// the page center within tolerance, proving the coordinate model maps
// normalized→document→screen consistently at this zoom.
final box = tester.renderObject<RenderBox>(
find.byType(SpikeEditorPane),
);
final globalCenter = box.localToGlobal(localCenter);
final roundTripDoc = controller.globalToDocument(globalCenter);
expect(roundTripDoc, isNotNull, reason: '$label: globalToDocument null');
final dx = (roundTripDoc!.dx - pageCenterDoc.dx).abs();
final dy = (roundTripDoc.dy - pageCenterDoc.dy).abs();
// Tolerance: 1 document unit (sub-pixel at these zooms).
expect(dx, lessThan(1.0),
reason: '$label: x off by $dx doc units (zoom=${controller.currentZoom})');
expect(dy, lessThan(1.0),
reason: '$label: y off by $dy doc units (zoom=${controller.currentZoom})');
}
// --- fit ---
await controller.goTo(
controller.calcMatrixForPage(pageNumber: 1, anchor: PdfPageAnchor.all),
duration: Duration.zero,
);
await assertCenterAtCurrentZoom('fit');
final fitZoom = controller.currentZoom;
// --- 2x (relative to fit) ---
await controller.setZoom(pageCenterDoc, fitZoom * 2, duration: Duration.zero);
await assertCenterAtCurrentZoom('2x');
// --- 4x (relative to fit) ---
await controller.setZoom(pageCenterDoc, fitZoom * 4, duration: Duration.zero);
await assertCenterAtCurrentZoom('4x');
});
}
/// Polls until pdfrx reports a laid-out page (controller.isReady + a page rect),
/// or the timeout elapses. Returns whether it became ready.
Future<bool> _waitForReady(
WidgetTester tester,
PdfViewerController controller, {
Duration timeout = const Duration(seconds: 20),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 100));
if (controller.isReady && controller.layout.pageLayouts.isNotEmpty) {
return true;
}
}
return false;
}
/// Writes a tiny single-page A4 PDF (with a faint border so the page box is
/// non-blank) to a temp file using syncfusion_flutter_pdf (already a dependency).
Future<File> _writeTinyPdf() async {
final doc = sf.PdfDocument();
final page = doc.pages.add();
final size = page.getClientSize();
page.graphics.drawRectangle(
pen: sf.PdfPen(sf.PdfColor(0, 0, 0)),
bounds: Rect.fromLTWH(2, 2, size.width - 4, size.height - 4),
);
page.graphics.drawString(
'M1 coord test',
sf.PdfStandardFont(sf.PdfFontFamily.helvetica, 18),
bounds: Rect.fromLTWH(20, 20, size.width - 40, 40),
);
final bytes = await doc.save();
doc.dispose();
final file = File(
'${Directory.systemTemp.path}/badnote_m1_coord_${DateTime.now().microsecondsSinceEpoch}.pdf',
);
await file.writeAsBytes(bytes, flush: true);
return file;
} }

View File

@@ -1,245 +1,16 @@
// integration_test/perf_scroll_bench.dart // Spike-based bench retired with own-canvas architecture.
// // See docs/plans/surface-diagnostic-checklist.md for device gates.
// M1 MUST #4 / MUST #5 harness (plan §7.1 / §10). // Replacement bench will target PenEditorScreen + PageTileCache.
//
// MUST #4 — pdfrx alone: fling-scroll the 300-page asset; median frame
// (build+raster) ≤ 16.6ms, p95 ≤ 22ms.
// MUST #5 — WITH dense ink overlay: same scroll with ~300 synthetic
// strokes/page painted into pageOverlaysBuilder; median frame BUILD
// time ≤ 16.6ms.
//
// Sample protocol (§7.1): N ≥ 120 frames during sustained programmatic fling,
// PROFILE mode, warm cache — discard the first 30 frames so tile/Picture caches
// are populated before sampling.
//
// RUN (profile mode, on the Windows tablet or a desktop with a display):
// flutter test --profile integration_test/perf_scroll_bench.dart
// or via the driver for on-device profiling:
// flutter drive --profile \
// --driver=test_driver/integration_test.dart \
// --target=integration_test/perf_scroll_bench.dart
//
// NOTE: results in `flutter test` (debug/headless) are NOT representative —
// always read the numbers from a PROFILE run on the target device. On a
// headless Linux box pdfium may fail to render; if so the bench prints a clear
// skip and must be run on the tablet (see coordinate_assertion_test header).
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:badnote/editor/pdf/spike_editor_pane.dart';
const String _kPdfPath = 'test/assets/large_300p.pdf';
const String _kDenseStrokesAsset = 'test/assets/dense_strokes.json';
/// Frames to sample after warm-up.
const int _kSampleFrames = 120;
/// Frames to discard before sampling (cache warm-up, §7.1).
const int _kWarmupFrames = 30;
void main() { void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// Report raw frame timings to the device lab / driver too.
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
pdfrxFlutterInitialize();
testWidgets('MUST #4/#5 fling-scroll frame-timing bench', (tester) async { testWidgets('perf_scroll_bench retired — use Surface diagnostic checklist',
final pdf = File(_kPdfPath); (tester) async {
if (!pdf.existsSync()) { // ignore: avoid_print
stdout.writeln('SKIP: $_kPdfPath not found — run tool/gen_bench_pdf.dart.'); print('SKIP: spike_editor_pane deleted; run Surface checklist instead.');
return; }, skip: true);
}
// ---- MUST #4: pdfrx alone ----
final r4 = await _runScrollPass(
tester,
label: 'MUST #4 — pdfrx alone (no ink overlay)',
inkLoad: false,
);
// ---- MUST #5: WITH dense ink overlay ----
final r5 = await _runScrollPass(
tester,
label: 'MUST #5 — WITH dense ink overlay (~300 strokes/page)',
inkLoad: true,
);
if (r4 == null || r5 == null) {
stdout.writeln(
'\n=== PERF BENCH SKIPPED ===\n'
'pdfrx did not become ready (headless pdfium limitation). Run on the '
'Windows tablet in profile mode:\n'
' flutter drive --profile '
'--driver=test_driver/integration_test.dart '
'--target=integration_test/perf_scroll_bench.dart\n',
);
return;
}
_printReport('MUST #4', r4, buildOnlyGate: false);
_printReport('MUST #5', r5, buildOnlyGate: true);
});
} }
class _Stats {
_Stats(this.label, this.build, this.raster, this.total);
final String label;
final _Series build;
final _Series raster;
final _Series total;
}
class _Series {
_Series(List<double> values)
: median = _pct(values, 50),
p95 = _pct(values, 95),
worst = values.isEmpty ? 0 : (List<double>.from(values)..sort()).last,
jankFrames = values.where((v) => v > 32.0).length,
n = values.length;
final double median;
final double p95;
final double worst;
final int jankFrames;
final int n;
static double _pct(List<double> v, int p) {
if (v.isEmpty) return 0;
final s = List<double>.from(v)..sort();
final i = ((p / 100.0) * (s.length - 1)).round();
return s[i.clamp(0, s.length - 1)];
}
}
/// Pumps the spike pane, warms up, then drives a sustained fling while
/// collecting FrameTiming. Returns null if pdfrx never became ready.
Future<_Stats?> _runScrollPass(
WidgetTester tester, {
required String label,
required bool inkLoad,
}) async {
final controller = PdfViewerController();
final paneKey = GlobalKey<SpikeEditorPaneState>();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SpikeEditorPane(
key: paneKey,
pdfPath: _kPdfPath,
controller: controller,
denseStrokesAsset: _kDenseStrokesAsset,
),
),
),
);
// Wait for the document to lay out.
final deadline = DateTime.now().add(const Duration(seconds: 20));
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 100));
if (controller.isReady && controller.layout.pageLayouts.isNotEmpty) break;
}
if (!controller.isReady || controller.layout.pageLayouts.isEmpty) {
return null;
}
if (inkLoad) {
await paneKey.currentState!.setInkLoad(true);
await tester.pumpAndSettle();
}
// Collect frame timings.
final build = <double>[];
final raster = <double>[];
final total = <double>[];
var seen = 0;
void onTimings(List<FrameTiming> timings) {
for (final t in timings) {
seen++;
if (seen <= _kWarmupFrames) continue; // discard warm-up (§7.1)
if (build.length >= _kSampleFrames) continue;
build.add(t.buildDuration.inMicroseconds / 1000.0);
raster.add(t.rasterDuration.inMicroseconds / 1000.0);
total.add(t.totalSpan.inMicroseconds / 1000.0);
}
}
SchedulerBinding.instance.addTimingsCallback(onTimings);
try {
// Sustained fling: repeated downward flings across the viewport center to
// keep the document scrolling continuously while we gather ≥150 frames.
final center = tester.getCenter(find.byType(SpikeEditorPane));
var safety = 0;
while (build.length < _kSampleFrames && safety < 400) {
await tester.fling(
find.byType(SpikeEditorPane),
const Offset(0, -600),
2000,
warnIfMissed: false,
);
// Pump several frames to let the fling settle and emit timings.
for (var i = 0; i < 20 && build.length < _kSampleFrames; i++) {
await tester.pump(const Duration(milliseconds: 16));
}
// Nudge back up occasionally so we don't run off the end of 300 pages.
if (safety % 8 == 7) {
await tester.fling(find.byType(SpikeEditorPane),
const Offset(0, 1200), 2000, warnIfMissed: false);
await tester.pump(const Duration(milliseconds: 16));
}
safety++;
// Keep `center` referenced (avoids unused warning) and re-target if needed.
if (!tester.binding.hasScheduledFrame && center.dy < 0) break;
}
} finally {
SchedulerBinding.instance.removeTimingsCallback(onTimings);
}
return _Stats(
label,
_Series(build),
_Series(raster),
_Series(total),
);
}
void _printReport(String tag, _Stats s, {required bool buildOnlyGate}) {
final buf = StringBuffer();
buf.writeln('\n========================================================');
buf.writeln('$tag${s.label}');
buf.writeln('Protocol (§7.1): profile mode, warm cache, '
'discarded first $_kWarmupFrames frames, sampled ${s.build.n} frames.');
buf.writeln('--------------------------------------------------------');
buf.writeln('phase median p95 worst jank(>32ms)');
buf.writeln('build ${_row(s.build)}');
buf.writeln('raster ${_row(s.raster)}');
buf.writeln('total ${_row(s.total)}');
buf.writeln('--------------------------------------------------------');
if (buildOnlyGate) {
final pass = s.build.median <= 16.6;
buf.writeln('GATE (MUST #5): build median ${s.build.median.toStringAsFixed(2)}ms '
'≤ 16.6ms -> ${pass ? "PASS" : "FAIL"}');
} else {
final passMed = s.total.median <= 16.6;
final passP95 = s.total.p95 <= 22.0;
buf.writeln('GATE (MUST #4): build+raster median '
'${s.total.median.toStringAsFixed(2)}ms ≤ 16.6ms -> '
'${passMed ? "PASS" : "FAIL"}; '
'p95 ${s.total.p95.toStringAsFixed(2)}ms ≤ 22ms -> '
'${passP95 ? "PASS" : "FAIL"}');
}
buf.writeln('========================================================\n');
stdout.write(buf.toString());
}
String _row(_Series s) =>
'${s.median.toStringAsFixed(2).padLeft(7)}ms '
'${s.p95.toStringAsFixed(2).padLeft(6)}ms '
'${s.worst.toStringAsFixed(2).padLeft(6)}ms '
'${s.jankFrames.toString().padLeft(6)}';

7
l10n.yaml Normal file
View File

@@ -0,0 +1,7 @@
# Flutter gen-l10n config. Generates AppLocalizations from the ARB files in
# lib/l10n. `flutter pub get` / build runs the generator (pubspec `generate: true`).
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
nullable-getter: false

View File

@@ -0,0 +1,164 @@
// Global structured logging bus for BadNote.
//
// Always-on (unlike the old PDF-only DiagnosticLogger opt-in). Writes NDJSON
// lines to a rotating session file under the app documents directory so a
// Surface user can export a diagnostic pack without attaching a debugger.
import 'dart:async';
import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
enum LogLevel { trace, debug, info, warn, error }
/// Known subsystems — keep the set small so filters stay useful.
abstract final class LogSubsystem {
static const shell = 'shell';
static const ink = 'ink';
static const arbiter = 'arbiter';
static const penNative = 'pen_native';
static const pdf = 'pdf';
static const office = 'office';
static const board = 'board';
static const sync = 'sync';
static const diag = 'diag';
static const frame = 'frame';
}
class BadNoteLog {
BadNoteLog._();
static final BadNoteLog instance = BadNoteLog._();
final String sessionId = const Uuid().v4();
final List<Map<String, Object?>> _ring = <Map<String, Object?>>[];
static const int _ringCap = 4000;
File? _file;
Directory? _dir;
Timer? _flushTimer;
final List<String> _pending = <String>[];
bool _started = false;
LogLevel minLevel = LogLevel.debug;
/// Absolute path of the current session log, once [start] succeeds.
String? get path => _file?.path;
Directory? get directory => _dir;
Future<void> start() async {
if (_started) return;
_started = true;
try {
Directory base;
try {
base = await getApplicationDocumentsDirectory();
} catch (_) {
base = await getTemporaryDirectory();
}
_dir = Directory(
'${base.path}${Platform.pathSeparator}badnote_diagnostics',
);
if (!await _dir!.exists()) {
await _dir!.create(recursive: true);
}
final stamp = DateTime.now()
.toIso8601String()
.replaceAll(':', '-')
.replaceAll('.', '-');
_file = File(
'${_dir!.path}${Platform.pathSeparator}session_$stamp.ndjson',
);
await _file!.writeAsString(
'${jsonEncode({
'ts': DateTime.now().toIso8601String(),
'level': 'info',
'subsystem': LogSubsystem.diag,
'msg': 'session_start',
'sessionId': sessionId,
'platform': Platform.operatingSystem,
'osVersion': Platform.operatingSystemVersion,
})}\n',
flush: true,
);
_flushTimer = Timer.periodic(const Duration(seconds: 1), (_) => _flush());
info(LogSubsystem.diag, 'log file ready', fields: {'path': _file!.path});
} catch (e) {
// Logging must never crash the app.
developer.log('BadNoteLog start failed: $e', name: 'badnote');
}
}
void trace(String subsystem, String msg, {Map<String, Object?>? fields}) =>
_emit(LogLevel.trace, subsystem, msg, fields);
void debug(String subsystem, String msg, {Map<String, Object?>? fields}) =>
_emit(LogLevel.debug, subsystem, msg, fields);
void info(String subsystem, String msg, {Map<String, Object?>? fields}) =>
_emit(LogLevel.info, subsystem, msg, fields);
void warn(String subsystem, String msg, {Map<String, Object?>? fields}) =>
_emit(LogLevel.warn, subsystem, msg, fields);
void error(String subsystem, String msg, {Map<String, Object?>? fields}) =>
_emit(LogLevel.error, subsystem, msg, fields);
void _emit(
LogLevel level,
String subsystem,
String msg,
Map<String, Object?>? fields,
) {
if (level.index < minLevel.index) return;
final entry = <String, Object?>{
'ts': DateTime.now().toIso8601String(),
'level': level.name,
'subsystem': subsystem,
'msg': msg,
'sessionId': sessionId,
if (fields != null) ...fields,
};
_ring.add(entry);
if (_ring.length > _ringCap) {
_ring.removeRange(0, _ring.length - _ringCap);
}
final line = jsonEncode(entry);
developer.log(line, name: 'badnote.$subsystem');
if (_file != null) {
_pending.add(line);
if (_pending.length >= 200) {
unawaited(_flush());
}
}
}
Future<void> _flush() async {
final file = _file;
if (file == null || _pending.isEmpty) return;
final chunk = '${_pending.join('\n')}\n';
_pending.clear();
try {
await file.writeAsString(chunk, mode: FileMode.append, flush: true);
} catch (_) {}
}
/// Snapshot of the in-memory ring (newest last).
List<Map<String, Object?>> snapshotRing() =>
List<Map<String, Object?>>.unmodifiable(_ring);
Future<void> flush() => _flush();
Future<void> stop() async {
_flushTimer?.cancel();
_flushTimer = null;
await _flush();
}
}
/// Bridge for legacy call sites that still use plain strings.
void logLegacyInputLine(String line) {
BadNoteLog.instance.debug(LogSubsystem.penNative, line);
}

View File

@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'badnote_log.dart';
import 'diagnostic_export.dart';
import '../editor/canvas/input_diagnostics.dart';
import '../editor/input/diagnostic_logger.dart';
import '../editor/input/pen_input_service.dart';
import '../l10n/app_localizations.dart';
/// Shared diagnostics chrome: overlay readout + export action.
/// Mount on any document surface (note / PDF / PPT / board).
class DiagnosticChrome extends StatefulWidget {
const DiagnosticChrome({
super.key,
required this.child,
this.initiallyVisible = false,
});
final Widget child;
final bool initiallyVisible;
@override
State<DiagnosticChrome> createState() => DiagnosticChromeState();
}
class DiagnosticChromeState extends State<DiagnosticChrome> {
late bool _visible = widget.initiallyVisible;
bool _exporting = false;
String? _lastExportPath;
bool get isVisible => _visible;
void toggle() {
setState(() {
_visible = !_visible;
if (_visible) {
DiagnosticLogger.instance.start();
InputDiagnostics.instance.reset();
} else {
DiagnosticLogger.instance.stop();
}
});
}
Future<void> exportPack() async {
if (_exporting) return;
setState(() => _exporting = true);
try {
final result = await DiagnosticExport.instance.exportPack();
if (!mounted) return;
setState(() => _lastExportPath = result.zipPath);
await Clipboard.setData(ClipboardData(text: result.zipPath));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).diagExported(result.bytes),
),
duration: const Duration(seconds: 5),
),
);
} catch (e) {
BadNoteLog.instance.error(LogSubsystem.diag, 'export_failed', fields: {
'error': '$e',
});
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).diagExportFail('$e')),
),
);
} finally {
if (mounted) setState(() => _exporting = false);
}
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
widget.child,
if (_visible)
Positioned(
left: 8,
right: 8,
bottom: 8,
child: Material(
elevation: 6,
borderRadius: BorderRadius.circular(8),
color: Colors.black.withValues(alpha: 0.82),
child: Padding(
padding: const EdgeInsets.all(10),
child: DefaultTextStyle(
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontFamily: 'monospace',
height: 1.35,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ListenableBuilder(
listenable: InputDiagnostics.instance,
builder: (context, _) {
return Text(
'${InputDiagnostics.instance.summary()}\n'
'${PenInputService.instance.debugSummary}\n'
'log: ${BadNoteLog.instance.path ?? "(starting…)"}\n'
'session: ${BadNoteLog.instance.sessionId}'
'${_lastExportPath != null ? "\nlast zip: $_lastExportPath" : ""}',
);
},
),
const SizedBox(height: 8),
Row(
children: [
TextButton(
onPressed: () => InputDiagnostics.instance.reset(),
child: const Text('Reset',
style: TextStyle(color: Colors.white70)),
),
TextButton(
onPressed: _exporting ? null : exportPack,
child: Text(
_exporting ? 'Exporting…' : 'Export pack',
style: const TextStyle(color: Colors.lightGreenAccent),
),
),
TextButton(
onPressed: toggle,
child: const Text('Hide',
style: TextStyle(color: Colors.white54)),
),
],
),
],
),
),
),
),
),
],
);
}
}
/// Compact icon button for app bars / toolbars.
class DiagnosticToggleButton extends StatelessWidget {
const DiagnosticToggleButton({
super.key,
required this.onToggle,
required this.onExport,
});
final VoidCallback onToggle;
final VoidCallback onExport;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
tooltip: 'Diagnostics',
icon: const Icon(Icons.bug_report_outlined),
onSelected: (v) {
if (v == 'toggle') onToggle();
if (v == 'export') onExport();
},
itemBuilder: (context) => const [
PopupMenuItem(value: 'toggle', child: Text('Toggle overlay')),
PopupMenuItem(value: 'export', child: Text('Export diagnostic pack')),
],
);
}
}

View File

@@ -0,0 +1,154 @@
// Build a zip diagnostic pack the user can hand back for remote debugging.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../editor/canvas/input_diagnostics.dart';
import '../editor/input/pen_input_service.dart';
import 'badnote_log.dart';
import 'frame_sampler.dart';
import 'pen_event_ring.dart';
/// Injected at build/export time so Surface packages can be matched to git.
/// Override via `--dart-define=BADNOTE_GIT_SHA=...` in CI.
const String kBadNoteGitSha = String.fromEnvironment(
'BADNOTE_GIT_SHA',
defaultValue: 'dev',
);
const String kBadNoteBuildTime = String.fromEnvironment(
'BADNOTE_BUILD_TIME',
defaultValue: '',
);
class DiagnosticExportResult {
DiagnosticExportResult({required this.zipPath, required this.bytes});
final String zipPath;
final int bytes;
}
class DiagnosticExport {
DiagnosticExport._();
static final DiagnosticExport instance = DiagnosticExport._();
/// Flush logs and write a zip under Documents/badnote_diagnostics/.
Future<DiagnosticExportResult> exportPack({
Duration penWindow = const Duration(minutes: 5),
}) async {
final log = BadNoteLog.instance;
await log.flush();
final meta = <String, Object?>{
'exportedAt': DateTime.now().toIso8601String(),
'sessionId': log.sessionId,
'gitSha': kBadNoteGitSha,
'buildTime': kBadNoteBuildTime.isEmpty ? null : kBadNoteBuildTime,
'platform': Platform.operatingSystem,
'osVersion': Platform.operatingSystemVersion,
'localHostname': Platform.localHostname,
'numberOfProcessors': Platform.numberOfProcessors,
'flutter': {
'foundationDebug': kDebugMode,
'foundationProfile': kProfileMode,
'foundationRelease': kReleaseMode,
},
'penNative': PenInputService.instance.debugSummary,
'penActive': PenInputService.instance.isActive,
'zoom': InputDiagnostics.instance.summary(),
'frames': FrameSampler.instance.summary(),
'instructions':
'Reproduce the issue for ~3 minutes with diagnostics on, then share this zip. '
'Confirm meta.gitSha matches the CI commit you installed.',
};
final archive = Archive();
void addText(String name, String body) {
final bytes = utf8.encode(body);
archive.addFile(ArchiveFile(name, bytes.length, bytes));
}
addText('meta.json', const JsonEncoder.withIndent(' ').convert(meta));
addText(
'pen_events.json',
const JsonEncoder.withIndent(' ').convert(
PenEventRing.instance.toJsonList(window: penWindow),
),
);
addText(
'frame_samples.json',
const JsonEncoder.withIndent(' ').convert(FrameSampler.instance.toJsonList()),
);
addText(
'log_ring.json',
const JsonEncoder.withIndent(' ').convert(log.snapshotRing()),
);
// Include on-disk session NDJSON if present.
final sessionPath = log.path;
if (sessionPath != null) {
try {
final f = File(sessionPath);
if (await f.exists()) {
final bytes = await f.readAsBytes();
archive.addFile(
ArchiveFile('session.ndjson', bytes.length, bytes),
);
}
} catch (_) {}
}
// Legacy input log if it exists alongside.
try {
Directory dir;
try {
dir = await getApplicationDocumentsDirectory();
} catch (_) {
dir = await getTemporaryDirectory();
}
final legacy = File(
'${dir.path}${Platform.pathSeparator}badnote_input_log.txt',
);
if (await legacy.exists()) {
final bytes = await legacy.readAsBytes();
archive.addFile(
ArchiveFile('legacy_input_log.txt', bytes.length, bytes),
);
}
} catch (_) {}
final encoded = ZipEncoder().encode(archive);
if (encoded.isEmpty) {
throw StateError('Failed to encode diagnostic zip');
}
Directory outDir = log.directory ??
Directory(
'${(await getApplicationDocumentsDirectory()).path}'
'${Platform.pathSeparator}badnote_diagnostics',
);
if (!await outDir.exists()) {
await outDir.create(recursive: true);
}
final stamp = DateTime.now()
.toIso8601String()
.replaceAll(':', '-')
.replaceAll('.', '-');
final zipPath =
'${outDir.path}${Platform.pathSeparator}badnote_diag_$stamp.zip';
await File(zipPath).writeAsBytes(encoded, flush: true);
BadNoteLog.instance.info(
LogSubsystem.diag,
'export_pack',
fields: {'path': zipPath, 'bytes': encoded.length},
);
return DiagnosticExportResult(zipPath: zipPath, bytes: encoded.length);
}
}

View File

@@ -0,0 +1,99 @@
// Frame / hitch sampler for diagnostic packs.
import 'badnote_log.dart';
class FrameSample {
FrameSample({
required this.at,
required this.label,
required this.ms,
this.dropped = false,
});
final DateTime at;
final String label;
final double ms;
final bool dropped;
Map<String, Object?> toJson() => {
'at': at.toIso8601String(),
'label': label,
'ms': ms,
'dropped': dropped,
};
}
class FrameSampler {
FrameSampler._();
static final FrameSampler instance = FrameSampler._();
static const int capacity = 500;
final List<FrameSample> _samples = <FrameSample>[];
int overBudget = 0;
int total = 0;
/// Budget for a single frame at 60fps.
static const double budgetMs = 16.6;
void record(String label, double ms, {bool dropped = false}) {
total++;
final over = ms > budgetMs;
if (over) overBudget++;
final sample = FrameSample(
at: DateTime.now(),
label: label,
ms: ms,
dropped: dropped || over,
);
_samples.add(sample);
if (_samples.length > capacity) {
_samples.removeRange(0, _samples.length - capacity);
}
if (over || dropped) {
BadNoteLog.instance.warn(
LogSubsystem.frame,
'slow_frame',
fields: {'label': label, 'ms': ms, 'dropped': dropped},
);
}
}
void recordZoom({
required double rawScale,
required bool scaleDrop,
required bool focalDrop,
required double focalJumpPx,
}) {
record(
'zoom',
scaleDrop || focalDrop ? budgetMs + 1 : 8,
dropped: scaleDrop || focalDrop,
);
BadNoteLog.instance.debug(
LogSubsystem.frame,
'zoom',
fields: {
'rawScale': rawScale,
'scaleDrop': scaleDrop,
'focalDrop': focalDrop,
'focalJumpPx': focalJumpPx,
},
);
}
List<Map<String, Object?>> toJsonList() =>
_samples.map((s) => s.toJson()).toList(growable: false);
Map<String, Object?> summary() => {
'total': total,
'overBudget': overBudget,
'budgetMs': budgetMs,
'recent': toJsonList(),
};
void reset() {
_samples.clear();
overBudget = 0;
total = 0;
}
}

View File

@@ -0,0 +1,121 @@
// Rolling ring of recent pen / arbiter events for diagnostic export.
class PenEventRecord {
PenEventRecord({
required this.at,
required this.kind,
required this.pointerId,
this.pressure,
this.tiltX,
this.tiltY,
this.barrel = false,
this.eraser = false,
this.inverted = false,
this.decision,
this.note,
});
final DateTime at;
final String kind; // down|move|up|hw|arbiter
final int pointerId;
final double? pressure;
final double? tiltX;
final double? tiltY;
final bool barrel;
final bool eraser;
final bool inverted;
final String? decision; // draw|pan|reject
final String? note;
Map<String, Object?> toJson() => {
'at': at.toIso8601String(),
'kind': kind,
'pointerId': pointerId,
if (pressure != null) 'pressure': pressure,
if (tiltX != null) 'tiltX': tiltX,
if (tiltY != null) 'tiltY': tiltY,
'barrel': barrel,
'eraser': eraser,
'inverted': inverted,
if (decision != null) 'decision': decision,
if (note != null) 'note': note,
};
}
class PenEventRing {
PenEventRing._();
static final PenEventRing instance = PenEventRing._();
static const int capacity = 2000;
final List<PenEventRecord> _events = <PenEventRecord>[];
void add(PenEventRecord event) {
_events.add(event);
if (_events.length > capacity) {
_events.removeRange(0, _events.length - capacity);
}
}
void recordPointer({
required String kind,
required int pointerId,
required String deviceKind,
double? pressure,
String? decision,
String? note,
}) {
add(PenEventRecord(
at: DateTime.now(),
kind: kind,
pointerId: pointerId,
pressure: pressure,
decision: decision,
note: note ?? deviceKind,
));
}
void recordHardware({
required bool barrel,
required bool eraser,
required bool inverted,
required double tiltX,
required double tiltY,
}) {
add(PenEventRecord(
at: DateTime.now(),
kind: 'hw',
pointerId: -1,
barrel: barrel,
eraser: eraser,
inverted: inverted,
tiltX: tiltX,
tiltY: tiltY,
));
}
void recordArbiter({
required int activeCount,
required String deviceKind,
required bool draw,
required bool fingerDrawing,
}) {
add(PenEventRecord(
at: DateTime.now(),
kind: 'arbiter',
pointerId: -1,
decision: draw ? 'draw' : 'pan',
note: 'count=$activeCount kind=$deviceKind finger=$fingerDrawing',
));
}
List<PenEventRecord> recent({Duration? window}) {
if (window == null) return List.unmodifiable(_events);
final cut = DateTime.now().subtract(window);
return _events.where((e) => e.at.isAfter(cut)).toList(growable: false);
}
List<Map<String, Object?>> toJsonList({Duration? window}) =>
recent(window: window).map((e) => e.toJson()).toList(growable: false);
void clear() => _events.clear();
}

115
lib/editor/board/board.dart Normal file
View File

@@ -0,0 +1,115 @@
// lib/editor/board/board.dart
//
// Infinite-board model (F7 — 便利贴 + 双链). A board is a set of positioned
// cards (sticky notes) in board content coordinates; each card has text that may
// contain [[links]] to other cards, so a board derives a LinkGraph for
// backlinks. Cards also host ink (via a StrokeHost keyed by the card id) — the
// same host-agnostic engine as PDF pages.
//
// Pure, immutable value model (no widgets/storage); fully unit-tested. The board
// canvas + persistence wrap it.
import 'dart:ui' show Offset, Rect, Size;
import 'package:flutter/foundation.dart';
import '../link/link_graph.dart';
/// One sticky-note card on the board.
@immutable
class BoardCard {
const BoardCard({
required this.id,
required this.position,
required this.size,
this.text = '',
});
final String id;
/// Top-left in board content coordinates.
final Offset position;
final Size size;
/// Card body; may contain `[[other-card]]` links.
final String text;
Rect get bounds => position & size;
BoardCard copyWith({Offset? position, Size? size, String? text}) => BoardCard(
id: id,
position: position ?? this.position,
size: size ?? this.size,
text: text ?? this.text,
);
@override
bool operator ==(Object other) =>
other is BoardCard &&
other.id == id &&
other.position == position &&
other.size == size &&
other.text == text;
@override
int get hashCode => Object.hash(id, position, size, text);
}
/// An immutable infinite board: an ordered list of cards with copy-on-write
/// edits. Card ids are unique.
@immutable
class Board {
Board(List<BoardCard> cards) : cards = List<BoardCard>.unmodifiable(cards);
static final Board empty = Board(const []);
final List<BoardCard> cards;
int get length => cards.length;
BoardCard? cardById(String id) {
for (final c in cards) {
if (c.id == id) return c;
}
return null;
}
/// Add a card (throws if [card].id already exists).
Board add(BoardCard card) {
if (cardById(card.id) != null) {
throw ArgumentError('duplicate card id: ${card.id}');
}
return Board([...cards, card]);
}
Board removeById(String id) =>
Board([for (final c in cards) if (c.id != id) c]);
/// Replace card [id] via [update]; no-op if absent.
Board updateCard(String id, BoardCard Function(BoardCard) update) =>
Board([for (final c in cards) if (c.id == id) update(c) else c]);
Board moveCard(String id, Offset position) =>
updateCard(id, (c) => c.copyWith(position: position));
Board setText(String id, String text) =>
updateCard(id, (c) => c.copyWith(text: text));
/// Cards whose bounds overlap [viewport] (board broad-phase culling).
List<BoardCard> cardsIn(Rect viewport) =>
[for (final c in cards) if (c.bounds.overlaps(viewport)) c];
/// Derive the 双链 graph from card texts (card id → its [[links]]).
LinkGraph linkGraph() =>
LinkGraph.fromTexts({for (final c in cards) c.id: c.text});
/// Backlinks to card [id] (ids of cards whose text links to it).
Set<String> backlinksOf(String id) => linkGraph().backlinksOf(id);
@override
bool operator ==(Object other) =>
other is Board && listEquals(other.cards, cards);
@override
int get hashCode => Object.hashAll(cards);
}

View File

@@ -0,0 +1,61 @@
// lib/editor/canvas/editor_tool.dart
//
// The shared tool model for the pen-first editors (PDF, note, slide). Replaces
// the scattered per-editor booleans (`_selectTextMode`, `_placeLinkMode`, the
// old `CanvasTool` pen/highlighter/eraser triad) with ONE active-tool enum so
// every editor reasons about "which tool is active" the same way.
//
// [EditorToolKind] is the core, render-path-independent set shared by all three
// editors. The PDF editor layers TWO extra page-anchored tools on top
// (select-text and place-scratch-link) that the PenCanvas editors don't have —
// those remain editor-local because they ride pdfrx's text layer / the page
// overlay, not the ink capture path. See `selectTextOrLink` note below.
//
// TODO(toolbar-batch-2): bookmark-to-paragraph, search+OCR, templates — later
// batches add kinds here. (The typed-text tool now exists as [EditorToolKind.
// text], PDF-only for now; see the `text` doc below.)
/// The shared inking/editing tools available on every pen-first canvas.
enum EditorToolKind {
/// Freehand drawing with the currently-selected [BrushKind] (fountain pen,
/// ballpoint, or pencil). Each brush carries its own remembered color.
brush,
/// Freehand drawing with the highlighter brush (its own color + flat width).
highlighter,
/// Stroke eraser (partial / whole-stroke per PenConfig).
eraser,
/// Cursor / selection tool: tap a committed stroke to select it, drag the
/// selection to translate it, delete to remove it.
select,
/// Shape tool: pen-drag previews a [ShapeKind] from start→current and commits
/// it as a generated [PenStroke] on release.
shape,
/// Typed-text tool (PDF editor only for now): a pen-tap OR a mouse
/// double-click on a page drops a text box at that normalized point and
/// focuses a real Flutter text field for input (so the OS IME / Windows-Ink
/// handwriting panel works). Committed boxes render glued to the page and are
/// re-editable; an empty box deletes itself on blur.
text,
}
/// The shapes the [EditorToolKind.shape] tool can draw. Each is generated as a
/// plain [PenStroke] (a polyline) so it reuses stroke rendering, persistence,
/// erase, and undo with no new model — see `shape_geometry.dart`.
enum ShapeKind {
/// Straight line: 2 points (start → end).
line,
/// Axis-aligned rectangle: 5-point closed polyline (start corner → end corner).
rectangle,
/// Ellipse inscribed in the start→end bounding box: ~48 sampled points.
ellipse,
/// Arrow: shaft (start → end) plus two arrowhead segments at the end.
arrow,
}

View File

@@ -4,16 +4,27 @@
// coordinates; both painters receive the on-screen page [Size] and scale // coordinates; both painters receive the on-screen page [Size] and scale
// points into pixels at paint time. perfect_freehand produces the outline. // points into pixels at paint time. perfect_freehand produces the outline.
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:perfect_freehand/perfect_freehand.dart' as pf; import 'package:perfect_freehand/perfect_freehand.dart' as pf;
import '../engine/brush.dart';
import '../engine/stroke_geometry.dart'
show freehandOutlinePoints, kDefaultPenThinning;
import 'pen_stroke.dart'; import 'pen_stroke.dart';
/// Builds a filled outline [Path] for one stroke (already scaled to pixels). /// Builds a filled outline [Path] for one stroke (already scaled to pixels).
/// ///
/// [pageSize] maps normalized coords to pixels. [isComplete] should be false /// [pageSize] maps normalized coords to pixels. [isComplete] should be false
/// for the in-progress live stroke so freehand tapers correctly. /// for the in-progress live stroke so freehand tapers correctly. [thinning] is
Path buildStrokePath(PenStroke stroke, Size pageSize, {required bool isComplete}) { /// the pressure→width response (shared default [kDefaultPenThinning]); the
/// SAME value drives the export path so screen and PDF never diverge.
Path buildStrokePath(
PenStroke stroke,
Size pageSize, {
required bool isComplete,
double thinning = kDefaultPenThinning,
}) {
final pixelWidth = stroke.width * pageSize.width; final pixelWidth = stroke.width * pageSize.width;
final hasRealPressure = stroke.points.any((p) => p.pressure != null); final hasRealPressure = stroke.points.any((p) => p.pressure != null);
@@ -21,7 +32,7 @@ Path buildStrokePath(PenStroke stroke, Size pageSize, {required bool isComplete}
final pfPoints = stroke.points final pfPoints = stroke.points
.map( .map(
(p) => pf.Point( (p) => pf.PointVector(
p.x * pageSize.width, p.x * pageSize.width,
p.y * pageSize.height, p.y * pageSize.height,
p.pressure ?? 0.5, p.pressure ?? 0.5,
@@ -29,50 +40,83 @@ Path buildStrokePath(PenStroke stroke, Size pageSize, {required bool isComplete}
) )
.toList(); .toList();
final outline = pf.getStroke( // Route through THE shared recipe (stroke_geometry.freehandOutlinePoints) so
pfPoints, // this PDF-overlay path and the note/slide path can never diverge (R7), and
// resolve the stroke's brush so each brush renders with its own
// thinning/streamline/smoothing/caps (spec §4). Pressure was already
// pre-warped by the brush's gamma at capture, so it is baked into pfPoints.
final outline = freehandOutlinePoints(
pfPoints: pfPoints,
size: pixelWidth, size: pixelWidth,
// Highlighter keeps a constant width (no thinning); pen thins like the isHighlighter: isHighlighter,
// existing ink_canvas (_drawFreehand uses 0.7). hasRealPressure: hasRealPressure,
thinning: isHighlighter ? 0.0 : 0.7,
smoothing: 0.5,
streamline: 0.5,
// Real stylus pressure → don't simulate; no pressure → let freehand fake
// it based on velocity (matches ink_canvas behavior).
simulatePressure: !hasRealPressure && !isHighlighter,
isComplete: isComplete, isComplete: isComplete,
thinning: thinning,
brush: brushProfileFor(stroke.brush),
); );
final path = Path(); final path = Path();
if (outline.isEmpty) return path; if (outline.isEmpty) return path;
path.moveTo(outline.first.x, outline.first.y); path.moveTo(outline.first.dx, outline.first.dy);
for (var i = 1; i < outline.length; i++) { for (var i = 1; i < outline.length; i++) {
path.lineTo(outline[i].x, outline[i].y); path.lineTo(outline[i].dx, outline[i].dy);
} }
path.close(); path.close();
return path; return path;
} }
/// Mean point pressure (`pressure ?? 0.5`) of a [PenStroke], for the per-stroke
/// opacity resolution (spec §3/§4 tie ballpoint/pencil opacity to pressure).
double _avgPressure(PenStroke stroke) {
if (stroke.points.isEmpty) return 0.5;
var sum = 0.0;
for (final p in stroke.points) {
sum += p.pressure ?? 0.5;
}
return sum / stroke.points.length;
}
/// THE single fill [Paint] for a committed/live stroke, with the brush's
/// resolved opacity (multiplied into the color's alpha) and blend mode applied
/// — closes TODO(brush-opacity). Shared by [StaticInkPainter]/[LiveInkPainter]
/// and the PDF overlay painter so both render paths composite identically.
Paint paintForStroke(PenStroke stroke) {
final resolved = resolveStrokePaint(
stroke.brush,
stroke.color,
pressureAvg: _avgPressure(stroke),
);
return Paint()
..color = resolved.color
..blendMode = resolved.blendMode
..style = PaintingStyle.fill
..isAntiAlias = true;
}
/// Paints all committed strokes for the page. Repaints only when the stroke /// Paints all committed strokes for the page. Repaints only when the stroke
/// list identity or page size changes (kept behind a RepaintBoundary). /// list identity or page size changes (kept behind a RepaintBoundary).
class StaticInkPainter extends CustomPainter { class StaticInkPainter extends CustomPainter {
StaticInkPainter({required this.strokes, required this.pageSize}); StaticInkPainter({
required this.strokes,
required this.pageSize,
this.thinning = kDefaultPenThinning,
});
final List<PenStroke> strokes; final List<PenStroke> strokes;
final Size pageSize; final Size pageSize;
/// Pressure→width response shared with the live/export paths.
final double thinning;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
for (final stroke in strokes) { for (final stroke in strokes) {
final path = buildStrokePath(stroke, pageSize, isComplete: true); final path =
buildStrokePath(stroke, pageSize, isComplete: true, thinning: thinning);
if (path.getBounds().isEmpty) continue; if (path.getBounds().isEmpty) continue;
canvas.drawPath( // Single drawPath per stroke ⇒ a highlighter's own self-overlap never
path, // darkens; cross-stroke overlap darkens via BlendMode.multiply (marker).
Paint() canvas.drawPath(path, paintForStroke(stroke));
..color = Color(stroke.color)
..style = PaintingStyle.fill
..isAntiAlias = true,
);
} }
} }
@@ -80,34 +124,180 @@ class StaticInkPainter extends CustomPainter {
bool shouldRepaint(StaticInkPainter old) => bool shouldRepaint(StaticInkPainter old) =>
!identical(old.strokes, strokes) || !identical(old.strokes, strokes) ||
old.strokes.length != strokes.length || old.strokes.length != strokes.length ||
old.pageSize != pageSize; old.pageSize != pageSize ||
old.thinning != thinning;
} }
/// Paints just the in-progress stroke (the live layer), kept behind its own /// Eraser preview: shows the eraser circle and faintly highlights the committed
/// RepaintBoundary so committed strokes don't repaint on every move. /// strokes the eraser would delete, so the user can see what is about to go.
class LiveInkPainter extends CustomPainter { /// Mounted only while the eraser is the active mode and the pen is near the
LiveInkPainter({required this.stroke, required this.pageSize}); /// page; kept behind its own RepaintBoundary so it never dirties the ink layers.
class EraserPreviewPainter extends CustomPainter {
EraserPreviewPainter({
required this.strokes,
required this.cursor,
required this.radius,
required this.aspect,
required this.pageSize,
}) : super(repaint: cursor);
final List<PenStroke> strokes;
/// Eraser center in normalized page coords (null = no preview). A listenable
/// so the painter repaints on cursor moves WITHOUT rebuilding the canvas.
final ValueListenable<PenPoint?> cursor;
/// Eraser radius as a fraction of page width (matches the live erase test).
final double radius;
/// Page aspect (height / width) so the on-screen circle stays round.
final double aspect;
/// Current in-progress stroke, or null when nothing is being drawn.
final PenStroke? stroke;
final Size pageSize; final Size pageSize;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final s = stroke; final c = cursor.value;
if (s == null || s.points.isEmpty) return; if (c == null) return;
final path = buildStrokePath(s, pageSize, isComplete: false);
if (path.getBounds().isEmpty) return; // CHEAP, ACCURATE highlight: trace ONLY the point-runs inside the eraser
canvas.drawPath( // radius — i.e. exactly what splitStrokeByCircle will remove — as a plain
path, // polyline (no perfect_freehand getStroke; that was the eraser lag source).
// So what turns red is exactly what gets deleted.
final r2 = radius * radius;
final highlight = Paint()
..color = const Color(0xFFFF5252).withValues(alpha: 0.85)
..style = PaintingStyle.stroke
..strokeWidth = 3.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..isAntiAlias = true;
for (final stroke in strokes) {
Path? run;
void flush() {
if (run != null) {
canvas.drawPath(run!, highlight);
run = null;
}
}
for (final pt in stroke.points) {
final dx = pt.x - c.x;
final dy = (pt.y - c.y) * aspect;
if (dx * dx + dy * dy < r2) {
final o = Offset(pt.x * pageSize.width, pt.y * pageSize.height);
(run ??= Path()..moveTo(o.dx, o.dy)).lineTo(o.dx, o.dy);
} else {
flush();
}
}
flush();
}
// The eraser circle itself (radius is a page-width fraction → px = r * w).
final center = Offset(c.x * pageSize.width, c.y * pageSize.height);
final rPx = radius * pageSize.width;
canvas.drawCircle(
center,
rPx,
Paint() Paint()
..color = Color(s.color) ..color = const Color(0xFF757575).withValues(alpha: 0.7)
..style = PaintingStyle.fill ..style = PaintingStyle.stroke
..strokeWidth = 1.0
..isAntiAlias = true,
);
canvas.drawCircle(
center,
rPx,
Paint()..color = const Color(0x14000000),
);
}
@override
bool shouldRepaint(EraserPreviewPainter old) =>
!identical(old.cursor, cursor) ||
old.radius != radius ||
old.aspect != aspect ||
!identical(old.strokes, strokes) ||
old.strokes.length != strokes.length ||
old.pageSize != pageSize;
}
/// Paints the SELECT tool's selection: a dashed-ish bounding box around the
/// selected stroke(s) so the user sees what is selected and draggable. The box
/// is given in normalized page coords and scaled to pixels at paint time.
class SelectionOverlayPainter extends CustomPainter {
SelectionOverlayPainter({
required this.boundsNorm,
required this.pageSize,
});
/// Selection bounding box in normalized page coords (null = nothing selected).
final Rect? boundsNorm;
final Size pageSize;
@override
void paint(Canvas canvas, Size size) {
final b = boundsNorm;
if (b == null) return;
// Inflate slightly so the box doesn't clip the stroke's rendered width.
const padPx = 6.0;
final rect = Rect.fromLTRB(
b.left * pageSize.width - padPx,
b.top * pageSize.height - padPx,
b.right * pageSize.width + padPx,
b.bottom * pageSize.height + padPx,
);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(4)),
Paint()
..color = const Color(0xFF2962FF).withValues(alpha: 0.12)
..style = PaintingStyle.fill,
);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(4)),
Paint()
..color = const Color(0xFF2962FF)
..style = PaintingStyle.stroke
..strokeWidth = 1.5
..isAntiAlias = true, ..isAntiAlias = true,
); );
} }
@override @override
bool shouldRepaint(LiveInkPainter old) => bool shouldRepaint(SelectionOverlayPainter old) =>
!identical(old.stroke, stroke) || old.pageSize != pageSize; old.boundsNorm != boundsNorm || old.pageSize != pageSize;
}
/// Paints just the in-progress stroke (the live layer), kept behind its own
/// RepaintBoundary so committed strokes don't repaint on every move.
class LiveInkPainter extends CustomPainter {
LiveInkPainter({
required this.stroke,
required this.pageSize,
this.thinning = kDefaultPenThinning,
});
/// Current in-progress stroke, or null when nothing is being drawn.
final PenStroke? stroke;
final Size pageSize;
/// Pressure→width response shared with the static/export paths.
final double thinning;
@override
void paint(Canvas canvas, Size size) {
final s = stroke;
if (s == null || s.points.isEmpty) return;
final path =
buildStrokePath(s, pageSize, isComplete: false, thinning: thinning);
if (path.getBounds().isEmpty) return;
canvas.drawPath(path, paintForStroke(s));
}
@override
bool shouldRepaint(LiveInkPainter old) =>
!identical(old.stroke, stroke) ||
old.pageSize != pageSize ||
old.thinning != thinning;
} }

View File

@@ -0,0 +1,107 @@
// lib/editor/canvas/input_diagnostics.dart
//
// Live zoom/pan diagnostics for the pen canvas. PenInteractiveViewer records one
// entry per scale-update frame; the editor's diagnostic overlay displays the
// accumulated summary + a rolling trace so a SINGLE device session reveals the
// nature of any "跳变" (is it a raw-scale spike, a focal/position jump, or a
// pointer-count oscillation?). All numbers reset via [reset].
import 'package:flutter/foundation.dart';
import '../../diagnostics/frame_sampler.dart';
import '../input/diagnostic_logger.dart';
class InputDiagnostics extends ChangeNotifier {
InputDiagnostics._();
static final InputDiagnostics instance = InputDiagnostics._();
int frames = 0;
int scaleDropped = 0; // frames HARD-rejected (legacy; prefer soft-clamp)
int softClamped = 0; // frames whose step was soft-clamped (still applied)
int focalDropped = 0; // frames rejected as a focal/position glitch
int rebaselines = 0; // pointer-count re-baselines
int pointerCountMax = 0;
double rawScaleMin = double.infinity, rawScaleMax = 0;
double scaleMin = double.infinity, scaleMax = 0;
double maxFocalJumpPx = 0; // largest single-frame local focal delta
double maxAppliedScaleJump = 1; // largest single-frame applied scale ratio
final List<String> _trace = <String>[];
List<String> get trace => List.unmodifiable(_trace);
void recordRebaseline() {
rebaselines++;
DiagnosticLogger.instance.log('ZOOM rebaseline');
notifyListeners();
}
void recordScaleFrame({
required double rawScale,
required int pointerCount,
required double currentScale,
required double appliedChange, // 1.0 when the frame was dropped
required double focalJumpPx,
required bool scaleDrop,
required bool focalDrop,
bool softClamped = false,
double? liveScale,
}) {
frames++;
if (scaleDrop) scaleDropped++;
if (softClamped) this.softClamped++;
if (focalDrop) focalDropped++;
if (rawScale < rawScaleMin) rawScaleMin = rawScale;
if (rawScale > rawScaleMax) rawScaleMax = rawScale;
final double resulting = currentScale;
if (resulting < scaleMin) scaleMin = resulting;
if (resulting > scaleMax) scaleMax = resulting;
if (pointerCount > pointerCountMax) pointerCountMax = pointerCount;
if (focalJumpPx > maxFocalJumpPx) maxFocalJumpPx = focalJumpPx;
final double jump = appliedChange >= 1 ? appliedChange : 1 / appliedChange;
if (jump > maxAppliedScaleJump) maxAppliedScaleJump = jump;
final liveBit = liveScale == null
? ''
: ' live=${liveScale.toStringAsFixed(3)}';
final String line = 'p$pointerCount raw=${rawScale.toStringAsFixed(3)} '
'ch=${appliedChange.toStringAsFixed(3)} '
'cur=${currentScale.toStringAsFixed(3)}$liveBit '
'fj=${focalJumpPx.toStringAsFixed(0)}'
'${softClamped ? " SCLAMP" : ""}'
'${scaleDrop ? " SDROP" : ""}'
'${focalDrop ? " FDROP" : ""}';
_trace.add(line);
if (_trace.length > 24) _trace.removeAt(0);
FrameSampler.instance.recordZoom(
rawScale: rawScale,
scaleDrop: scaleDrop || softClamped,
focalDrop: focalDrop,
focalJumpPx: focalJumpPx,
);
DiagnosticLogger.instance.log('ZOOM $line');
notifyListeners();
}
void reset() {
frames = scaleDropped = softClamped = focalDropped = rebaselines =
pointerCountMax = 0;
rawScaleMin = scaleMin = double.infinity;
rawScaleMax = scaleMax = 0;
maxFocalJumpPx = 0;
maxAppliedScaleJump = 1;
_trace.clear();
notifyListeners();
}
String summary() {
final rawLo = rawScaleMin.isFinite ? rawScaleMin.toStringAsFixed(2) : '-';
final rawHi = rawScaleMax > 0 ? rawScaleMax.toStringAsFixed(2) : '-';
final scLo = scaleMin.isFinite ? scaleMin.toStringAsFixed(2) : '-';
final scHi = scaleMax > 0 ? scaleMax.toStringAsFixed(2) : '-';
return 'zoom f=$frames sDrop=$scaleDropped sClamp=$softClamped '
'fDrop=$focalDropped rebase=$rebaselines pMax=$pointerCountMax\n'
' raw=$rawLo..$rawHi scale=$scLo..$scHi\n'
' maxFocalJump=${maxFocalJumpPx.toStringAsFixed(0)}px '
'maxScaleJump=${maxAppliedScaleJump.toStringAsFixed(2)}';
}
}

View File

@@ -0,0 +1,160 @@
// lib/editor/canvas/note_background.dart
//
// rnote-style page background TEMPLATES for the blank-note editor. A background
// is a repeating PATTERN painted in the note page's local pixel space (the
// `pageWidget` is sized to the page rect inside the InteractiveViewer, so a
// CustomPainter here scales 1:1 with zoom — no extra transform needed).
//
// The choice is per-notebook and persists in the sidecar (stored as the enum
// `name`; missing/unknown → [NoteBackground.blank] for back-compat).
import 'package:flutter/material.dart';
/// The available page-background templates (rnote: blank + dots/lines/grid +
/// the Cornell note layout).
enum NoteBackground {
/// Plain white sheet, no pattern.
blank,
/// A regular grid of small dots (dotted paper).
dots,
/// Evenly spaced horizontal lines (ruled / lined paper).
ruled,
/// Square grid (graph paper).
grid,
/// Cornell layout: a left cue-column line + a bottom summary line over a
/// ruled note-taking body.
cornell,
}
/// Decode a persisted background name (the enum [NoteBackground.name]); unknown
/// or missing values fall back to [NoteBackground.blank] (back-compat).
NoteBackground noteBackgroundFromName(String? name) {
for (final b in NoteBackground.values) {
if (b.name == name) return b;
}
return NoteBackground.blank;
}
/// Localized-ish English display label for the picker menu.
String noteBackgroundLabel(NoteBackground b) {
switch (b) {
case NoteBackground.blank:
return 'Blank';
case NoteBackground.dots:
return 'Dots';
case NoteBackground.ruled:
return 'Ruled lines';
case NoteBackground.grid:
return 'Grid';
case NoteBackground.cornell:
return 'Cornell';
}
}
/// An icon for the picker menu.
IconData noteBackgroundIcon(NoteBackground b) {
switch (b) {
case NoteBackground.blank:
return Icons.crop_portrait;
case NoteBackground.dots:
return Icons.grain;
case NoteBackground.ruled:
return Icons.notes;
case NoteBackground.grid:
return Icons.grid_4x4;
case NoteBackground.cornell:
return Icons.view_quilt_outlined;
}
}
/// Paints a [NoteBackground] template behind the ink, in the page's local pixel
/// space. Spacing is page-relative (a fraction of page width) so the template
/// looks the same on any logical page size, and the lines are a light, subtle
/// grey so they sit behind handwriting.
class NoteBackgroundPainter extends CustomPainter {
const NoteBackgroundPainter(this.background);
final NoteBackground background;
/// Pattern spacing as a fraction of the page WIDTH — a ~28-line page.
static const double _spacingFraction = 1 / 28;
static const Color _lineColor = Color(0x1A000000); // ~10% black, subtle grey.
static const Color _dotColor = Color(0x33000000); // dots a touch darker.
static const Color _accentColor = Color(0x33335C81); // Cornell margin lines.
@override
void paint(Canvas canvas, Size size) {
if (background == NoteBackground.blank) return;
final spacing = size.width * _spacingFraction;
if (spacing <= 0) return;
switch (background) {
case NoteBackground.blank:
break;
case NoteBackground.dots:
_paintDots(canvas, size, spacing);
case NoteBackground.ruled:
_paintRuled(canvas, size, spacing);
case NoteBackground.grid:
_paintGrid(canvas, size, spacing);
case NoteBackground.cornell:
_paintCornell(canvas, size, spacing);
}
}
void _paintDots(Canvas canvas, Size size, double spacing) {
final paint = Paint()
..color = _dotColor
..style = PaintingStyle.fill;
final r = (spacing * 0.06).clamp(0.6, 2.0);
for (double y = spacing; y < size.height; y += spacing) {
for (double x = spacing; x < size.width; x += spacing) {
canvas.drawCircle(Offset(x, y), r, paint);
}
}
}
void _paintRuled(Canvas canvas, Size size, double spacing) {
final paint = Paint()
..color = _lineColor
..strokeWidth = 1.0;
for (double y = spacing; y < size.height; y += spacing) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
}
void _paintGrid(Canvas canvas, Size size, double spacing) {
final paint = Paint()
..color = _lineColor
..strokeWidth = 1.0;
for (double y = spacing; y < size.height; y += spacing) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
for (double x = spacing; x < size.width; x += spacing) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
}
void _paintCornell(Canvas canvas, Size size, double spacing) {
// Ruled body lines.
_paintRuled(canvas, size, spacing);
final accent = Paint()
..color = _accentColor
..strokeWidth = 1.4;
// Left cue-column vertical line (~25% of width).
final cueX = size.width * 0.25;
// Bottom summary horizontal line (~80% down).
final summaryY = size.height * 0.80;
canvas.drawLine(Offset(cueX, 0), Offset(cueX, summaryY), accent);
canvas.drawLine(Offset(0, summaryY), Offset(size.width, summaryY), accent);
}
@override
bool shouldRepaint(covariant NoteBackgroundPainter oldDelegate) =>
oldDelegate.background != background;
}

View File

@@ -0,0 +1,337 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../../diagnostics/badnote_log.dart';
import '../../diagnostics/pen_event_ring.dart';
import '../../services/office/docx_parser.dart';
import '../../services/office/office_document.dart';
import '../../services/office/pptx_parser.dart';
import '../../theme/app_theme.dart';
import '../ui/page_nav_shortcuts.dart';
/// Unified native Office viewer + ink annotation (PPTX / DOCX).
class OfficeDocumentScreen extends StatefulWidget {
const OfficeDocumentScreen({
super.key,
required this.filePath,
});
final String filePath;
@override
State<OfficeDocumentScreen> createState() => _OfficeDocumentScreenState();
}
class _OfficeDocumentScreenState extends State<OfficeDocumentScreen> {
bool _loading = true;
String? _error;
ParsedPptx? _pptx;
ParsedDocx? _docx;
int _pageIndex = 0;
final List<_InkStroke> _strokes = [];
_InkStroke? _live;
final TransformationController _transform = TransformationController();
String get _sidecarPath => '${widget.filePath}.badnote.json';
@override
void initState() {
super.initState();
_open();
}
@override
void dispose() {
_transform.dispose();
super.dispose();
}
Future<void> _open() async {
final ext = p.extension(widget.filePath).toLowerCase();
try {
if (ext == '.pptx' || ext == '.ppt') {
_pptx = await PptxParser().parse(widget.filePath);
} else if (ext == '.docx') {
_docx = await DocxParser().parse(widget.filePath);
} else {
throw StateError('Unsupported: $ext');
}
await _loadSidecar();
BadNoteLog.instance.info(LogSubsystem.office, 'office_open', fields: {
'path': widget.filePath,
'pages': pageCount,
});
} catch (e) {
_error = '$e';
BadNoteLog.instance.error(LogSubsystem.office, 'office_open_failed', fields: {
'error': '$e',
});
}
if (mounted) setState(() => _loading = false);
}
int get pageCount {
if (_pptx != null) return _pptx!.slides.length;
if (_docx != null) return (_docx!.blocks.length / 12).ceil().clamp(1, 9999);
return 0;
}
Future<void> _loadSidecar() async {
final f = File(_sidecarPath);
if (!await f.exists()) return;
try {
final json = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
final pages = json['pages'] as Map<String, dynamic>? ?? {};
final key = '$_pageIndex';
final list = pages[key] as List<dynamic>? ?? [];
_strokes
..clear()
..addAll(list.map((e) => _InkStroke.fromJson(e as Map<String, dynamic>)));
} catch (_) {}
}
Future<void> _saveSidecar() async {
Map<String, dynamic> root = {'version': 1, 'pages': <String, dynamic>{}};
final f = File(_sidecarPath);
if (await f.exists()) {
try {
root = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
} catch (_) {}
}
final pages = (root['pages'] as Map<String, dynamic>?) ?? {};
pages['$_pageIndex'] = _strokes.map((s) => s.toJson()).toList();
root['pages'] = pages;
await f.writeAsString(const JsonEncoder.withIndent(' ').convert(root));
}
Future<void> _goPage(int i) async {
await _saveSidecar();
setState(() {
_pageIndex = i.clamp(0, pageCount - 1);
_strokes.clear();
_live = null;
});
await _loadSidecar();
if (mounted) setState(() {});
}
void _onPointerDown(PointerDownEvent e) {
if (e.kind != ui.PointerDeviceKind.stylus &&
e.kind != ui.PointerDeviceKind.invertedStylus &&
e.kind != ui.PointerDeviceKind.mouse) {
return;
}
final local = _toScene(e.localPosition);
_live = _InkStroke(points: [local], pressures: [e.pressure]);
PenEventRing.instance.recordPointer(
kind: 'down',
pointerId: e.pointer,
deviceKind: e.kind.name,
pressure: e.pressure,
decision: 'draw',
);
setState(() {});
}
void _onPointerMove(PointerMoveEvent e) {
final live = _live;
if (live == null) return;
live.points.add(_toScene(e.localPosition));
live.pressures.add(e.pressure);
setState(() {});
}
void _onPointerUp(PointerUpEvent e) {
final live = _live;
if (live == null) return;
setState(() {
_strokes.add(live);
_live = null;
});
unawaited(_saveSidecar());
}
Offset _toScene(Offset local) {
final inv = Matrix4.inverted(_transform.value);
return MatrixUtils.transformPoint(inv, local);
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (_error != null) {
return Scaffold(
appBar: AppBar(title: Text(p.basename(widget.filePath))),
body: Center(child: Text(_error!)),
);
}
return pageNavShortcuts(
onPrevious: _pageIndex > 0 ? () => _goPage(_pageIndex - 1) : null,
onNext:
_pageIndex < pageCount - 1 ? () => _goPage(_pageIndex + 1) : null,
onFirst: pageCount > 0 ? () => _goPage(0) : null,
onLast: pageCount > 0 ? () => _goPage(pageCount - 1) : null,
child: Scaffold(
appBar: AppBar(
title: Text(p.basename(widget.filePath)),
actions: [
IconButton(
onPressed: _pageIndex > 0 ? () => _goPage(_pageIndex - 1) : null,
icon: const Icon(Icons.chevron_left),
),
Center(child: Text('${_pageIndex + 1} / $pageCount')),
IconButton(
onPressed: _pageIndex < pageCount - 1
? () => _goPage(_pageIndex + 1)
: null,
icon: const Icon(Icons.chevron_right),
),
],
),
body: InteractiveViewer(
transformationController: _transform,
minScale: 0.5,
maxScale: 4,
child: Listener(
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
child: CustomPaint(
painter: _OfficePagePainter(
pptx: _pptx,
docx: _docx,
pageIndex: _pageIndex,
strokes: _strokes,
live: _live,
),
size: _pageSize,
),
),
),
),
);
}
Size get _pageSize {
if (_pptx != null && _pptx!.slides.isNotEmpty) {
final s = _pptx!.slides[_pageIndex.clamp(0, _pptx!.slides.length - 1)];
return Size(s.width, s.height);
}
return const Size(800, 1100);
}
}
class _InkStroke {
_InkStroke({required this.points, required this.pressures});
final List<Offset> points;
final List<double> pressures;
Map<String, dynamic> toJson() => {
'points': [
for (final p in points) {'x': p.dx, 'y': p.dy},
],
'pressures': pressures,
};
factory _InkStroke.fromJson(Map<String, dynamic> json) {
final pts = (json['points'] as List<dynamic>)
.map((e) => Offset(
(e['x'] as num).toDouble(),
(e['y'] as num).toDouble(),
))
.toList();
final pr = (json['pressures'] as List<dynamic>?)
?.map((e) => (e as num).toDouble())
.toList() ??
List.filled(pts.length, 0.5);
return _InkStroke(points: pts, pressures: pr);
}
}
class _OfficePagePainter extends CustomPainter {
_OfficePagePainter({
required this.pptx,
required this.docx,
required this.pageIndex,
required this.strokes,
required this.live,
});
final ParsedPptx? pptx;
final ParsedDocx? docx;
final int pageIndex;
final List<_InkStroke> strokes;
final _InkStroke? live;
@override
void paint(Canvas canvas, Size size) {
final bg = Paint()..color = AppTokens.paper;
canvas.drawRect(Offset.zero & size, bg);
if (pptx != null && pptx!.slides.isNotEmpty) {
final slide = pptx!.slides[pageIndex.clamp(0, pptx!.slides.length - 1)];
final border = Paint()
..color = AppTokens.rule
..style = PaintingStyle.stroke;
canvas.drawRect(Offset.zero & Size(slide.width, slide.height), border);
for (final run in slide.runs) {
final tp = TextPainter(
text: TextSpan(
text: run.text,
style: TextStyle(
color: AppTokens.ink,
fontSize: run.fontSize,
),
),
textDirection: TextDirection.ltr,
)..layout(maxWidth: run.width > 0 ? run.width : slide.width - 96);
tp.paint(canvas, Offset(run.x, run.y));
}
} else if (docx != null) {
final start = pageIndex * 12;
final blocks = docx!.blocks.skip(start).take(12).toList();
var y = 48.0;
for (final b in blocks) {
final style = TextStyle(
color: AppTokens.ink,
fontSize: b.type == DocBlockType.heading ? 22 - b.level * 2.0 : 15,
fontWeight:
b.type == DocBlockType.heading ? FontWeight.w700 : FontWeight.w400,
);
final tp = TextPainter(
text: TextSpan(text: b.text, style: style),
textDirection: TextDirection.ltr,
)..layout(maxWidth: size.width - 96);
tp.paint(canvas, Offset(48, y));
y += tp.height + 12;
}
}
final ink = Paint()
..color = AppTokens.copper
..style = PaintingStyle.stroke
..strokeWidth = 2.2
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
for (final s in [...strokes, if (live != null) live!]) {
if (s.points.length < 2) continue;
final path = Path()..moveTo(s.points.first.dx, s.points.first.dy);
for (var i = 1; i < s.points.length; i++) {
path.lineTo(s.points[i].dx, s.points[i].dy);
}
canvas.drawPath(path, ink);
}
}
@override
bool shouldRepaint(covariant _OfficePagePainter oldDelegate) => true;
}

View File

@@ -21,11 +21,49 @@
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'ink_painters.dart'; import '../engine/brush.dart';
import '../engine/pen_physics.dart';
import '../engine/stroke_eraser.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../engine/stroke_model.dart';
import '../engine/stroke_predictor.dart';
import '../engine/stroke_store.dart';
import '../input/input_arbiter.dart' as arbiter;
import '../input/pen_config.dart';
import '../input/pressure_curve.dart';
import '../input/pen_input_service.dart';
import '../../diagnostics/pen_event_ring.dart';
import '../engine/shape_geometry.dart';
import 'dart:math' as math;
import '../render/ink_picture_cache.dart';
import '../render/live_ink_painter.dart' as render;
import '../render/static_ink_painter.dart' as render;
import 'editor_tool.dart';
import 'ink_painters.dart' show EraserPreviewPainter, SelectionOverlayPainter;
import 'pen_interactive_viewer.dart';
import 'pen_stroke.dart'; import 'pen_stroke.dart';
/// The active tool on the pen canvas. /// The active tool on the pen canvas. Pen/highlighter/eraser are the legacy
enum CanvasTool { pen, highlighter, eraser } /// triad; [select] and [shape] are the core-writing-batch additions. This mirrors
/// the shared [EditorToolKind] (the PDF editor uses that enum directly); the
/// PenCanvas keeps its own enum because it predates the shared model and is wired
/// through many call sites — see [editorToolToCanvas].
enum CanvasTool { pen, highlighter, eraser, select, shape }
/// Map the shared [EditorToolKind] to the PenCanvas's [CanvasTool] so the note/
/// slide editors can drive PenCanvas from the shared active-tool state.
CanvasTool editorToolToCanvas(EditorToolKind kind) => switch (kind) {
EditorToolKind.brush => CanvasTool.pen,
EditorToolKind.highlighter => CanvasTool.highlighter,
EditorToolKind.eraser => CanvasTool.eraser,
EditorToolKind.select => CanvasTool.select,
EditorToolKind.shape => CanvasTool.shape,
// The TEXT tool is PDF-editor-only for now (note/slide typed text is a
// later increment); the note palette has no text button, so this mapping
// is unreachable in practice — fall back to the pen so the switch stays
// exhaustive without inventing a PenCanvas typed-text path.
EditorToolKind.text => CanvasTool.pen,
};
class PenCanvas extends StatefulWidget { class PenCanvas extends StatefulWidget {
const PenCanvas({ const PenCanvas({
@@ -35,14 +73,29 @@ class PenCanvas extends StatefulWidget {
required this.strokes, required this.strokes,
required this.transformationController, required this.transformationController,
required this.tool, required this.tool,
this.brush = BrushKind.fountainPen,
this.shapeKind = ShapeKind.line,
required this.color, required this.color,
required this.strokeWidth, required this.strokeWidth,
required this.onStrokeComplete, required this.onStrokeComplete,
required this.onEraseStroke, required this.onEraseStroke,
this.selectedStrokeIndex,
this.onSelectStroke,
this.onMoveStroke,
this.allowFingerDrawing = false, this.allowFingerDrawing = false,
this.minScale = 0.5, this.minScale = 0.5,
this.maxScale = 8.0, this.maxScale = 8.0,
this.scaleEnabled = true,
this.panEnabled = true,
this.onPenDebug, this.onPenDebug,
this.thinning = kDefaultPenThinning,
this.pressureGamma = kNaturalPressureGamma,
this.pressureFloor = kNaturalPressureFloor,
this.eraserRadius = kDefaultEraserRadius,
this.eraserWholeStroke = false,
this.sideButtonAction = PenButtonAction.eraser,
this.eraserEndAction = PenButtonAction.eraser,
this.onPenButtonAction,
}); });
/// Debug hook: called with a readout of the latest pen event /// Debug hook: called with a readout of the latest pen event
@@ -64,6 +117,17 @@ class PenCanvas extends StatefulWidget {
final TransformationController transformationController; final TransformationController transformationController;
final CanvasTool tool; final CanvasTool tool;
/// The brush selected for the PEN tool (fountain/ballpoint/pencil). The
/// highlighter tool always renders with [BrushKind.highlighter] regardless of
/// this value; the eraser draws nothing. Drives both the capture-time pressure
/// pre-warp ([BrushProfile.pressureGamma]) and the render geometry.
final BrushKind brush;
/// The shape to draw when [tool] is [CanvasTool.shape]. Generated as a
/// PenStroke via [generateShapePoints] (no new model).
final ShapeKind shapeKind;
final Color color; final Color color;
/// Pen width as a fraction of page width (so it zooms with the page). /// Pen width as a fraction of page width (so it zooms with the page).
@@ -72,8 +136,27 @@ class PenCanvas extends StatefulWidget {
/// Called with a finished stroke (normalized coords) to commit it. /// Called with a finished stroke (normalized coords) to commit it.
final void Function(PenStroke stroke) onStrokeComplete; final void Function(PenStroke stroke) onStrokeComplete;
/// Called with the index of a committed stroke to erase (stroke-erase). /// Called to replace committed stroke [strokeIndex] with its surviving pieces
final void Function(int strokeIndex) onEraseStroke; /// after a partial (segment) erase. An empty [replacements] list removes the
/// stroke entirely (whole-stroke erase).
final void Function(int strokeIndex, List<PenStroke> replacements)
onEraseStroke;
/// Index of the currently selected committed stroke (SELECT tool), or null.
/// Drives the selection bounding-box overlay.
final int? selectedStrokeIndex;
/// Called when the SELECT tool taps a committed stroke (its index), or null
/// when the tap hits empty space (clears the selection).
final ValueChanged<int?>? onSelectStroke;
/// Called when the SELECT tool drags the selected stroke: ([strokeIndex],
/// [dx],[dy]) is the normalized translation to apply, and [isDragStart] is true
/// on the FIRST delta of a drag so the parent records ONE undo snapshot per
/// drag (not per pixel). The parent translates + persists (see
/// `translateStroke`).
final void Function(int strokeIndex, double dx, double dy, bool isDragStart)?
onMoveStroke;
/// User toggle: allow a single finger to draw. Forced off once a stylus is /// User toggle: allow a single finger to draw. Forced off once a stylus is
/// seen (palm rejection). /// seen (palm rejection).
@@ -82,6 +165,51 @@ class PenCanvas extends StatefulWidget {
final double minScale; final double minScale;
final double maxScale; final double maxScale;
/// When false, the canvas cannot be pinch-zoomed (sticky notes lock this so
/// writing isn't fighting an inner transform).
final bool scaleEnabled;
/// When false, one-finger pan is disabled (sticky notes often lock pan too).
final bool panEnabled;
/// perfect_freehand pressure→width response, from `PenConfig.pressureSensitivity`.
final double thinning;
/// Pressure-response exponent applied to raw stylus pressure BEFORE it reaches
/// perfect_freehand. <1 boosts light touches (responsive, rnote-like); 1 is
/// raw linear (the old "pressure-finger" feel). From `PenConfig.pressureGamma`.
///
/// TODO(brush-pressure-knob): superseded by the per-brush
/// [BrushProfile.pressureGamma] (fountain p², pencil √p) which now drives the
/// capture-time warp. This config knob is retained for the API + future
/// reconciliation (e.g. a user multiplier on top of the brush curve) but is no
/// longer read by [_normalizedPressure].
final double pressureGamma;
/// Minimum shaped pressure, so a light stroke still has body instead of
/// scratchy near-zero width. From `PenConfig.pressureFloor`.
final double pressureFloor;
/// Eraser radius as a fraction of page width (live hit area + cursor size).
/// From `PenConfig.eraserRadius`.
final double eraserRadius;
/// When true the eraser removes a whole stroke on contact (OneNote-style);
/// when false it does a partial / segment erase. From
/// `PenConfig.eraserWholeStroke`.
final bool eraserWholeStroke;
/// Configured action for the pen's side barrel button (W3 — resolved against
/// the native pen plugin's flags on Windows).
final PenButtonAction sideButtonAction;
/// Configured action for the pen's eraser/inverted end (W3).
final PenButtonAction eraserEndAction;
/// Fired (edge-triggered) when a hardware pen button mapped to a non-eraser
/// action (undo / toggleTool) is pressed.
final void Function(PenButtonAction action)? onPenButtonAction;
@override @override
State<PenCanvas> createState() => _PenCanvasState(); State<PenCanvas> createState() => _PenCanvasState();
} }
@@ -95,28 +223,131 @@ class _PenCanvasState extends State<PenCanvas> {
/// In-progress stroke points (normalized). /// In-progress stroke points (normalized).
final List<PenPoint> _livePoints = []; final List<PenPoint> _livePoints = [];
final StrokePredictor _predictor = StrokePredictor();
/// Count of real (non-predicted) points in [_livePoints].
int _realPointCount = 0;
/// Live stroke snapshot handed to the LiveInkPainter; null when idle. /// Live stroke snapshot handed to the LiveInkPainter; null when idle.
PenStroke? _liveStroke; PenStroke? _liveStroke;
/// SHAPE tool: the normalized start point of the in-progress shape, or null.
PenPoint? _shapeStart;
/// SELECT tool: the last normalized drag position, used to compute the
/// incremental translation reported to [PenCanvas.onMoveStroke].
PenPoint? _selectLast;
/// SELECT tool: true once a drag of the selected stroke has begun (so the move
/// undo snapshot is recorded once, on the first drag delta — see _extendStroke).
bool _selectDragging = false;
/// Tip-velocity tracker for [tipVelocityWidthScale] (physical ink starvation).
Offset? _lastTipNorm;
Duration? _lastTipTime;
/// True when the active stylus reports the eraser signal (barrel button or /// True when the active stylus reports the eraser signal (barrel button or
/// inverted stylus), detected on hover/down. /// inverted stylus), detected on hover/down.
bool _eraserActive = false; bool _eraserActive = false;
/// Eraser preview cursor (normalized page coords), or null when not in eraser
/// mode / the pen is not near the page. A ValueNotifier so the preview layer
/// repaints on cursor moves WITHOUT rebuilding the whole canvas every frame
/// (the old per-move setState was the eraser-lag source).
final ValueNotifier<PenPoint?> _eraserCursor = ValueNotifier<PenPoint?>(null);
/// Committed ink mirrored as the canonical [EditorStroke] model, driving the
/// revision-gated [render.StaticInkPainter] + [InkPictureCache] (P0 step 3).
/// The cache replays a recorded ui.Picture for the committed layer, so pinch /
/// pan / live-stroke frames never re-rasterize the committed ink — the
/// P0.5 perf prerequisite. Kept in sync with [PenCanvas.strokes] (which the
/// parent replaces with a fresh list identity on every commit/erase).
final StrokeStore _store = StrokeStore();
final InkPictureCache _inkCache = InkPictureCache();
List<PenStroke>? _syncedStrokesRef;
static const String _inkHostId = 'pen-canvas';
/// Re-mirror [PenCanvas.strokes] into [_store] when the parent hands us a new
/// list (identity change ⇒ a commit/erase happened). Bumping the store
/// revision invalidates the cached Picture so the committed layer repaints.
void _syncStore() {
if (identical(_syncedStrokesRef, widget.strokes)) return;
_syncedStrokesRef = widget.strokes;
_store.replaceAll(
widget.strokes.map((s) => EditorStroke.fromPenStroke(s)).toList(),
);
}
/// True when the eraser would act (eraser tool selected, or a barrel/inverted
/// eraser signal is live).
bool get _isEraserMode =>
widget.tool == CanvasTool.eraser || _eraserActive;
/// Page aspect (height / width) so the eraser circle stays round on screen.
double get _pageAspect => widget.pageSize.width <= 0
? 1.0
: widget.pageSize.height / widget.pageSize.width;
/// Normalized bounding box of the currently selected stroke (SELECT tool), or
/// null when nothing valid is selected.
Rect? get _selectionBounds {
final idx = widget.selectedStrokeIndex;
if (idx == null || idx < 0 || idx >= widget.strokes.length) return null;
final b = penStrokeBounds(widget.strokes[idx]);
if (b == null) return null;
return Rect.fromLTRB(b.left, b.top, b.right, b.bottom);
}
// The explicit user toggle wins: if finger-drawing is ON, a single finger // The explicit user toggle wins: if finger-drawing is ON, a single finger
// draws even after a stylus has been seen. (Palm rejection when the toggle is // draws even after a stylus has been seen. (Palm rejection when the toggle is
// OFF is automatic — fingers simply never draw — and a 2nd pointer always // OFF is automatic — fingers simply never draw — and a 2nd pointer always
// cancels an in-progress stroke regardless.) // cancels an in-progress stroke regardless.)
bool get _fingerDrawingEnabled => widget.allowFingerDrawing; bool get _fingerDrawingEnabled => widget.allowFingerDrawing;
bool _isStylus(PointerDeviceKind kind) => bool _isStylus(PointerDeviceKind kind) => arbiter.isStylusKind(kind);
kind == PointerDeviceKind.stylus ||
kind == PointerDeviceKind.invertedStylus; /// The brush in effect for the current tool: highlighter tool ⇒ highlighter
/// brush, otherwise the selected pen brush. (Eraser draws nothing, so its
/// brush is irrelevant.)
BrushKind get _currentBrush => widget.tool == CanvasTool.highlighter
? BrushKind.highlighter
: widget.brush;
/// The brush profile in effect, for the capture-time pressure pre-warp.
BrushProfile get _currentBrushProfile => brushProfileFor(_currentBrush);
/// Normalize stylus pressure to [0,1], or null when the device reports no /// Normalize stylus pressure to [0,1], or null when the device reports no
/// usable pressure range (then perfect_freehand simulates pressure). /// usable pressure range (then perfect_freehand simulates pressure).
///
/// The raw normalized force is then shaped by the pressure-response curve
/// (floor + gamma) so the stored pressure already carries the rnote-like feel
/// — and because the shaping happens at capture, the live stroke and the PDF
/// export replay identical pressures (no divergence).
double? _normalizedPressure(PointerEvent event) { double? _normalizedPressure(PointerEvent event) {
if (!_isStylus(event.kind)) return null; if (!_isStylus(event.kind)) return null;
final double? raw = _rawNormalizedPressure(event);
if (raw == null) return null;
// Pre-warp pressure with the BRUSH's gamma (rnote PressureCurve: fountain
// = Pow2/p², pencil = Sqrt/√p, ballpoint/highlighter = Linear), reusing the
// existing PressureCurve. Baking the warp in at capture means the live
// stroke and the export replay identical pressures (no divergence). The
// brush gamma supersedes the legacy per-config `pressureGamma` knob — see
// TODO(brush-pressure-knob) on `widget.pressureGamma`.
return PressureCurve(
floor: widget.pressureFloor,
gamma: _currentBrushProfile.pressureGamma,
).apply(raw);
}
/// Raw [0,1] stylus force before response shaping (see [_normalizedPressure]).
///
/// Prefer native Win32 pressure from [PenInputService] when valid — Flutter's
/// PointerEvent.pressure on Windows is often flat/useless while the driver
/// still reports real 0..1024 via GetPointerPenInfo.
double? _rawNormalizedPressure(PointerEvent event) {
final hw = PenInputService.instance;
if (hw.isActive && hw.current.pressureValid) {
return hw.current.pressure.clamp(0.0, 1.0);
}
final range = event.pressureMax - event.pressureMin; final range = event.pressureMax - event.pressureMin;
if (range > 0.0001) { if (range > 0.0001) {
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0); return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
@@ -130,26 +361,103 @@ class _PenCanvasState extends State<PenCanvas> {
return null; return null;
} }
/// The eraser signal: barrel/secondary button held, or an inverted stylus. /// The eraser signal. Two sources, ORed:
bool _isEraserSignal(PointerEvent event) => /// 1. Flutter-native: secondary button held or an inverted stylus (works on
event.buttons == kSecondaryButton || /// desktop / platforms that surface these).
event.kind == PointerDeviceKind.invertedStylus; /// 2. Windows native pen plugin: barrel / inverted / eraser flags that
/// Flutter 3.44 drops, mapped through the configured side-button /
/// Decide whether the gesture currently forming should DRAW. /// eraser-end actions (W3). Level-triggered, so holding the button keeps
/// True iff exactly one active pointer AND (stylus OR finger-drawing on). /// erasing — correct for an eraser.
bool _shouldDraw(PointerDeviceKind kind) { bool _isEraserSignal(PointerEvent event) {
if (_activePointers.length != 1) return false; // BITMASK test, not equality: Flutter defines kPrimaryStylusButton == 0x02
if (_isStylus(kind)) return true; // == kSecondaryButton, and kStylusContact == 0x01. While the pen TIP is
if (kind == PointerDeviceKind.mouse) return true; // down with the barrel pressed, event.buttons == 0x03, so `== kSecondaryButton`
if (kind == PointerDeviceKind.touch) return _fingerDrawingEnabled; // (0x02) is false — the side button registered only on hover, never while
// drawing. `& kSecondaryButton != 0` catches both.
if ((event.buttons & kSecondaryButton) != 0 ||
event.kind == PointerDeviceKind.invertedStylus) {
return true;
}
final hw = PenInputService.instance;
if (hw.isActive) {
final s = hw.current;
if ((s.inverted || s.eraser) &&
widget.eraserEndAction == PenButtonAction.eraser) {
return true;
}
if (s.barrel && widget.sideButtonAction == PenButtonAction.eraser) {
return true;
}
}
return false; return false;
} }
/// Resolve the currently-active configured action from the native pen flags
/// (eraser-end takes precedence over the side button when both are set).
PenButtonAction _activeHwAction() {
final hw = PenInputService.instance;
if (!hw.isActive) return PenButtonAction.none;
final s = hw.current;
if (s.inverted || s.eraser) return widget.eraserEndAction;
if (s.barrel) return widget.sideButtonAction;
return PenButtonAction.none;
}
/// Last hardware action seen, for rising-edge detection of undo/toggleTool.
PenButtonAction _lastHwAction = PenButtonAction.none;
/// Edge-triggered dispatch of non-eraser button actions (undo / toggleTool).
/// Eraser is handled level-triggered by [_isEraserSignal]; pan suppresses
/// drawing via [_shouldDraw].
void _dispatchHwButtonActions() {
final action = _activeHwAction();
if (action == _lastHwAction) return;
_lastHwAction = action;
if (action == PenButtonAction.undo ||
action == PenButtonAction.toggleTool ||
action == PenButtonAction.select) {
widget.onPenButtonAction?.call(action);
}
}
/// True while a hardware button mapped to `pan` is held (suppresses drawing
/// so the InteractiveViewer pans instead).
bool get _hwPanActive => _activeHwAction() == PenButtonAction.pan;
/// Pen tilt magnitude (degrees) for a stylus event, or null when unavailable.
double? _tiltFor(PointerEvent event) {
if (!_isStylus(event.kind)) return null;
final hw = PenInputService.instance;
if (!hw.isActive) return null;
final t = hw.current.tiltMagnitude;
return t == 0 ? null : t;
}
/// Decide whether the gesture currently forming should DRAW. Delegates to the
/// pure [arbiter.shouldDraw] (unit-tested truth table) so the live canvas and
/// the tests can never disagree on the rule.
bool _shouldDraw(PointerDeviceKind kind) {
final draw = arbiter.shouldDraw(
activePointerCount: _activePointers.length,
kind: kind,
fingerDrawingEnabled: _fingerDrawingEnabled,
hwPanActive: _hwPanActive,
);
PenEventRing.instance.recordArbiter(
activeCount: _activePointers.length,
deviceKind: kind.name,
draw: draw,
fingerDrawing: _fingerDrawingEnabled,
);
return draw;
}
// --- Coordinate mapping --------------------------------------------------- // --- Coordinate mapping ---------------------------------------------------
/// Map a global pointer position into normalized page coords using the /// Map a global pointer position into normalized page coords using the
/// shared transform (inverse) and this widget's geometry. /// shared transform (inverse) and this widget's geometry.
PenPoint? _toNormalized(Offset globalPosition, double? pressure) { PenPoint? _toNormalized(Offset globalPosition, double? pressure,
{double? tilt, Duration? timeStamp}) {
final box = context.findRenderObject() as RenderBox?; final box = context.findRenderObject() as RenderBox?;
if (box == null) return null; if (box == null) return null;
final local = box.globalToLocal(globalPosition); final local = box.globalToLocal(globalPosition);
@@ -159,7 +467,23 @@ class _PenCanvasState extends State<PenCanvas> {
final nx = scene.dx / widget.pageSize.width; final nx = scene.dx / widget.pageSize.width;
final ny = scene.dy / widget.pageSize.height; final ny = scene.dy / widget.pageSize.height;
return PenPoint(nx, ny, pressure);
double? shaped = pressure;
if (shaped != null && timeStamp != null && _lastTipNorm != null &&
_lastTipTime != null) {
final dt = (timeStamp - _lastTipTime!).inMicroseconds / 1e6;
if (dt > 0) {
final dx = nx - _lastTipNorm!.dx;
final dy = ny - _lastTipNorm!.dy;
final speed = math.sqrt(dx * dx + dy * dy) / dt;
shaped = (shaped * tipVelocityWidthScale(_currentBrush, speed))
.clamp(0.0, 1.0);
}
}
_lastTipNorm = Offset(nx, ny);
_lastTipTime = timeStamp;
return PenPoint(nx, ny, shaped, tilt: tilt);
} }
// --- Stroke lifecycle ----------------------------------------------------- // --- Stroke lifecycle -----------------------------------------------------
@@ -167,52 +491,186 @@ class _PenCanvasState extends State<PenCanvas> {
void _startStroke(PointerDownEvent event) { void _startStroke(PointerDownEvent event) {
_drawPointer = event.pointer; _drawPointer = event.pointer;
_livePoints.clear(); _livePoints.clear();
final p = _toNormalized(event.position, _normalizedPressure(event)); _realPointCount = 0;
if (p != null) _livePoints.add(p); _predictor.reset();
_shapeStart = null;
_selectLast = null;
_selectDragging = false;
_lastTipNorm = null;
_lastTipTime = null;
final p = _toNormalized(event.position, _normalizedPressure(event),
tilt: _tiltFor(event), timeStamp: event.timeStamp);
if (_eraserActive || widget.tool == CanvasTool.eraser) { if (_eraserActive || widget.tool == CanvasTool.eraser) {
_eraserCursor.value = p;
_eraseAt(p); _eraseAt(p);
// Keep the stroke pointer reserved so moves keep erasing, but don't paint. // No setState here: the preview repaints via the notifier, and any erased
setState(() => _liveStroke = null); // stroke repaints via the editor's onEraseStroke setState. (_liveStroke is
// already null in eraser mode.)
if (_liveStroke != null) setState(() => _liveStroke = null);
return; return;
} }
// SELECT: tap hit-tests the committed strokes (topmost first) and reports
// the selection. A subsequent drag translates it (see _extendStroke).
if (widget.tool == CanvasTool.select) {
if (p != null) {
_selectLast = p;
widget.onSelectStroke?.call(_hitTestStroke(p));
}
return;
}
// SHAPE: record the start point; the preview shape is built on each move.
if (widget.tool == CanvasTool.shape) {
_shapeStart = p;
return;
}
if (p != null) {
_livePoints.add(p);
_realPointCount = _livePoints.length;
}
_updateLiveStroke(); _updateLiveStroke();
} }
void _extendStroke(PointerMoveEvent event) { void _extendStroke(PointerMoveEvent event) {
final p = _toNormalized(event.position, _normalizedPressure(event)); final p = _toNormalized(event.position, _normalizedPressure(event),
tilt: _tiltFor(event), timeStamp: event.timeStamp);
if (p == null) return; if (p == null) return;
if (_eraserActive || widget.tool == CanvasTool.eraser) { if (_eraserActive || widget.tool == CanvasTool.eraser) {
_eraserCursor.value = p;
_eraseAt(p); _eraseAt(p);
return; return;
} }
// SELECT drag: translate the selected stroke by the incremental delta.
if (widget.tool == CanvasTool.select) {
final last = _selectLast;
final idx = widget.selectedStrokeIndex;
if (last != null && idx != null) {
final dx = p.x - last.x;
final dy = p.y - last.y;
if (dx != 0 || dy != 0) {
final isStart = !_selectDragging;
_selectDragging = true;
widget.onMoveStroke?.call(idx, dx, dy, isStart);
}
}
_selectLast = p;
return;
}
// SHAPE preview: regenerate the shape from start→current on every move.
if (widget.tool == CanvasTool.shape) {
_updateShapePreview(p);
return;
}
// Drop previous predicted tip before appending the real sample.
if (_livePoints.length > _realPointCount) {
_livePoints.removeRange(_realPointCount, _livePoints.length);
}
_livePoints.add(p); _livePoints.add(p);
_realPointCount = _livePoints.length;
final pred = _predictor.observe(Offset(p.x, p.y), p.pressure ?? 0.5);
if (pred != null) {
_livePoints.add(PenPoint(
pred.offset.dx.clamp(0.0, 1.0),
pred.offset.dy.clamp(0.0, 1.0),
pred.pressure,
tilt: p.tilt,
));
}
_updateLiveStroke(); _updateLiveStroke();
} }
void _endStroke() { void _endStroke() {
if (_drawPointer == null) return; if (_drawPointer == null) return;
final wasEraser = _eraserActive || widget.tool == CanvasTool.eraser; final tool = widget.tool;
if (!wasEraser && _livePoints.isNotEmpty) { final wasEraser = _eraserActive || tool == CanvasTool.eraser;
if (tool == CanvasTool.shape) {
// Commit the generated shape stroke (if the drag spanned any distance).
final start = _shapeStart;
final end = _livePoints.isNotEmpty ? _livePoints.last : null;
if (start != null && end != null) {
final pts = generateShapePoints(widget.shapeKind, start, end);
widget.onStrokeComplete(PenStroke(
points: pts,
color: _currentColor().toARGB32(),
width: widget.strokeWidth,
kind: PenStrokeKind.pen,
brush: kShapeBrush,
));
}
} else if (tool == CanvasTool.select) {
// Nothing to commit on release: selection + moves were applied live.
} else if (!wasEraser && _livePoints.isNotEmpty) {
// Never commit predicted tips — only real digitizer samples.
if (_livePoints.length > _realPointCount) {
_livePoints.removeRange(_realPointCount, _livePoints.length);
}
widget.onStrokeComplete( widget.onStrokeComplete(
PenStroke( PenStroke(
points: List.of(_livePoints), points: List.of(_livePoints),
color: _currentColor().toARGB32(), color: _currentColor().toARGB32(),
width: widget.strokeWidth, width: widget.strokeWidth,
kind: _currentKind(), kind: _currentKind(),
brush: _currentBrush,
), ),
); );
} }
_drawPointer = null; _drawPointer = null;
_shapeStart = null;
_selectLast = null;
_selectDragging = false;
_livePoints.clear(); _livePoints.clear();
_realPointCount = 0;
_predictor.reset();
_eraserCursor.value = null; // hide the preview when the pen lifts
setState(() => _liveStroke = null); setState(() => _liveStroke = null);
} }
/// Hit-test committed strokes (topmost first) at normalized [p]; returns the
/// index of the first stroke within the eraser radius, or null. Reuses
/// [strokeHit] so tap-select matches the eraser's proximity model.
int? _hitTestStroke(PenPoint p) {
final radius = widget.eraserRadius;
final aspect = _pageAspect;
for (var i = widget.strokes.length - 1; i >= 0; i--) {
if (strokeHit(widget.strokes[i], p.x, p.y, radius, aspect: aspect)) {
return i;
}
}
return null;
}
/// Build the SHAPE preview stroke from the recorded start to the current [p].
void _updateShapePreview(PenPoint p) {
final start = _shapeStart;
if (start == null) return;
_livePoints
..clear()
..add(p); // remember the latest end point for commit
final pts = generateShapePoints(widget.shapeKind, start, p);
setState(() {
_liveStroke = PenStroke(
points: pts,
color: _currentColor().toARGB32(),
width: widget.strokeWidth,
kind: PenStrokeKind.pen,
brush: kShapeBrush,
);
});
}
/// Discard the in-progress stroke without committing (palm/2nd-finger). /// Discard the in-progress stroke without committing (palm/2nd-finger).
void _cancelStroke() { void _cancelStroke() {
_drawPointer = null; _drawPointer = null;
_livePoints.clear(); _livePoints.clear();
_eraserCursor.value = null;
setState(() => _liveStroke = null); setState(() => _liveStroke = null);
} }
@@ -223,6 +681,7 @@ class _PenCanvasState extends State<PenCanvas> {
color: _currentColor().toARGB32(), color: _currentColor().toARGB32(),
width: widget.strokeWidth, width: widget.strokeWidth,
kind: _currentKind(), kind: _currentKind(),
brush: _currentBrush,
); );
}); });
} }
@@ -236,37 +695,54 @@ class _PenCanvasState extends State<PenCanvas> {
? widget.color.withAlpha(0x80) ? widget.color.withAlpha(0x80)
: widget.color; : widget.color;
/// Stroke-erase: remove the first committed stroke within proximity of [p]. /// Partial (segment) erase: find the first committed stroke the eraser circle
/// touches and replace it with its surviving pieces. The eraser radius is in
/// normalized page-width fractions; [aspect] corrects the y axis so the circle
/// stays round on screen (the page rect is not square).
void _eraseAt(PenPoint? p) { void _eraseAt(PenPoint? p) {
if (p == null) return; if (p == null) return;
final radius = widget.strokeWidth * 2; // normalized radius final radius = widget.eraserRadius; // normalized (page-width fraction)
final aspect = _pageAspect;
for (var i = widget.strokes.length - 1; i >= 0; i--) { for (var i = widget.strokes.length - 1; i >= 0; i--) {
final stroke = widget.strokes[i]; final stroke = widget.strokes[i];
for (final sp in stroke.points) { if (!strokeHit(stroke, p.x, p.y, radius, aspect: aspect)) continue;
final dx = sp.x - p.x; // Stroke-eraser mode: a hit removes the entire stroke (empty replacement).
final dy = sp.y - p.y; // Point-eraser mode (default): cut out the touched span, keep the rest.
if (dx * dx + dy * dy < radius * radius) { final pieces = widget.eraserWholeStroke
widget.onEraseStroke(i); ? const <PenStroke>[]
: splitStrokeByCircle(stroke, p.x, p.y, radius, aspect: aspect);
// Defensive no-op guard (strokeHit already passed, so a hit is expected).
if (pieces.length == 1 && identical(pieces.first, stroke)) return;
widget.onEraseStroke(i, pieces);
return; return;
} }
} }
}
}
// --- Listener callbacks --------------------------------------------------- // --- Listener callbacks ---------------------------------------------------
/// Highest NORMALIZED pressure seen since the diagnostic was last reset —
/// makes "does pressure actually vary?" unambiguous on the readout.
double _peakNorm = 0;
void _emitPenDebug(PointerEvent event) { void _emitPenDebug(PointerEvent event) {
final cb = widget.onPenDebug; final cb = widget.onPenDebug;
if (cb == null) return; if (cb == null) return;
cb('${event.kind.name} p=${event.pressure.toStringAsFixed(3)} ' final norm = _normalizedPressure(event);
'min=${event.pressureMin.toStringAsFixed(2)} ' if (norm != null && norm > _peakNorm) _peakNorm = norm;
'max=${event.pressureMax.toStringAsFixed(2)} ' cb('${event.kind.name} raw=${event.pressure.toStringAsFixed(1)}'
'tilt=${event.tilt.toStringAsFixed(2)}'); '/${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}');
} }
void _onPointerHover(PointerHoverEvent event) { void _onPointerHover(PointerHoverEvent event) {
if (_isStylus(event.kind)) { if (_isStylus(event.kind)) {
_emitPenDebug(event); _emitPenDebug(event);
// Fire edge-triggered button actions (undo / toggleTool) on hover so a
// mapped barrel press works without first touching down.
_dispatchHwButtonActions();
// Detect eraser (barrel button / inverted) while hovering. // Detect eraser (barrel button / inverted) while hovering.
_eraserActive = _isEraserSignal(event); _eraserActive = _isEraserSignal(event);
} }
@@ -274,7 +750,13 @@ class _PenCanvasState extends State<PenCanvas> {
void _onPointerDown(PointerDownEvent event) { void _onPointerDown(PointerDownEvent event) {
if (event.kind == PointerDeviceKind.trackpad) return; if (event.kind == PointerDeviceKind.trackpad) return;
if (_isStylus(event.kind)) _emitPenDebug(event); if (_isStylus(event.kind)) {
_emitPenDebug(event);
// Fire edge-triggered button actions for a direct pen-down (no prior
// hover); the native observer latched this contact's flags before Flutter
// synthesized this event (plan M1/M2).
_dispatchHwButtonActions();
}
_activePointers[event.pointer] = event.kind; _activePointers[event.pointer] = event.kind;
@@ -317,12 +799,27 @@ class _PenCanvasState extends State<PenCanvas> {
if (wasDrawer) _cancelStroke(); if (wasDrawer) _cancelStroke();
} }
@override
void dispose() {
_eraserCursor.dispose();
_inkCache.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Pan only when NOT mid-stroke; while drawing we suppress IV pan so it // The PEN never reaches PenInteractiveViewer's recognizer (it excludes
// can't fight the stroke. (A 2nd finger cancels the stroke first, so pinch // stylus), so a stylus stroke can never be stolen as a pan. panEnabled only
// re-enables pan/zoom immediately.) // governs touch/mouse: suppress pan while a single-finger / mouse stroke is
final panEnabled = _drawPointer == null; // in progress (finger-drawing mode); a 2nd pointer cancels the stroke first
// so a pinch re-enables pan/zoom immediately.
final panEnabled = widget.panEnabled && _drawPointer == null;
// Mirror committed strokes into the revision-tracked store (only re-mirrors
// when the parent handed us a new list identity).
_syncStore();
final liveEditorStroke =
_liveStroke == null ? null : EditorStroke.fromPenStroke(_liveStroke!);
return Listener( return Listener(
onPointerHover: _onPointerHover, onPointerHover: _onPointerHover,
@@ -330,38 +827,78 @@ class _PenCanvasState extends State<PenCanvas> {
onPointerMove: _onPointerMove, onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp, onPointerUp: _onPointerUp,
onPointerCancel: _onPointerCancel, onPointerCancel: _onPointerCancel,
child: InteractiveViewer( child: PenInteractiveViewer(
transformationController: widget.transformationController, transformationController: widget.transformationController,
minScale: widget.minScale, minScale: widget.minScale,
maxScale: widget.maxScale, maxScale: widget.maxScale,
panEnabled: panEnabled, panEnabled: panEnabled,
scaleEnabled: true, scaleEnabled: widget.scaleEnabled,
constrained: false,
boundaryMargin: const EdgeInsets.all(double.infinity),
child: SizedBox( child: SizedBox(
width: widget.pageSize.width, width: widget.pageSize.width,
height: widget.pageSize.height, height: widget.pageSize.height,
child: Stack( child: Stack(
children: [ children: [
// PDF page bitmap. // PDF page bitmap. Wrapped in its own RepaintBoundary (W2) so the
Positioned.fill(child: widget.pageWidget), // per-move live-ink repaints and the static-ink repaints never
// Committed ink (static layer, isolated repaint). // mark the page's raster layer dirty — isolating it from
// ink-driven repaints. (The definitive crisp-on-zoom / no-flash
// fix is the P0.5 page_tile DPI-on-settle double-buffer; this
// boundary is the safe, non-regressive interim per plan M3.)
Positioned.fill(
child: RepaintBoundary(child: widget.pageWidget),
),
// Committed ink (static layer, isolated repaint). Backed by the
// revision-gated ui.Picture cache (P0 step 3): unchanged across
// pinch/pan/live-move frames ⇒ cache hit ⇒ zero re-raster.
Positioned.fill( Positioned.fill(
child: RepaintBoundary( child: RepaintBoundary(
child: CustomPaint( child: CustomPaint(
painter: StaticInkPainter( painter: render.StaticInkPainter(
hostId: _inkHostId,
store: _store,
pageSize: widget.pageSize,
cache: _inkCache,
thinning: widget.thinning,
),
),
),
),
// Eraser preview: faint outline on strokes about to be deleted +
// the eraser circle. Mounted only in eraser mode with a cursor.
if (_isEraserMode)
Positioned.fill(
child: RepaintBoundary(
child: CustomPaint(
painter: EraserPreviewPainter(
strokes: widget.strokes, strokes: widget.strokes,
cursor: _eraserCursor,
radius: widget.eraserRadius,
aspect: _pageAspect,
pageSize: widget.pageSize, pageSize: widget.pageSize,
), ),
), ),
), ),
), ),
// Live ink (current stroke only, isolated repaint). // Live ink (current stroke only, isolated repaint). Also carries
// the SHAPE tool's preview (built as a live PenStroke).
Positioned.fill( Positioned.fill(
child: RepaintBoundary( child: RepaintBoundary(
child: CustomPaint( child: CustomPaint(
painter: LiveInkPainter( painter: render.LiveInkPainter(
stroke: _liveStroke, live: liveEditorStroke,
pageSize: widget.pageSize,
thinning: widget.thinning,
),
),
),
),
// SELECT tool: bounding box around the selected stroke.
if (widget.tool == CanvasTool.select && _selectionBounds != null)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: SelectionOverlayPainter(
boundsNorm: _selectionBounds,
pageSize: widget.pageSize, pageSize: widget.pageSize,
), ),
), ),

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,526 @@
// lib/editor/canvas/pen_interactive_viewer.dart
//
// A focused fork of Flutter 3.44's InteractiveViewer, adapted for the pen-first
// canvas (clean-room model shared with Saber). Two deliberate changes vs stock:
//
// 1. The pan/zoom ScaleGestureRecognizer is restricted to NON-stylus devices
// (`supportedDevices` excludes stylus / invertedStylus). The pen therefore
// never reaches this recognizer — it only draws via the canvas `Listener`.
// This removes the gesture-arena fight and, crucially, the one-frame
// "pan-steal" where a stylus stroke's first frame was consumed as a pan
// (the "写字识别成单击" feel bug) because stock InteractiveViewer's
// `panEnabled` only updated a frame after the stroke had begun.
//
// 2. The per-frame scale change is clamped (`_kMin/_MaxScaleChangePerFrame`).
// Stock InteractiveViewer already damps focal jitter and guards the pan
// branch, but a single-frame multi-touch glitch can still spike
// `details.scale`, popping the zoom bigger/smaller for one frame and then
// snapping back (the reported pinch flicker). Clamping the per-update change
// swallows that spike without affecting a real (gradual) pinch, since scale
// is tracked absolutely from gesture start and simply catches up next frame.
//
// Everything else (scale-about-focal math, pan, fling inertia, mouse-wheel zoom)
// is Flutter's proven logic. The boundary/rotation/panAxis machinery is dropped
// because this canvas always uses an infinite boundary, free pan, and no
// rotation — so that code was provably a no-op here.
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show clampDouble;
import 'package:flutter/gestures.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/widgets.dart';
import 'input_diagnostics.dart';
import 'pinch_scale_solver.dart';
/// Devices allowed to pan/zoom. Stylus + invertedStylus are excluded so the pen
/// is owned exclusively by the drawing `Listener`.
const Set<PointerDeviceKind> _kPanZoomDevices = <PointerDeviceKind>{
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
PointerDeviceKind.unknown,
};
/// A real pinch changes scale only modestly per frame (≲1.15x at 60fps). A frame
/// demanding far more than this is a Windows multi-touch position glitch, not
/// intent — that frame is dropped so the zoom can't pop and snap back.
/// Device logs showed spikes ~1.30; keep the band under that so jumps die.
const double _kScaleGlitchHi = 1.18;
/// During a 2-finger gesture the focal point (finger midpoint) should move
/// smoothly. A single-frame local jump beyond this is a Windows touch misread,
/// and the frame is dropped (position-jump guard).
const double _kFocalGlitchPx = 64.0;
const double _kDrag = 0.0000135;
enum _GestureType { pan, scale }
/// Pan + zoom for the pen canvas. The pen never reaches this widget's gesture
/// recognizer; only touch / mouse / trackpad pan and zoom the shared transform.
class PenInteractiveViewer extends StatefulWidget {
const PenInteractiveViewer({
super.key,
required this.transformationController,
required this.child,
this.minScale = 0.5,
this.maxScale = 8.0,
this.panEnabled = true,
this.scaleEnabled = true,
this.scaleFactor = kDefaultMouseScrollToScaleFactor,
this.interactionEndFrictionCoefficient = _kDrag,
}) : assert(minScale > 0),
assert(maxScale >= minScale);
final TransformationController transformationController;
final Widget child;
final double minScale;
final double maxScale;
final bool panEnabled;
final bool scaleEnabled;
final double scaleFactor;
final double interactionEndFrictionCoefficient;
@override
State<PenInteractiveViewer> createState() => _PenInteractiveViewerState();
}
class _PenInteractiveViewerState extends State<PenInteractiveViewer>
with TickerProviderStateMixin {
TransformationController get _transformer => widget.transformationController;
final GlobalKey _childKey = GlobalKey();
Animation<Offset>? _animation;
Animation<double>? _scaleAnimation;
late Offset _scaleAnimationFocalPoint;
late AnimationController _controller;
late AnimationController _scaleController;
Offset? _referenceFocalPoint;
double? _scaleStart;
_GestureType? _gestureType;
/// Number of pointers in the active gesture. When it changes (a finger lands
/// or lifts, or a Windows touch dropout/re-acquire), we re-baseline instead of
/// applying a frame whose scale/focal still refer to the old finger set.
int _lastPointerCount = 0;
/// The absolute scale we last APPLIED. Soft-clamp limits the step from this
/// value; we never read the live matrix back into the per-frame scale change.
double _lastAppliedScale = 1.0;
/// The recognizer's cumulative `details.scale` AT THE CURRENT BASELINE (the
/// gesture start, or the last pointer-count re-baseline). The absolute target
/// is `_scaleStart * (details.scale / _rawScaleAtBaseline)`: dividing by this
/// re-normalizes the cumulative scale so it reads 1.0 at the baseline moment.
///
/// Without this, a mid-gesture re-baseline (a finger blips 2→1→2 — routine on
/// Windows touch) captured a fresh `_scaleStart` but left `details.scale` at
/// its un-normalized cumulative value, so the next frame computed
/// `_scaleStart * 0.40` and the zoom popped to a wrong scale then snapped back
/// (the reported flicker). Normalizing kills that pop at the source.
double _rawScaleAtBaseline = 1.0;
/// Windows ScaleGestureRecognizer emits one onUpdate per finger move in the
/// same event-loop turn. Applying both mutates the matrix twice with an
/// intermediate state (Surface diag: √2-ish cur ping-pong). Keep latest only.
ScaleUpdateDetails? _pendingScaleUpdate;
bool _scaleFlushScheduled = false;
// --- Matrix helpers (infinite boundary → no clamping to bounds) -----------
Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
if (translation == Offset.zero) return matrix.clone();
return matrix.clone()
..translateByDouble(translation.dx, translation.dy, 0, 1);
}
Matrix4 _matrixScale(Matrix4 matrix, double scale) {
if (scale == 1.0) return matrix.clone();
assert(scale != 0.0);
final double currentScale = _transformer.value.getMaxScaleOnAxis();
final double clampedTotalScale = clampDouble(
currentScale * scale,
widget.minScale,
widget.maxScale,
);
final double clampedScale = clampedTotalScale / currentScale;
return matrix.clone()
..scaleByDouble(clampedScale, clampedScale, clampedScale, 1);
}
bool _gestureIsSupported(_GestureType? gestureType) => switch (gestureType) {
_GestureType.scale => widget.scaleEnabled,
_GestureType.pan || null => widget.panEnabled,
};
_GestureType _getGestureType(ScaleUpdateDetails details) {
final double scale = widget.scaleEnabled ? details.scale : 1.0;
return (scale - 1).abs() > 0 ? _GestureType.scale : _GestureType.pan;
}
// --- Gesture lifecycle ----------------------------------------------------
void _onScaleStart(ScaleStartDetails details) {
if (_controller.isAnimating) {
_controller.stop();
_controller.reset();
_animation?.removeListener(_handleInertiaAnimation);
_animation = null;
}
if (_scaleController.isAnimating) {
_scaleController.stop();
_scaleController.reset();
_scaleAnimation?.removeListener(_handleScaleAnimation);
_scaleAnimation = null;
}
_pendingScaleUpdate = null;
_scaleFlushScheduled = false;
_gestureType = null;
_lastPointerCount = details.pointerCount;
_scaleStart = _transformer.value.getMaxScaleOnAxis();
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
_lastAppliedScale = _scaleStart!;
_rawScaleAtBaseline = 1.0;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
// Pointer-count change must apply immediately (re-baseline), not coalesce.
if (details.pointerCount != _lastPointerCount) {
_pendingScaleUpdate = null;
_scaleFlushScheduled = false;
_applyScaleUpdate(details);
return;
}
_pendingScaleUpdate = details;
if (_scaleFlushScheduled) return;
_scaleFlushScheduled = true;
scheduleMicrotask(() {
_scaleFlushScheduled = false;
final pending = _pendingScaleUpdate;
_pendingScaleUpdate = null;
if (pending != null && mounted && _scaleStart != null) {
_applyScaleUpdate(pending);
}
});
}
void _applyScaleUpdate(ScaleUpdateDetails details) {
final double scale = _transformer.value.getMaxScaleOnAxis();
_scaleAnimationFocalPoint = details.localFocalPoint;
// Re-baseline on any pointer-count change so a finger landing/lifting (or a
// Windows touch dropout) can't make scale/focal jump from the stale set.
// The transitional frame itself is skipped.
if (details.pointerCount != _lastPointerCount) {
_lastPointerCount = details.pointerCount;
// Anchor the new baseline to the CLEAN tracked scale (_lastAppliedScale),
// NOT a fresh matrix read-back. Windows touch flickers the pointer count
// (2↔1↔2) mid-pinch, firing this re-baseline spuriously; reading
// getMaxScaleOnAxis() at that glitchy instant popped _scaleStart to a
// noisy value, so the absolute map K = scaleStart / rawScaleAtBaseline
// oscillated frame-to-frame (the reported "zoom jump"). Using
// _lastAppliedScale makes the displayed scale CONTINUOUS across the
// re-baseline: target == _lastAppliedScale at this instant, regardless of
// any transient in the live matrix.
_scaleStart = _lastAppliedScale;
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
// Re-anchor the cumulative scale to THIS frame's details.scale so the next
// good frame resumes from _scaleStart (not _scaleStart × a stale ratio).
_rawScaleAtBaseline = details.scale;
InputDiagnostics.instance.recordRebaseline();
return;
}
final double focalJumpPx = details.focalPointDelta.distance;
final Offset focalPointScene = _transformer.toScene(details.localFocalPoint);
if (_gestureType == _GestureType.pan) {
// A 2-finger gesture can start with no scale change; allow re-typing it.
_gestureType = _getGestureType(details);
} else {
_gestureType ??= _getGestureType(details);
}
if (!_gestureIsSupported(_gestureType)) return;
// Position-jump guard: during a pinch the focal midpoint should move
// smoothly; a big single-frame jump is a touch misread → drop the frame.
final bool focalDrop =
details.pointerCount >= 2 && focalJumpPx > _kFocalGlitchPx;
void record(
double currentScale,
double appliedChange,
bool softClamped,
bool focalDropped,
) {
InputDiagnostics.instance.recordScaleFrame(
rawScale: details.scale,
pointerCount: details.pointerCount,
currentScale: currentScale,
appliedChange: appliedChange,
focalJumpPx: focalJumpPx,
scaleDrop: false,
softClamped: softClamped,
focalDrop: focalDropped,
);
}
switch (_gestureType!) {
case _GestureType.scale:
assert(_scaleStart != null);
// Soft-clamp per-step change instead of hard-dropping (Surface diag:
// hard SDROP froze lastRaw and avalanched while the matrix still moved).
final SoftPinchStep step = softClampedPinchStep(
scaleStart: _scaleStart!,
rawScaleAtBaseline: _rawScaleAtBaseline,
rawScale: details.scale,
lastAppliedScale: _lastAppliedScale,
minScale: widget.minScale,
maxScale: widget.maxScale,
maxStepRatio: _kScaleGlitchHi,
);
if (focalDrop) {
if (step.reanchor) {
_scaleStart = step.appliedScale;
_rawScaleAtBaseline = details.scale;
_lastAppliedScale = step.appliedScale;
}
record(_lastAppliedScale, 1.0, step.spiked, true);
return;
}
final double targetScale = step.appliedScale;
if (step.reanchor) {
_scaleStart = targetScale;
_rawScaleAtBaseline = details.scale;
}
final Offset focal = details.localFocalPoint;
final double tx = focal.dx - targetScale * _referenceFocalPoint!.dx;
final double ty = focal.dy - targetScale * _referenceFocalPoint!.dy;
_transformer.value = Matrix4.identity()
..setEntry(0, 0, targetScale)
..setEntry(1, 1, targetScale)
..setEntry(2, 2, targetScale)
..setTranslationRaw(tx, ty, 0);
final double applied =
_lastAppliedScale > 0 ? targetScale / _lastAppliedScale : 1.0;
_lastAppliedScale = targetScale;
record(targetScale, applied, step.spiked, false);
case _GestureType.pan:
assert(_referenceFocalPoint != null);
// Throw away near-scale frames so a stale reference can't jump the pan.
if (details.scale != 1.0) return;
if (focalDrop) {
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
record(scale, 1.0, false, true);
return;
}
final Offset translationChange =
focalPointScene - _referenceFocalPoint!;
_transformer.value =
_matrixTranslate(_transformer.value, translationChange);
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
record(scale, 1.0, false, false);
}
}
void _onScaleEnd(ScaleEndDetails details) {
final pending = _pendingScaleUpdate;
_pendingScaleUpdate = null;
_scaleFlushScheduled = false;
if (pending != null && _scaleStart != null) {
_applyScaleUpdate(pending);
}
_scaleStart = null;
_referenceFocalPoint = null;
_lastPointerCount = 0;
_animation?.removeListener(_handleInertiaAnimation);
_scaleAnimation?.removeListener(_handleScaleAnimation);
_controller.reset();
_scaleController.reset();
if (!_gestureIsSupported(_gestureType)) return;
switch (_gestureType) {
case _GestureType.pan:
if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) {
return;
}
final translationVector = _transformer.value.getTranslation();
final Offset translation =
Offset(translationVector.x, translationVector.y);
final FrictionSimulation frictionSimulationX = FrictionSimulation(
widget.interactionEndFrictionCoefficient,
translation.dx,
details.velocity.pixelsPerSecond.dx,
);
final FrictionSimulation frictionSimulationY = FrictionSimulation(
widget.interactionEndFrictionCoefficient,
translation.dy,
details.velocity.pixelsPerSecond.dy,
);
final double tFinal = _getFinalTime(
details.velocity.pixelsPerSecond.distance,
widget.interactionEndFrictionCoefficient,
);
_animation = Tween<Offset>(
begin: translation,
end: Offset(frictionSimulationX.finalX, frictionSimulationY.finalX),
).animate(CurvedAnimation(parent: _controller, curve: Curves.decelerate));
_controller.duration = Duration(milliseconds: (tFinal * 1000).round());
_animation!.addListener(_handleInertiaAnimation);
_controller.forward();
case _GestureType.scale:
// No scale fling: Windows touch often reports noisy scaleVelocity that
// animates past the intended zoom and feels like a "jump" after pinch.
return;
case null:
break;
}
}
// --- Mouse wheel / trackpad zoom ------------------------------------------
void _receivedPointerSignal(PointerSignalEvent event) {
final double scaleChange;
if (event is PointerScrollEvent) {
if (event.kind == PointerDeviceKind.trackpad) {
// Trackpad scroll → pan.
if (!_gestureIsSupported(_GestureType.pan)) return;
final Offset localDelta = PointerEvent.transformDeltaViaPositions(
untransformedEndPosition: event.position + event.scrollDelta,
untransformedDelta: event.scrollDelta,
transform: event.transform,
);
final Offset focalPointScene = _transformer.toScene(event.localPosition);
final Offset newFocalPointScene =
_transformer.toScene(event.localPosition - localDelta);
_transformer.value = _matrixTranslate(
_transformer.value,
newFocalPointScene - focalPointScene,
);
return;
}
if (event.scrollDelta.dy == 0.0) return;
scaleChange = math.exp(-event.scrollDelta.dy / widget.scaleFactor);
} else if (event is PointerScaleEvent) {
scaleChange = event.scale;
} else {
return;
}
if (!_gestureIsSupported(_GestureType.scale)) return;
final Offset focalPointScene = _transformer.toScene(event.localPosition);
_transformer.value = _matrixScale(_transformer.value, scaleChange);
final Offset focalPointSceneScaled =
_transformer.toScene(event.localPosition);
_transformer.value = _matrixTranslate(
_transformer.value,
focalPointSceneScaled - focalPointScene,
);
}
void _handleInertiaAnimation() {
if (!_controller.isAnimating) {
_animation?.removeListener(_handleInertiaAnimation);
_animation = null;
_controller.reset();
return;
}
final translationVector = _transformer.value.getTranslation();
final Offset translation = Offset(translationVector.x, translationVector.y);
_transformer.value = _matrixTranslate(
_transformer.value,
_transformer.toScene(_animation!.value) - _transformer.toScene(translation),
);
}
void _handleScaleAnimation() {
if (!_scaleController.isAnimating) {
_scaleAnimation?.removeListener(_handleScaleAnimation);
_scaleAnimation = null;
_scaleController.reset();
return;
}
final double desiredScale = _scaleAnimation!.value;
final double scaleChange =
desiredScale / _transformer.value.getMaxScaleOnAxis();
final Offset referenceFocalPoint =
_transformer.toScene(_scaleAnimationFocalPoint);
_transformer.value = _matrixScale(_transformer.value, scaleChange);
final Offset focalPointSceneScaled =
_transformer.toScene(_scaleAnimationFocalPoint);
_transformer.value = _matrixTranslate(
_transformer.value,
focalPointSceneScaled - referenceFocalPoint,
);
}
void _handleTransformation() => setState(() {});
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this);
_scaleController = AnimationController(vsync: this);
_transformer.addListener(_handleTransformation);
}
@override
void didUpdateWidget(PenInteractiveViewer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.transformationController != widget.transformationController) {
oldWidget.transformationController.removeListener(_handleTransformation);
widget.transformationController.addListener(_handleTransformation);
}
}
@override
void dispose() {
_controller.dispose();
_scaleController.dispose();
_transformer.removeListener(_handleTransformation);
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget child = Transform(
transform: _transformer.value,
child: KeyedSubtree(key: _childKey, child: widget.child),
);
child = OverflowBox(
alignment: Alignment.topLeft,
minWidth: 0.0,
minHeight: 0.0,
maxWidth: double.infinity,
maxHeight: double.infinity,
child: child,
);
child = ClipRect(child: child);
return Listener(
onPointerSignal: _receivedPointerSignal,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
supportedDevices: _kPanZoomDevices,
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
onScaleEnd: _onScaleEnd,
trackpadScrollCausesScale: false,
trackpadScrollToScaleFactor: Offset(0, -1 / widget.scaleFactor),
child: child,
),
);
}
}
double _getFinalTime(double velocity, double drag,
{double effectivelyMotionless = 10}) {
return math.log(effectivelyMotionless / velocity) / math.log(drag / 100);
}

View File

@@ -0,0 +1,921 @@
// lib/editor/canvas/pen_note_screen.dart
//
// Pen-first blank-note editor. Reuses the single performant inking engine
// (PenCanvas) over a white logical page instead of a PDF page, and persists
// strokes back to the Note model via the InkStroke<->PenStroke adapter. This is
// the note half of "all note features on the pen-first canvas".
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../../models/ink_stroke.dart';
import '../../models/note.dart';
import '../../providers/note_provider.dart';
import '../../providers/ocr_provider.dart';
import '../engine/brush.dart';
import '../engine/shape_geometry.dart';
import '../engine/stroke_model.dart';
import '../persistence/sidecar_repository.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../input/pen_slots.dart';
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
import '../layout/viewport_fit.dart';
import '../notebook/ink_stroke_adapter.dart';
import '../ui/pen_settings_page.dart';
import 'editor_tool.dart';
import 'note_background.dart';
import 'pen_canvas.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
class PenNoteScreen extends ConsumerStatefulWidget {
const PenNoteScreen({super.key, this.note});
/// Existing note to edit, or null for a new note.
final Note? note;
@override
ConsumerState<PenNoteScreen> createState() => _PenNoteScreenState();
}
class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
static const _uuid = Uuid();
/// Per-page live strokes in normalized coords (the canvas source of truth).
final Map<int, List<PenStroke>> _strokesByPage = {};
/// Current page index (0-based) and total page count (min 1).
int _pageIndex = 0;
int _pageCount = 1;
/// Snapshot-before-change undo/redo scoped to the current page. Cleared on
/// page switch so undo never crosses pages.
final List<List<PenStroke>> _undo = [];
final List<List<PenStroke>> _redo = [];
bool _showPageScrubber = false;
double? _pageScrub;
/// The single active-tool state (shared model across the 3 editors).
EditorToolKind _tool = EditorToolKind.brush;
/// Selected shape for the SHAPE tool.
ShapeKind _shapeKind = ShapeKind.line;
/// Index of the currently selected committed stroke (SELECT tool), or null.
int? _selectedStroke;
/// Highlighter keeps its own color (not a pen slot).
Color _highlighterColor = Colors.orange;
/// Active pen brush from the selected slot (fallback until slots load).
BrushKind get _penBrush =>
_penSlots?.active.brush ?? BrushKind.fountainPen;
/// Active drawing color: highlighter tool uses [_highlighterColor], else the
/// active pen slot's color.
Color get _color => _tool == EditorToolKind.highlighter
? _highlighterColor
: (_penSlots?.active.color ?? Colors.black);
/// The page-background template painted behind the ink (rnote-style). Default
/// blank; persisted per-notebook in the sidecar.
NoteBackground _background = NoteBackground.blank;
bool _allowFingerDrawing = false;
bool _dirty = false;
bool _needsCenter = true;
/// The note's synthetic source path `<folder>/notebook` (also the note id).
/// Persistence flows through this note's `notebook.badnote.json` sidecar.
String? _notePath;
/// Per-file sidecar persistence sink (strokes + pageCount + title), debounced
/// and atomic — replaces the old SQLite Note/noteListProvider write path here.
SidecarRepository? _repo;
final TextEditingController _titleController = TextEditingController();
PenConfigController? _penConfig;
PenSlotsController? _penSlots;
final TransformationController _transform = TransformationController();
static const double _highlighterWidthFraction = 0.02;
static const List<Color> _palette = kInkPalette;
/// Current page's stroke list (PenCanvas source of truth).
List<PenStroke> get _strokes =>
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]);
set _strokes(List<PenStroke> value) {
_strokesByPage[_pageIndex] = value;
}
@override
void initState() {
super.initState();
PenInputService.instance.start();
final note = widget.note;
if (note != null) {
_notePath = note.id;
_titleController.text = note.title;
// Seed from the in-memory note's strokes (e.g. tests) until the sidecar
// load resolves and (if present) overrides with persisted strokes.
_strokesByPage[0] = penStrokesFromInk(note.strokes, kNoteLogicalPage);
} else {
_titleController.text = 'Untitled';
}
_initPenConfig();
if (_notePath != null) _initPersistence(_notePath!);
}
/// Open the note's `notebook.badnote.json` sidecar and hydrate every page of
/// strokes plus title / background / pageCount.
Future<void> _initPersistence(String notePath) async {
final repo = await SidecarRepository.open(notePath, docType: 'notebook');
if (!mounted) {
repo.dispose();
return;
}
_repo = repo;
setState(() {
_hydrateFromRepo(repo);
});
}
/// Load all pages from [repo]. pageCount = max(sidecar.pageCount ?? 1,
/// highest stroke key + 1). Persists pageCount when the sidecar omitted it.
void _hydrateFromRepo(SidecarRepository repo) {
// Only replace in-memory strokes when the sidecar actually holds ink —
// otherwise keep the seed from widget.note (widget tests / cold open).
if (repo.loadedStrokes.isNotEmpty) {
_strokesByPage.clear();
for (final entry in repo.loadedStrokes.entries) {
if (entry.value.isEmpty) continue;
_strokesByPage[entry.key] = [
for (final es in entry.value) _penStrokeFromEditor(es),
];
}
}
final fromKeys = _strokesByPage.isEmpty
? 1
: _strokesByPage.keys.reduce((a, b) => a > b ? a : b) + 1;
final declared = repo.sidecar.pageCount ?? 1;
_pageCount = declared > fromKeys ? declared : fromKeys;
if (_pageCount < 1) _pageCount = 1;
if (_pageIndex >= _pageCount) _pageIndex = _pageCount - 1;
_undo.clear();
_redo.clear();
_selectedStroke = null;
final title = repo.loadedTitle;
if (title != null && title.isNotEmpty) {
_titleController.text = title;
}
_background = noteBackgroundFromName(repo.loadedBackground);
if (repo.sidecar.pageCount != _pageCount) {
repo.schedulePageCountSave(_pageCount);
}
}
/// EditorStroke → live PenStroke (mirror of the PDF editor's loader). Brush
/// is persisted on the EditorStroke now, so carry it through; old sidecars
/// without the field decode to fountainPen (back-compat default).
PenStroke _penStrokeFromEditor(EditorStroke es) => PenStroke(
points: es.points
.map((ep) => PenPoint(ep.x, ep.y, ep.pressure, tilt: ep.tilt))
.toList(),
color: es.color,
width: es.width,
kind: es.tool == EditorTool.highlighter
? PenStrokeKind.highlighter
: PenStrokeKind.pen,
brush: es.brush,
);
Future<void> _initPenConfig() async {
final results = await Future.wait([
PenConfigController.load(),
PenSlotsController.load(),
]);
final config = results[0] as PenConfigController;
final slots = results[1] as PenSlotsController;
if (!mounted) {
config.dispose();
slots.dispose();
return;
}
config.addListener(_onPenConfigChanged);
slots.addListener(_onPenSlotsChanged);
setState(() {
_penConfig = config;
_penSlots = slots;
_allowFingerDrawing = config.value.fingerDrawing;
});
}
void _onPenConfigChanged() {
if (mounted) setState(() {});
}
void _onPenSlotsChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
// Flush any pending sidecar write before tearing down (atomic write
// completes off the widget tree).
final repo = _repo;
if (repo != null) {
repo.flush();
repo.dispose();
}
_penConfig?.removeListener(_onPenConfigChanged);
_penConfig?.dispose();
_penSlots?.removeListener(_onPenSlotsChanged);
_penSlots?.dispose();
_titleController.dispose();
_transform.dispose();
super.dispose();
}
// ── Mutations ──────────────────────────────────────────────────────────────
void _pushUndo() {
_undo.add(List<PenStroke>.from(_strokes));
_redo.clear();
}
void _commitStroke(PenStroke stroke) {
setState(() {
_pushUndo();
_strokes = [..._strokes, stroke];
_dirty = true;
});
}
void _eraseStroke(int index, List<PenStroke> replacements) {
if (index < 0 || index >= _strokes.length) return;
setState(() {
_pushUndo();
_strokes = [
..._strokes.sublist(0, index),
...replacements,
..._strokes.sublist(index + 1),
];
_dirty = true;
});
}
void _performUndo() {
if (_undo.isEmpty) return;
setState(() {
_redo.add(List<PenStroke>.from(_strokes));
_strokes = _undo.removeLast();
_dirty = true;
});
}
void _performRedo() {
if (_redo.isEmpty) return;
setState(() {
_undo.add(List<PenStroke>.from(_strokes));
_strokes = _redo.removeLast();
_dirty = true;
});
}
void _toggleFingerDrawing() {
final next = !_allowFingerDrawing;
setState(() => _allowFingerDrawing = next);
_penConfig?.setFingerDrawing(next);
}
// ── Persistence ──────────────────────────────────────────────────────────────
/// Schedule a stroke save for [pageIndex] (defaults to current) without
/// flushing. Used when switching pages so ink isn't lost mid-edit.
void _schedulePageStrokeSave([int? pageIndex]) {
final repo = _repo;
if (repo == null) return;
final idx = pageIndex ?? _pageIndex;
final pageStrokes = _strokesByPage[idx] ?? const <PenStroke>[];
final editorStrokes = <EditorStroke>[
for (final s in pageStrokes) EditorStroke.fromPenStroke(s),
];
repo.scheduleStrokeSave(idx, editorStrokes);
}
void _goToPage(int index) {
if (_pageCount < 1) return;
final clamped = index.clamp(0, _pageCount - 1);
if (clamped == _pageIndex) {
setState(() {
_pageScrub = null;
_showPageScrubber = false;
});
return;
}
_schedulePageStrokeSave(_pageIndex);
setState(() {
_pageIndex = clamped;
_pageScrub = null;
_showPageScrubber = false;
_selectedStroke = null;
_undo.clear();
_redo.clear();
});
}
void _addPage() {
_schedulePageStrokeSave(_pageIndex);
setState(() {
_pageCount += 1;
_pageIndex = _pageCount - 1;
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]);
_pageScrub = null;
_showPageScrubber = false;
_selectedStroke = null;
_undo.clear();
_redo.clear();
_dirty = true;
});
_repo?.schedulePageCountSave(_pageCount);
}
/// Persist the live pen strokes + title + pageCount to the note's
/// `notebook.badnote.json` sidecar, debounced/atomic via [SidecarRepository].
/// Creates the notebook folder lazily on first save when the screen was opened
/// without a path. Refreshes the home list and triggers local OCR for search.
Future<void> _save() async {
if (!_dirty) return;
final notifier = ref.read(noteListProvider.notifier);
final now = DateTime.now();
final title = _titleController.text.trim().isEmpty
? 'Untitled'
: _titleController.text.trim();
// Lazily create the notebook folder + sidecar repo on first save.
if (_repo == null) {
final created = await notifier.createNote(title: title);
if (!mounted) return;
_notePath = created.id;
final repo =
await SidecarRepository.open(created.id, docType: 'notebook');
if (!mounted) {
repo.dispose();
return;
}
_repo = repo;
}
final repo = _repo!;
repo.scheduleTitleSave(title);
repo.scheduleBackgroundSave(_background.name);
repo.schedulePageCountSave(_pageCount);
// Persist every page that has (or had) strokes in this session. Empty pages
// clear their sidecar entry via scheduleStrokeSave.
for (final idx in _strokesByPage.keys.toList()..sort()) {
_schedulePageStrokeSave(idx);
}
// Also ensure the current page is written even if never putIfAbsent'd empty.
_schedulePageStrokeSave(_pageIndex);
await repo.flush();
// Refresh the home list so the title/recency update is visible on return.
await notifier.loadNotes();
if (!mounted) return;
setState(() => _dirty = false);
// Build an in-memory Note (id = note path) for OCR/FTS indexing only —
// flatten all pages into one stroke list.
final inkStrokes = <InkStroke>[
for (final page in _strokesByPage.values)
for (final s in page)
inkStrokeFromPen(s, kNoteLogicalPage, id: _uuid.v4(), createdAt: now),
];
_runLocalOcr(Note(
id: _notePath!,
title: title,
strokes: inkStrokes,
createdAt: now,
updatedAt: now,
));
}
void _runLocalOcr(Note note) {
final id = note.id;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
id: OcrStatus.processing,
};
ref.read(ocrServiceProvider).processNote(note).then((_) {
if (!mounted) return;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
id: OcrStatus.done,
};
}).catchError((_) {
if (!mounted) return;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
id: OcrStatus.failed,
};
});
}
// ── Layout helpers ──────────────────────────────────────────────────────────
void _centerPage(Size viewport, Size pageSize) {
final o = centerOffset(pageSize, viewport, 1.0);
_transform.value = Matrix4.identity()..translateByDouble(o.dx, o.dy, 0, 1);
}
double get _strokeWidth => _tool == EditorToolKind.highlighter
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
: (_penSlots?.active.width ?? 0.006);
// ── SELECT tool: select / move / delete (reuses the undo stacks) ─────────────
/// Set (or clear) the selected stroke from a SELECT-tool tap.
void _selectStroke(int? index) {
setState(() => _selectedStroke = index);
}
/// Translate the selected stroke by ([dx],[dy]) normalized. On the first delta
/// of a drag ([isDragStart]) push ONE undo snapshot so the whole drag is a
/// single undo step.
void _moveStroke(int index, double dx, double dy, bool isDragStart) {
if (index < 0 || index >= _strokes.length) return;
setState(() {
if (isDragStart) _pushUndo();
final next = List<PenStroke>.from(_strokes);
next[index] = translateStroke(next[index], dx, dy);
_strokes = next;
_dirty = true;
});
}
/// Delete the selected stroke (button or long-press), as one undo step.
void _deleteSelected() {
final idx = _selectedStroke;
if (idx == null || idx < 0 || idx >= _strokes.length) return;
setState(() {
_pushUndo();
_strokes = [
..._strokes.sublist(0, idx),
..._strokes.sublist(idx + 1),
];
_selectedStroke = null;
_dirty = true;
});
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, _) {
if (didPop && _dirty) _save();
},
child: Scaffold(
body: Stack(
children: [
Positioned.fill(child: _buildCanvas()),
// Tool palette (top-center) — identical chrome to the PDF editor.
SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: _buildToolPalette(cs),
),
),
),
// Back (saves on the way out).
SafeArea(
child: Padding(
padding: const EdgeInsets.all(8),
child: RoundIconButton(
icon: Icons.arrow_back,
tooltip: 'Back',
onPressed: () async {
final navigator = Navigator.of(context);
await _save();
if (mounted) navigator.maybePop();
},
),
),
),
// Title + page chrome (bottom-center).
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildPagePill(cs),
const SizedBox(height: 8),
_buildTitlePill(cs),
],
),
),
),
),
],
),
),
);
}
Widget _buildCanvas() {
return LayoutBuilder(
builder: (context, constraints) {
// Fit the logical note page into the viewport at scale 1.0.
final fitW = constraints.maxWidth / kNoteLogicalPage.width;
final fitH = constraints.maxHeight / kNoteLogicalPage.height;
final scale = fitW < fitH ? fitW : fitH;
final pageSize = Size(
kNoteLogicalPage.width * scale,
kNoteLogicalPage.height * scale,
);
if (_needsCenter) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_centerPage(
Size(constraints.maxWidth, constraints.maxHeight), pageSize);
setState(() => _needsCenter = false);
});
}
return PenCanvas(
pageSize: pageSize,
strokes: _strokes,
transformationController: _transform,
tool: editorToolToCanvas(_tool),
brush: _penBrush,
shapeKind: _shapeKind,
color: _color,
strokeWidth: _strokeWidth,
selectedStrokeIndex: _selectedStroke,
onSelectStroke: _selectStroke,
onMoveStroke: _moveStroke,
pressureGamma:
_penConfig?.value.pressureGamma ?? kNaturalPressureGamma,
eraserRadius: _penConfig?.value.eraserRadius ?? kDefaultEraserRadius,
eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false,
sideButtonAction:
_penConfig?.value.sideButton ?? PenButtonAction.select,
eraserEndAction:
_penConfig?.value.eraserEnd ?? PenButtonAction.eraser,
allowFingerDrawing: _allowFingerDrawing,
onStrokeComplete: _commitStroke,
onEraseStroke: _eraseStroke,
onPenButtonAction: (action) {
if (action == PenButtonAction.select) {
setState(() => _tool = EditorToolKind.select);
} else if (action == PenButtonAction.undo) {
if (_undo.isNotEmpty) _performUndo();
} else if (action == PenButtonAction.toggleTool) {
setState(() {
_tool = _tool == EditorToolKind.eraser
? EditorToolKind.brush
: EditorToolKind.eraser;
});
}
},
// A white sheet with a soft shadow — the note "paper" — overlaid with
// the selected background template, painted in page-pixel space (so it
// scales with zoom) and BEHIND the ink layers.
pageWidget: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 12,
spreadRadius: 1,
),
],
),
child: CustomPaint(
painter: NoteBackgroundPainter(_background),
size: Size.infinite,
),
),
);
},
);
}
Widget _buildToolPalette(ColorScheme cs) {
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// OneNote-style: each pen slot restores brush + color + thickness.
for (final slot in _penSlots?.slots ?? kDefaultPenSlots())
PenSlotButton(
kind: slot.brush,
selected: _tool == EditorToolKind.brush &&
(_penSlots?.activeId ?? 'slot_0') == slot.id,
color: slot.color,
widthHint: slot.width,
tooltip: brushLabelEn(slot.brush),
onPressed: () {
_penSlots?.select(slot.id);
setState(() => _tool = EditorToolKind.brush);
},
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == EditorToolKind.highlighter,
tooltip: 'Highlighter',
onPressed: () =>
setState(() => _tool = EditorToolKind.highlighter),
),
ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _tool == EditorToolKind.eraser,
tooltip: 'Eraser',
onPressed: () => setState(() => _tool = EditorToolKind.eraser),
),
// Select (cursor) + shape tools.
ToolButton(
icon: Icons.ads_click,
selected: _tool == EditorToolKind.select,
tooltip: 'Select',
onPressed: () => setState(() => _tool = EditorToolKind.select),
),
ShapePickerButton(
selected: _shapeKind,
active: _tool == EditorToolKind.shape,
tooltip: 'Shape',
labelFor: shapeLabelEn,
onActivate: () => setState(() => _tool = EditorToolKind.shape),
onSelected: (s) => setState(() {
_shapeKind = s;
_tool = EditorToolKind.shape;
}),
),
if (_tool == EditorToolKind.select && _selectedStroke != null)
ToolButton(
icon: Icons.delete_outline,
selected: false,
tooltip: 'Delete selection',
onPressed: _deleteSelected,
),
PaletteDivider(cs: cs),
ToolButton(
icon: Icons.undo,
selected: false,
tooltip: 'Undo',
onPressed: _undo.isNotEmpty ? _performUndo : null,
),
ToolButton(
icon: Icons.redo,
selected: false,
tooltip: 'Redo',
onPressed: _redo.isNotEmpty ? _performRedo : null,
),
PaletteDivider(cs: cs),
for (final c in _palette) _colorDot(c, cs),
ThicknessPickerButton(
width: _penSlots?.active.width ?? 0.006,
onChanged: (w) => _penSlots?.setActiveWidth(w),
),
PaletteDivider(cs: cs),
// Page-background template picker (rnote-style: blank / dots / ruled
// / grid / cornell). Persists per-notebook in the sidecar.
PopupMenuButton<NoteBackground>(
tooltip: 'Page background',
initialValue: _background,
onSelected: (b) {
setState(() {
_background = b;
_dirty = true;
});
},
itemBuilder: (context) => [
for (final b in NoteBackground.values)
PopupMenuItem<NoteBackground>(
value: b,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(noteBackgroundIcon(b), size: 20),
const SizedBox(width: 10),
Text(noteBackgroundLabel(b)),
if (b == _background) ...[
const SizedBox(width: 8),
Icon(Icons.check, size: 18, color: cs.primary),
],
],
),
),
],
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
noteBackgroundIcon(_background),
size: 22,
color: cs.onSurfaceVariant,
),
Icon(
Icons.arrow_drop_down,
size: 18,
color: cs.onSurfaceVariant,
),
],
),
),
),
PaletteDivider(cs: cs),
ToolButton(
icon:
_allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
selected: _allowFingerDrawing,
tooltip: _allowFingerDrawing
? 'Finger drawing ON'
: 'Finger drawing OFF (pen only)',
onPressed: _toggleFingerDrawing,
),
ToolButton(
icon: Icons.settings_outlined,
selected: false,
tooltip: 'Pen settings (width, pressure, eraser…)',
onPressed: _penConfig != null
? () => showPenSettingsSheet(context, _penConfig!)
: null,
),
],
),
),
),
);
}
Widget _colorDot(Color c, ColorScheme cs) {
// Selected against the ACTIVE slot (or highlighter) color. A color tap
// updates only the active slot / highlighter — not other slots.
final selected = _color.toARGB32() == c.toARGB32() &&
_tool != EditorToolKind.eraser &&
_tool != EditorToolKind.select;
return GestureDetector(
onTap: () {
if (_tool == EditorToolKind.eraser ||
_tool == EditorToolKind.select) {
setState(() => _tool = EditorToolKind.brush);
}
if (_tool == EditorToolKind.highlighter) {
setState(() => _highlighterColor = c);
} else {
_penSlots?.setActiveColor(c);
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: 24,
height: 24,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: selected ? cs.onSurface : cs.outlineVariant,
width: selected ? 3 : 1,
),
),
),
);
}
Widget _buildTitlePill(ColorScheme cs) {
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: TextField(
controller: _titleController,
textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600),
decoration: const InputDecoration(
border: InputBorder.none,
hintText: 'Note title…',
isDense: true,
),
onChanged: (_) => _dirty = true,
),
),
),
);
}
/// Compact page chrome: prev / "n / total" / next, plus add-page. Tapping the
/// center label toggles a scrubber Slider when there is more than one page.
Widget _buildPagePill(ColorScheme cs) {
final total = _pageCount;
final scrub = _pageScrub;
final shown = (scrub ?? (_pageIndex + 1).toDouble()).round();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (total > 1 && _showPageScrubber)
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 = v);
_goToPage(v.round() - 1);
},
),
),
),
),
Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Previous page',
icon: const Icon(Icons.chevron_left),
onPressed: _pageIndex > 0
? () => _goToPage(_pageIndex - 1)
: null,
),
TextButton(
onPressed: () {
if (total > 1) {
setState(() => _showPageScrubber = !_showPageScrubber);
}
},
child: Text(
'$shown / $total',
style: TextStyle(
color: cs.onSurface,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: 'Next page',
icon: const Icon(Icons.chevron_right),
onPressed: _pageIndex < total - 1
? () => _goToPage(_pageIndex + 1)
: null,
),
IconButton(
tooltip: 'Add page',
icon: const Icon(Icons.add),
onPressed: _addPage,
),
],
),
),
),
],
);
}
}

View File

@@ -0,0 +1,547 @@
// lib/editor/canvas/pen_palette_widgets.dart
//
// Shared Material 3 chrome for the pen-first editors (PDF, note, slide) so the
// floating tool palette looks and behaves identically everywhere — one source
// of truth for the inking UI.
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../engine/brush.dart';
import '../input/pen_slots.dart';
import 'editor_tool.dart';
/// Shared ink color palette for PDF / note / slide / scratch editors.
const List<Color> kInkPalette = <Color>[
Color(0xFF1A1A1A),
Color(0xFFC62828),
Color(0xFF1565C0),
Color(0xFF2E7D32),
Color(0xFFEF6C00),
Color(0xFF6A1B9A),
Color(0xFF00838F),
Color(0xFF5D4037),
Color(0xFFF9A825),
Color(0xFFE91E63),
Color(0xFF455A64),
Color(0xFF37474F),
];
/// Localized display name for a brush (single source so all three editors agree).
String brushLabel(BrushKind kind, AppLocalizations l) => switch (kind) {
BrushKind.fountainPen => l.brushFountainPen,
BrushKind.ballpoint => l.brushBallpoint,
BrushKind.pencil => l.brushPencil,
BrushKind.highlighter => l.brushHighlighter,
};
/// English fallback brush name, for the note/slide editors which (like their
/// other chrome) use hardcoded English strings rather than [AppLocalizations]
/// (their test harness mounts a MaterialApp without localization delegates).
/// TODO(brush-l10n-noteslide): localize the note/slide toolbars wholesale.
String brushLabelEn(BrushKind kind) => switch (kind) {
BrushKind.fountainPen => 'Fountain pen',
BrushKind.ballpoint => 'Ballpoint',
BrushKind.pencil => 'Pencil',
BrushKind.highlighter => 'Highlighter',
};
/// Localized display name for a shape kind.
String shapeLabel(ShapeKind kind, AppLocalizations l) => switch (kind) {
ShapeKind.line => l.shapeLine,
ShapeKind.rectangle => l.shapeRectangle,
ShapeKind.ellipse => l.shapeEllipse,
ShapeKind.arrow => l.shapeArrow,
};
/// English fallback shape name (for the note/slide editors which use hardcoded
/// English strings — see [brushLabelEn]).
/// TODO(brush-l10n-noteslide): localize the note/slide toolbars wholesale.
String shapeLabelEn(ShapeKind kind) => switch (kind) {
ShapeKind.line => 'Line',
ShapeKind.rectangle => 'Rectangle',
ShapeKind.ellipse => 'Ellipse',
ShapeKind.arrow => 'Arrow',
};
/// The brushes selectable as the PEN tool. The highlighter is its own tool, so
/// it is NOT offered here (eraser is also a separate tool).
const List<BrushKind> kPenToolBrushes = [
BrushKind.fountainPen,
BrushKind.ballpoint,
BrushKind.pencil,
];
/// Material icon for a brush (used in the brush picker + the pen tool button).
IconData brushIcon(BrushKind kind) => switch (kind) {
BrushKind.fountainPen => Icons.edit_outlined, // nib pen
BrushKind.ballpoint => Icons.create_outlined, // ballpoint
BrushKind.pencil => Icons.draw_outlined, // pencil
BrushKind.highlighter => Icons.brush_outlined, // marker
};
/// Material icon for a [ShapeKind] (used in the shape picker + tool button).
IconData shapeIcon(ShapeKind kind) => switch (kind) {
ShapeKind.line => Icons.show_chart, // straight line
ShapeKind.rectangle => Icons.crop_square,
ShapeKind.ellipse => Icons.circle_outlined,
ShapeKind.arrow => Icons.arrow_outward,
};
/// OneNote-style pen slot: each slot is its own toolbar button with a color
/// underline (slot-remembered color). Prefer this over [BrushPickerButton]
/// when the UX wants pens visible side-by-side.
class PenSlotButton extends StatelessWidget {
const PenSlotButton({
super.key,
required this.kind,
required this.selected,
required this.color,
required this.tooltip,
required this.onPressed,
this.widthHint,
});
final BrushKind kind;
final bool selected;
final Color color;
final String tooltip;
final VoidCallback onPressed;
/// Optional page-width fraction; when set, underline height scales slightly
/// so thicker slots read visually thicker. Null keeps the fixed 3px bar.
final double? widthHint;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final iconColor =
selected ? cs.onSecondaryContainer : cs.onSurfaceVariant;
final barHeight = widthHint == null
? 3.0
: (2.0 + (widthHint! / kThicknessLarge).clamp(0.0, 1.0) * 3.0);
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(20),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.fromLTRB(6, 8, 6, 6),
decoration: BoxDecoration(
color: selected ? cs.secondaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(brushIcon(kind), size: 22, color: iconColor),
const SizedBox(height: 3),
Container(
width: 16,
height: barHeight,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
],
),
),
),
);
}
}
/// Compact thickness control: S / M / L presets + a custom slider. Writes the
/// chosen page-width fraction via [onChanged] (typically
/// [PenSlotsController.setActiveWidth]).
class ThicknessPickerButton extends StatelessWidget {
const ThicknessPickerButton({
super.key,
required this.width,
required this.onChanged,
this.tooltip = 'Thickness',
});
/// Current stroke width (page-width fraction).
final double width;
final ValueChanged<double> onChanged;
final String tooltip;
static String _labelFor(double w) {
if ((w - kThicknessSmall).abs() < 0.0003) return 'S';
if ((w - kThicknessMedium).abs() < 0.0003) return 'M';
if ((w - kThicknessLarge).abs() < 0.0003) return 'L';
return '·';
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return PopupMenuButton<double>(
tooltip: tooltip,
onSelected: onChanged,
itemBuilder: (context) => [
PopupMenuItem<double>(
value: kThicknessSmall,
child: Row(
children: [
const Text('S'),
const Spacer(),
if ((width - kThicknessSmall).abs() < 0.0003)
Icon(Icons.check, size: 18, color: cs.primary),
],
),
),
PopupMenuItem<double>(
value: kThicknessMedium,
child: Row(
children: [
const Text('M'),
const Spacer(),
if ((width - kThicknessMedium).abs() < 0.0003)
Icon(Icons.check, size: 18, color: cs.primary),
],
),
),
PopupMenuItem<double>(
value: kThicknessLarge,
child: Row(
children: [
const Text('L'),
const Spacer(),
if ((width - kThicknessLarge).abs() < 0.0003)
Icon(Icons.check, size: 18, color: cs.primary),
],
),
),
PopupMenuItem<double>(
enabled: false,
child: SizedBox(
width: 180,
child: StatefulBuilder(
builder: (context, setLocal) {
final v = width.clamp(kPenSlotWidthMin, kPenSlotWidthMax);
return Slider(
value: v,
min: kPenSlotWidthMin,
max: kPenSlotWidthMax,
onChanged: (next) {
onChanged(next);
setLocal(() {});
},
);
},
),
),
),
],
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.line_weight, size: 20, color: cs.onSurfaceVariant),
const SizedBox(width: 2),
Text(
_labelFor(width),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: cs.onSurfaceVariant,
),
),
],
),
),
);
}
}
/// A dropdown that selects the active PEN brush (fountain / ballpoint / pencil).
///
/// Highlighter and eraser remain separate tools. Tapping the button opens a
/// menu of [kPenToolBrushes]; the chosen brush is reported via [onSelected].
/// [labelFor] localizes each brush name so the menu honors the app locale.
/// [colorFor] returns each brush's REMEMBERED color (rnote-style per-brush color
/// memory): the active brush's color is shown as an underline on the button and
/// as a dot beside each menu item, so the toolbar makes each brush's color
/// visible at a glance.
class BrushPickerButton extends StatelessWidget {
const BrushPickerButton({
super.key,
required this.selected,
required this.active,
required this.onSelected,
required this.labelFor,
required this.colorFor,
required this.tooltip,
});
/// The currently selected pen brush.
final BrushKind selected;
/// True when the pen tool (this brush) is the active tool — drives highlight.
final bool active;
final ValueChanged<BrushKind> onSelected;
/// Localized display name for a brush.
final String Function(BrushKind) labelFor;
/// The remembered color for a brush (drives the underline + menu dots).
final Color Function(BrushKind) colorFor;
final String tooltip;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final iconColor =
active ? cs.onSecondaryContainer : cs.onSurfaceVariant;
return PopupMenuButton<BrushKind>(
tooltip: tooltip,
initialValue: selected,
onSelected: onSelected,
itemBuilder: (context) => [
for (final b in kPenToolBrushes)
PopupMenuItem<BrushKind>(
value: b,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(brushIcon(b), size: 20),
const SizedBox(width: 10),
Text(labelFor(b)),
const SizedBox(width: 8),
// The brush's remembered color (rnote per-brush color memory).
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: colorFor(b),
shape: BoxShape.circle,
border: Border.all(color: cs.outlineVariant),
),
),
if (b == selected) ...[
const SizedBox(width: 8),
Icon(Icons.check, size: 18, color: cs.primary),
],
],
),
),
],
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
decoration: BoxDecoration(
color: active ? cs.secondaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(brushIcon(selected), size: 22, color: iconColor),
Icon(Icons.arrow_drop_down, size: 18, color: iconColor),
],
),
// Per-brush color underline: shows the active brush's remembered
// color so switching brushes visibly switches the color.
Container(
height: 3,
width: 24,
decoration: BoxDecoration(
color: colorFor(selected),
borderRadius: BorderRadius.circular(2),
),
),
],
),
),
);
}
}
/// A toggle-style tool button that doubles as a [ShapeKind] picker: a short tap
/// activates the shape tool with the current shape; a long-press (or the dropdown
/// caret) opens the line / rectangle / ellipse / arrow submenu.
class ShapePickerButton extends StatelessWidget {
const ShapePickerButton({
super.key,
required this.selected,
required this.active,
required this.onActivate,
required this.onSelected,
required this.labelFor,
required this.tooltip,
});
/// The currently selected shape kind.
final ShapeKind selected;
/// True when the shape tool is the active tool — drives highlight.
final bool active;
/// Called when the button body is tapped (activate the shape tool).
final VoidCallback onActivate;
/// Called when a shape kind is picked from the submenu (also activates).
final ValueChanged<ShapeKind> onSelected;
/// Localized display name for a shape kind.
final String Function(ShapeKind) labelFor;
final String tooltip;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final iconColor = active ? cs.onSecondaryContainer : cs.onSurfaceVariant;
return PopupMenuButton<ShapeKind>(
tooltip: tooltip,
initialValue: selected,
onSelected: onSelected,
itemBuilder: (context) => [
for (final s in ShapeKind.values)
PopupMenuItem<ShapeKind>(
value: s,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(shapeIcon(s), size: 20),
const SizedBox(width: 10),
Text(labelFor(s)),
if (s == selected) ...[
const SizedBox(width: 8),
Icon(Icons.check, size: 18, color: cs.primary),
],
],
),
),
],
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onActivate,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
decoration: BoxDecoration(
color: active ? cs.secondaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(shapeIcon(selected), size: 22, color: iconColor),
Icon(Icons.arrow_drop_down, size: 18, color: iconColor),
],
),
),
),
);
}
}
/// A Material 3 toggle-style icon button for the floating tool palette.
class ToolButton extends StatelessWidget {
const ToolButton({
super.key,
required this.icon,
required this.selected,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final bool selected;
final String tooltip;
/// Tap handler. When null the button renders disabled (dimmed, no ripple).
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final enabled = onPressed != null;
final iconColor = !enabled
? cs.onSurfaceVariant.withValues(alpha: 0.38)
: selected
? cs.onSecondaryContainer
: cs.onSurfaceVariant;
return Tooltip(
message: tooltip,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: selected ? cs.secondaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Icon(icon, size: 22, color: iconColor),
),
),
);
}
}
/// A thin vertical divider between palette groups.
class PaletteDivider extends StatelessWidget {
const PaletteDivider({super.key, required this.cs});
final ColorScheme cs;
@override
Widget build(BuildContext context) => Container(
width: 1,
height: 24,
margin: const EdgeInsets.symmetric(horizontal: 6),
color: cs.outlineVariant,
);
}
/// A round, tonal icon button (used for the floating back button).
class RoundIconButton extends StatelessWidget {
const RoundIconButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final String tooltip;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
shape: const CircleBorder(),
child: IconButton(
tooltip: tooltip,
icon: Icon(icon),
color: cs.onSurfaceVariant,
onPressed: onPressed,
),
);
}
}

View File

@@ -0,0 +1,681 @@
// lib/editor/canvas/pen_slide_screen.dart
//
// Pen-first slide (PPT) annotator. Reuses the single performant inking engine
// (PenCanvas) over each slide image, with per-slide normalized strokes and
// prev/next navigation. Export to PDF maps the normalized strokes into each
// slide's draw rect (slide_export.dart) — which also fixes the old exporter's
// known ink-misalignment bug. In-memory only (PPT ink is not auto-saved; Export
// to PDF is how annotations are kept), matching the previous behavior.
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:syncfusion_flutter_pdf/pdf.dart';
import '../engine/brush.dart';
import '../engine/shape_geometry.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../input/pen_slots.dart';
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
import '../layout/viewport_fit.dart';
import '../pdf/slide_export.dart';
import '../ui/page_nav_shortcuts.dart';
import '../ui/pen_settings_page.dart';
import 'editor_tool.dart';
import 'pen_canvas.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
class PenSlideScreen extends StatefulWidget {
const PenSlideScreen({
super.key,
required this.filePath,
required this.slideImagePaths,
this.extractedText,
});
final String filePath;
final List<String> slideImagePaths;
final String? extractedText;
@override
State<PenSlideScreen> createState() => _PenSlideScreenState();
}
class _PenSlideScreenState extends State<PenSlideScreen> {
int _slideIndex = 0;
final Map<int, List<PenStroke>> _strokesBySlide = {};
final Map<int, List<List<PenStroke>>> _undo = {};
final Map<int, List<List<PenStroke>>> _redo = {};
/// Intrinsic pixel size of each slide image, loaded async so the page rect
/// keeps the slide's aspect (no distortion). Null until loaded.
Map<int, Size>? _slideSizes;
/// The single active-tool state (shared model across the 3 editors).
EditorToolKind _tool = EditorToolKind.brush;
/// Selected shape for the SHAPE tool.
ShapeKind _shapeKind = ShapeKind.line;
/// Index of the currently selected committed stroke (SELECT tool), or null.
int? _selectedStroke;
/// Highlighter keeps its own color (not a pen slot).
Color _highlighterColor = Colors.orange;
BrushKind get _penBrush =>
_penSlots?.active.brush ?? BrushKind.fountainPen;
Color get _color => _tool == EditorToolKind.highlighter
? _highlighterColor
: (_penSlots?.active.color ?? Colors.black);
bool _allowFingerDrawing = false;
bool _needsCenter = true;
bool _showSlider = false;
double? _scrub;
PenConfigController? _penConfig;
PenSlotsController? _penSlots;
final TransformationController _transform = TransformationController();
static const double _highlighterWidthFraction = 0.02;
static const Size _fallbackSlide = Size(1600, 900);
static const List<Color> _palette = kInkPalette;
int get _slideCount => widget.slideImagePaths.length;
List<PenStroke> get _currentStrokes => _strokesBySlide[_slideIndex] ?? const [];
@override
void initState() {
super.initState();
PenInputService.instance.start();
_loadSlideSizes();
_initPenConfig();
}
Future<void> _loadSlideSizes() async {
final sizes = <int, Size>{};
for (var i = 0; i < _slideCount; i++) {
try {
final bytes = await File(widget.slideImagePaths[i]).readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
sizes[i] = Size(
frame.image.width.toDouble(), frame.image.height.toDouble());
frame.image.dispose();
} catch (_) {
sizes[i] = _fallbackSlide;
}
}
if (mounted) setState(() => _slideSizes = sizes);
}
Future<void> _initPenConfig() async {
final results = await Future.wait([
PenConfigController.load(),
PenSlotsController.load(),
]);
final config = results[0] as PenConfigController;
final slots = results[1] as PenSlotsController;
if (!mounted) {
config.dispose();
slots.dispose();
return;
}
config.addListener(_onPenConfigChanged);
slots.addListener(_onPenSlotsChanged);
setState(() {
_penConfig = config;
_penSlots = slots;
_allowFingerDrawing = config.value.fingerDrawing;
});
}
void _onPenConfigChanged() {
if (mounted) setState(() {});
}
void _onPenSlotsChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
_penConfig?.removeListener(_onPenConfigChanged);
_penConfig?.dispose();
_penSlots?.removeListener(_onPenSlotsChanged);
_penSlots?.dispose();
_transform.dispose();
super.dispose();
}
// ── Mutations ──────────────────────────────────────────────────────────────
void _pushUndo() {
(_undo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
_redo[_slideIndex]?.clear();
}
void _commitStroke(PenStroke stroke) {
setState(() {
_pushUndo();
_strokesBySlide[_slideIndex] = [..._currentStrokes, stroke];
});
}
void _eraseStroke(int index, List<PenStroke> replacements) {
final strokes = _currentStrokes;
if (index < 0 || index >= strokes.length) return;
setState(() {
_pushUndo();
_strokesBySlide[_slideIndex] = [
...strokes.sublist(0, index),
...replacements,
...strokes.sublist(index + 1),
];
});
}
void _performUndo() {
final stack = _undo[_slideIndex];
if (stack == null || stack.isEmpty) return;
setState(() {
(_redo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
_strokesBySlide[_slideIndex] = stack.removeLast();
});
}
void _performRedo() {
final stack = _redo[_slideIndex];
if (stack == null || stack.isEmpty) return;
setState(() {
(_undo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
_strokesBySlide[_slideIndex] = stack.removeLast();
});
}
void _toggleFingerDrawing() {
final next = !_allowFingerDrawing;
setState(() => _allowFingerDrawing = next);
_penConfig?.setFingerDrawing(next);
}
void _goToSlide(int i) {
final clamped = i.clamp(0, _slideCount - 1);
if (clamped == _slideIndex) return;
setState(() {
_slideIndex = clamped;
_needsCenter = true;
_selectedStroke = null; // selection is per-slide
});
}
// ── Export ──────────────────────────────────────────────────────────────────
Future<void> _exportPdf() async {
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(content: Text('Exporting PDF...')));
try {
final bytes = await _buildPdfBytes();
final dir = await _exportDir();
final base = p.basenameWithoutExtension(widget.filePath);
final outPath = p.join(dir.path, '${base}_annotated.pdf');
await File(outPath).writeAsBytes(bytes);
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text('PDF saved: $outPath')));
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text('Export failed: $e')));
}
}
Future<Directory> _exportDir() async {
try {
final home = Platform.environment['HOME'];
if (home != null) {
final dir = Directory(p.join(home, 'Documents', 'BadNote'));
if (!await dir.exists()) await dir.create(recursive: true);
return dir;
}
} catch (_) {}
return Directory.current;
}
Future<Uint8List> _buildPdfBytes() async {
final doc = PdfDocument();
doc.pageSettings.margins.all = 0;
final sizes = _slideSizes ?? const {};
for (var i = 0; i < _slideCount; i++) {
final page = doc.pages.add();
final pageSize = page.getClientSize();
try {
final imgBytes = await File(widget.slideImagePaths[i]).readAsBytes();
final bitmap = PdfBitmap(imgBytes);
final imageSize = sizes[i] ??
Size(bitmap.width.toDouble(), bitmap.height.toDouble());
final draw = slideDrawRect(
Size(pageSize.width, pageSize.height), imageSize);
page.graphics.drawImage(bitmap, draw);
for (final stroke in _strokesBySlide[i] ?? const <PenStroke>[]) {
if (stroke.points.length < 2) continue;
final r = (stroke.color >> 16) & 0xFF;
final g = (stroke.color >> 8) & 0xFF;
final b = stroke.color & 0xFF;
final path = PdfPath();
path.startFigure();
for (var j = 0; j < stroke.points.length - 1; j++) {
final p1 = stroke.points[j];
final p2 = stroke.points[j + 1];
path.addLine(
normToSlide(p1.x, p1.y, draw),
normToSlide(p2.x, p2.y, draw),
);
}
page.graphics.drawPath(
path,
pen: PdfPen(PdfColor(r, g, b),
width: slideStrokeWidth(stroke.width, draw)),
);
}
} catch (_) {
page.graphics.drawRectangle(
brush: PdfSolidBrush(PdfColor(230, 230, 230)),
bounds: Rect.fromLTWH(0, 0, pageSize.width, pageSize.height),
);
}
}
final bytes = await doc.save();
doc.dispose();
return Uint8List.fromList(bytes);
}
// ── Layout ────────────────────────────────────────────────────────────────
void _centerPage(Size viewport, Size pageSize) {
final o = centerOffset(pageSize, viewport, 1.0);
_transform.value = Matrix4.identity()..translateByDouble(o.dx, o.dy, 0, 1);
}
double get _strokeWidth => _tool == EditorToolKind.highlighter
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
: (_penSlots?.active.width ?? 0.006);
// ── SELECT tool: select / move / delete (per-slide, reuses the undo stacks) ──
void _selectStroke(int? index) {
setState(() => _selectedStroke = index);
}
void _moveStroke(int index, double dx, double dy, bool isDragStart) {
final strokes = _currentStrokes;
if (index < 0 || index >= strokes.length) return;
setState(() {
if (isDragStart) _pushUndo();
final next = List<PenStroke>.from(strokes);
next[index] = translateStroke(next[index], dx, dy);
_strokesBySlide[_slideIndex] = next;
});
}
void _deleteSelected() {
final idx = _selectedStroke;
final strokes = _currentStrokes;
if (idx == null || idx < 0 || idx >= strokes.length) return;
setState(() {
_pushUndo();
_strokesBySlide[_slideIndex] = [
...strokes.sublist(0, idx),
...strokes.sublist(idx + 1),
];
_selectedStroke = null;
});
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return pageNavShortcuts(
onPrevious:
_slideIndex > 0 ? () => _goToSlide(_slideIndex - 1) : null,
onNext: _slideIndex < _slideCount - 1
? () => _goToSlide(_slideIndex + 1)
: null,
onFirst: _slideCount > 0 ? () => _goToSlide(0) : null,
onLast: _slideCount > 0 ? () => _goToSlide(_slideCount - 1) : null,
child: Scaffold(
body: Stack(
children: [
Positioned.fill(child: _buildCanvas()),
SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: _buildToolPalette(cs),
),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(8),
child: RoundIconButton(
icon: Icons.arrow_back,
tooltip: 'Back',
onPressed: () => Navigator.of(context).maybePop(),
),
),
),
SafeArea(
child: Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8),
child: RoundIconButton(
icon: Icons.picture_as_pdf_outlined,
tooltip: 'Export to PDF',
onPressed: _exportPdf,
),
),
),
),
if (_slideCount > 0)
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _buildSlidePill(cs),
),
),
),
],
),
),
);
}
Widget _buildCanvas() {
final sizes = _slideSizes;
if (sizes == null) {
return const Center(child: CircularProgressIndicator());
}
if (_slideCount == 0) {
return const Center(child: Text('No slides.'));
}
final slide = sizes[_slideIndex] ?? _fallbackSlide;
return LayoutBuilder(
builder: (context, constraints) {
final fitW = constraints.maxWidth / slide.width;
final fitH = constraints.maxHeight / slide.height;
final scale = fitW < fitH ? fitW : fitH;
final pageSize = Size(slide.width * scale, slide.height * scale);
if (_needsCenter) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_centerPage(
Size(constraints.maxWidth, constraints.maxHeight), pageSize);
setState(() => _needsCenter = false);
});
}
return PenCanvas(
key: ValueKey(_slideIndex),
pageSize: pageSize,
strokes: _currentStrokes,
transformationController: _transform,
tool: editorToolToCanvas(_tool),
brush: _penBrush,
shapeKind: _shapeKind,
color: _color,
strokeWidth: _strokeWidth,
selectedStrokeIndex: _selectedStroke,
onSelectStroke: _selectStroke,
onMoveStroke: _moveStroke,
pressureGamma:
_penConfig?.value.pressureGamma ?? kNaturalPressureGamma,
eraserRadius: _penConfig?.value.eraserRadius ?? kDefaultEraserRadius,
eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false,
sideButtonAction:
_penConfig?.value.sideButton ?? PenButtonAction.eraser,
eraserEndAction:
_penConfig?.value.eraserEnd ?? PenButtonAction.eraser,
allowFingerDrawing: _allowFingerDrawing,
onStrokeComplete: _commitStroke,
onEraseStroke: _eraseStroke,
pageWidget: Image.file(
File(widget.slideImagePaths[_slideIndex]),
fit: BoxFit.fill,
gaplessPlayback: true,
),
);
},
);
}
Widget _buildToolPalette(ColorScheme cs) {
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: [
// OneNote-style: each pen slot restores brush + color + thickness.
for (final slot in _penSlots?.slots ?? kDefaultPenSlots())
PenSlotButton(
kind: slot.brush,
selected: _tool == EditorToolKind.brush &&
(_penSlots?.activeId ?? 'slot_0') == slot.id,
color: slot.color,
widthHint: slot.width,
tooltip: brushLabelEn(slot.brush),
onPressed: () {
_penSlots?.select(slot.id);
setState(() => _tool = EditorToolKind.brush);
},
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == EditorToolKind.highlighter,
tooltip: 'Highlighter',
onPressed: () => setState(() => _tool = EditorToolKind.highlighter),
),
ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _tool == EditorToolKind.eraser,
tooltip: 'Eraser',
onPressed: () => setState(() => _tool = EditorToolKind.eraser),
),
ToolButton(
icon: Icons.ads_click,
selected: _tool == EditorToolKind.select,
tooltip: 'Select',
onPressed: () => setState(() => _tool = EditorToolKind.select),
),
ShapePickerButton(
selected: _shapeKind,
active: _tool == EditorToolKind.shape,
tooltip: 'Shape',
labelFor: shapeLabelEn,
onActivate: () => setState(() => _tool = EditorToolKind.shape),
onSelected: (s) => setState(() {
_shapeKind = s;
_tool = EditorToolKind.shape;
}),
),
if (_tool == EditorToolKind.select && _selectedStroke != null)
ToolButton(
icon: Icons.delete_outline,
selected: false,
tooltip: 'Delete selection',
onPressed: _deleteSelected,
),
PaletteDivider(cs: cs),
ToolButton(
icon: Icons.undo,
selected: false,
tooltip: 'Undo',
onPressed:
(_undo[_slideIndex]?.isNotEmpty ?? false) ? _performUndo : null,
),
ToolButton(
icon: Icons.redo,
selected: false,
tooltip: 'Redo',
onPressed:
(_redo[_slideIndex]?.isNotEmpty ?? false) ? _performRedo : null,
),
PaletteDivider(cs: cs),
for (final c in _palette) _colorDot(c, cs),
ThicknessPickerButton(
width: _penSlots?.active.width ?? 0.006,
onChanged: (w) => _penSlots?.setActiveWidth(w),
),
PaletteDivider(cs: cs),
ToolButton(
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
selected: _allowFingerDrawing,
tooltip: _allowFingerDrawing
? 'Finger drawing ON'
: 'Finger drawing OFF (pen only)',
onPressed: _toggleFingerDrawing,
),
ToolButton(
icon: Icons.settings_outlined,
selected: false,
tooltip: 'Pen settings (width, pressure, eraser…)',
onPressed: _penConfig != null
? () => showPenSettingsSheet(context, _penConfig!)
: null,
),
],
),
),
);
}
Widget _colorDot(Color c, ColorScheme cs) {
final selected = _color.toARGB32() == c.toARGB32() &&
_tool != EditorToolKind.eraser &&
_tool != EditorToolKind.select;
return GestureDetector(
onTap: () {
if (_tool == EditorToolKind.eraser ||
_tool == EditorToolKind.select) {
setState(() => _tool = EditorToolKind.brush);
}
if (_tool == EditorToolKind.highlighter) {
setState(() => _highlighterColor = c);
} else {
_penSlots?.setActiveColor(c);
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: 24,
height: 24,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: selected ? cs.onSurface : cs.outlineVariant,
width: selected ? 3 : 1,
),
),
),
);
}
Widget _buildSlidePill(ColorScheme cs) {
final shown = (_scrub ?? (_slideIndex + 1).toDouble()).round();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_showSlider && _slideCount > 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: _slideCount.toDouble(),
value: (_scrub ?? (_slideIndex + 1).toDouble())
.clamp(1, _slideCount.toDouble()),
divisions: _slideCount > 1 ? _slideCount - 1 : null,
onChanged: (v) => setState(() => _scrub = v),
onChangeEnd: (v) {
final target = v.round() - 1;
setState(() {
_scrub = v;
_slideIndex = target;
});
_goToSlide(target);
setState(() {
_scrub = null;
_showSlider = false;
});
},
),
),
),
),
Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Previous slide',
icon: const Icon(Icons.chevron_left),
onPressed:
_slideIndex > 0 ? () => _goToSlide(_slideIndex - 1) : null,
),
TextButton(
onPressed: _slideCount > 1
? () => setState(() => _showSlider = !_showSlider)
: null,
child: Text(
'$shown / $_slideCount',
style: TextStyle(
color: cs.onSurface,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: 'Next slide',
icon: const Icon(Icons.chevron_right),
onPressed: _slideIndex < _slideCount - 1
? () => _goToSlide(_slideIndex + 1)
: null,
),
],
),
),
),
],
);
}
}

View File

@@ -6,18 +6,24 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../engine/brush.dart';
/// A single captured sample of a stroke. /// A single captured sample of a stroke.
/// ///
/// [x]/[y] are normalized to the page rectangle ([0,1]). /// [x]/[y] are normalized to the page rectangle ([0,1]).
/// [pressure] is the normalized stylus pressure ([0,1]) or null when the /// [pressure] is the normalized stylus pressure ([0,1]) or null when the
/// device reported no usable pressure (perfect_freehand then simulates it). /// device reported no usable pressure (perfect_freehand then simulates it).
/// [tilt] is the pen tilt magnitude in degrees (0 = perpendicular), or null
/// when unavailable. On Windows it is sourced from the native pen plugin
/// (`badnote/pen`) since Flutter 3.44 does not surface tilt itself.
@immutable @immutable
class PenPoint { class PenPoint {
const PenPoint(this.x, this.y, this.pressure); const PenPoint(this.x, this.y, this.pressure, {this.tilt});
final double x; final double x;
final double y; final double y;
final double? pressure; final double? pressure;
final double? tilt;
} }
/// Which kind of mark a stroke is. /// Which kind of mark a stroke is.
@@ -31,6 +37,7 @@ class PenStroke {
required this.color, required this.color,
required this.width, required this.width,
required this.kind, required this.kind,
this.brush = BrushKind.fountainPen,
}); });
/// Normalized points (see [PenPoint]). /// Normalized points (see [PenPoint]).
@@ -44,4 +51,12 @@ class PenStroke {
final double width; final double width;
final PenStrokeKind kind; final PenStrokeKind kind;
/// The brush this stroke was drawn with — drives the perfect_freehand
/// geometry (thinning/streamline/smoothing/caps) at render time via
/// [brushProfileFor]. The pressure pre-warp ([BrushProfile.pressureGamma]) is
/// applied at CAPTURE so it is already baked into [points]. Defaults to
/// [BrushKind.fountainPen] (the legacy pen visual) so old/loaded strokes keep
/// rendering as before.
final BrushKind brush;
} }

View File

@@ -0,0 +1,103 @@
// lib/editor/canvas/pinch_scale_solver.dart
//
// Pure math for the pen canvas's absolute pinch-zoom. Extracted so the
// re-baseline behavior (the subtle part) can be unit-tested without simulating
// a flaky multi-pointer gesture.
//
// The pinch is driven ABSOLUTELY: the scale shown is always
// scaleStart * (rawScale / rawScaleAtBaseline)
// where `scaleStart` is the matrix scale captured at the current baseline and
// `rawScaleAtBaseline` is the recognizer's cumulative `details.scale` at that
// same baseline. Dividing by `rawScaleAtBaseline` re-normalizes the cumulative
// scale so it reads 1.0 at the baseline instant.
//
// Why this matters: a baseline is captured at gesture start AND on every
// pointer-count change (a finger blips 2→1→2, routine on Windows touch). At
// gesture start `details.scale` is 1.0, so a naive `scaleStart * rawScale` is
// correct. But at a MID-GESTURE re-baseline `details.scale` is whatever the
// pinch has accumulated (e.g. 0.40) — multiplying the fresh `scaleStart` by
// that stale 0.40 popped the zoom to a wrong scale and snapped back (the
// reported flicker). Normalizing against `rawScaleAtBaseline` removes the pop.
//
// Soft-clamp (Surface 2026-08-05 diag): a HARD drop of frames whose per-step
// ratio exceeds the glitch band caused an avalanche — lastRaw never advanced,
// so every subsequent frame also dropped while pdfrx/live zoom still crawled.
// [softClampedPinchStep] always returns an applied scale, clamping the step,
// and tells the caller to re-anchor when a spike was clipped.
import 'package:flutter/foundation.dart' show clampDouble;
/// Returns the absolute target scale for a pinch frame.
///
/// [scaleStart] — matrix scale captured at the current baseline.
/// [rawScaleAtBaseline] — recognizer cumulative `details.scale` at that
/// baseline (1.0 at gesture start; the live value at a re-baseline).
/// [rawScale] — the recognizer's current cumulative `details.scale`.
/// Result is clamped to [minScale, maxScale].
double absolutePinchScale({
required double scaleStart,
required double rawScaleAtBaseline,
required double rawScale,
required double minScale,
required double maxScale,
}) {
final double cumulative =
rawScaleAtBaseline > 0 ? rawScale / rawScaleAtBaseline : 1.0;
return clampDouble(scaleStart * cumulative, minScale, maxScale);
}
/// Result of one soft-clamped pinch step.
class SoftPinchStep {
const SoftPinchStep({
required this.appliedScale,
required this.reanchor,
required this.spiked,
});
/// Scale to write into the matrix / controller this frame.
final double appliedScale;
/// When true the caller must set `scaleStart = appliedScale` and
/// `rawScaleAtBaseline = rawScale` so absolute tracking does not keep
/// fighting the clamp on later frames.
final bool reanchor;
/// True when the ideal absolute target was clipped by the per-step band.
final bool spiked;
}
/// Soft-clamp the per-frame scale change instead of dropping the frame.
///
/// Ideal scale comes from [absolutePinchScale]. The step from
/// [lastAppliedScale] is then limited to `[1/maxStepRatio, maxStepRatio]`.
/// Spikes still get partially applied (smooth catch-up) and the caller
/// re-anchors so the next frame starts clean.
SoftPinchStep softClampedPinchStep({
required double scaleStart,
required double rawScaleAtBaseline,
required double rawScale,
required double lastAppliedScale,
required double minScale,
required double maxScale,
required double maxStepRatio,
}) {
final ideal = absolutePinchScale(
scaleStart: scaleStart,
rawScaleAtBaseline: rawScaleAtBaseline,
rawScale: rawScale,
minScale: minScale,
maxScale: maxScale,
);
if (lastAppliedScale <= 0 || maxStepRatio <= 1.0) {
return SoftPinchStep(appliedScale: ideal, reanchor: false, spiked: false);
}
final lo = lastAppliedScale / maxStepRatio;
final hi = lastAppliedScale * maxStepRatio;
final applied = clampDouble(ideal, lo, hi);
final spiked = applied != ideal;
return SoftPinchStep(
appliedScale: clampDouble(applied, minScale, maxScale),
reanchor: spiked,
spiked: spiked,
);
}

View File

@@ -0,0 +1,291 @@
// lib/editor/canvas/sticky_note_overlay.dart
//
// Page-anchored paper sticky: sized/positioned by the parent in page space,
// shares the editor brush/color/tool, locks inner pan/zoom so writing feels
// like drawing on the sticky surface itself.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../../models/ink_stroke.dart';
import '../../models/scratch_link.dart';
import '../../storage/badnote_sidecar.dart';
import '../engine/brush.dart';
import '../input/pen_config.dart' show kDefaultEraserRadius;
import '../notebook/ink_stroke_adapter.dart';
import '../persistence/sidecar_repository.dart';
import 'editor_tool.dart';
import 'pen_canvas.dart';
import 'pen_stroke.dart';
/// Default world size for a fresh sticky scratchpad (absolute px).
const Size kStickyWorldSize = Size(1200, 900);
/// Floating sticky-note card glued to a PDF page (parent supplies pixel size).
class StickyNoteOverlay extends StatefulWidget {
const StickyNoteOverlay({
super.key,
required this.link,
required this.repo,
required this.onClose,
required this.onDelete,
required this.onDragPx,
required this.onResizePx,
this.brush = BrushKind.ballpoint,
this.color = const Color(0xFF1A1A1A),
this.tool = EditorToolKind.brush,
this.strokeWidth = 0.008,
this.allowFingerDrawing = false,
});
final ScratchLink link;
final SidecarRepository repo;
final VoidCallback onClose;
final VoidCallback onDelete;
/// Header drag delta in viewer/page pixels.
final void Function(double dx, double dy) onDragPx;
/// Corner resize delta in viewer/page pixels.
final void Function(double dx, double dy) onResizePx;
final BrushKind brush;
final Color color;
final EditorToolKind tool;
final double strokeWidth;
final bool allowFingerDrawing;
@override
State<StickyNoteOverlay> createState() => _StickyNoteOverlayState();
}
class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
static const _uuid = Uuid();
final TransformationController _transform = TransformationController();
List<InkStroke> _strokes = [];
Size _world = kStickyWorldSize;
Timer? _saveTimer;
bool _dirty = false;
@override
void initState() {
super.initState();
final pad = widget.repo.scratchpadFor(widget.link.id);
if (pad != null) {
_world = Size(pad.canvasWidth, pad.canvasHeight);
_strokes = pad.strokes.where((s) => isFreehandTool(s.tool)).toList();
}
}
@override
void dispose() {
_saveTimer?.cancel();
if (_dirty) {
_persist(flush: true);
}
_transform.dispose();
super.dispose();
}
void _scheduleSave() {
_dirty = true;
_saveTimer?.cancel();
_saveTimer = Timer(const Duration(milliseconds: 600), () => _persist());
}
Future<void> _persist({bool flush = false}) async {
if (!_dirty && !flush) return;
widget.repo.scheduleScratchpadSave(
widget.link.id,
SidecarScratchpad(
canvasWidth: _world.width,
canvasHeight: _world.height,
strokes: List<InkStroke>.of(_strokes),
),
);
_dirty = false;
if (flush) await widget.repo.flush();
}
void _onStrokeComplete(PenStroke pen) {
setState(() {
_strokes = [
..._strokes,
inkStrokeFromPen(pen, _world, id: _uuid.v4(), createdAt: DateTime.now()),
];
});
_scheduleSave();
}
void _onErase(int index, List<PenStroke> replacements) {
if (index < 0 || index >= _strokes.length) return;
setState(() {
final next = List<InkStroke>.of(_strokes)..removeAt(index);
for (final r in replacements) {
next.insert(
index,
inkStrokeFromPen(r, _world, id: _uuid.v4(), createdAt: DateTime.now()),
);
}
_strokes = next;
});
_scheduleSave();
}
Future<void> _close() async {
_saveTimer?.cancel();
await _persist(flush: true);
widget.onClose();
}
CanvasTool get _canvasTool {
switch (widget.tool) {
case EditorToolKind.eraser:
return CanvasTool.eraser;
case EditorToolKind.select:
return CanvasTool.select;
case EditorToolKind.highlighter:
return CanvasTool.highlighter;
case EditorToolKind.brush:
case EditorToolKind.shape:
case EditorToolKind.text:
return CanvasTool.pen;
}
}
BrushKind get _canvasBrush =>
widget.tool == EditorToolKind.highlighter
? BrushKind.highlighter
: widget.brush;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
elevation: 8,
borderRadius: BorderRadius.circular(4),
color: const Color(0xFFFFF8E1),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Column(
children: [
_StickyHeader(
onDragDelta: widget.onDragPx,
onClose: _close,
onDelete: () async {
await _persist(flush: true);
widget.onDelete();
},
cs: cs,
),
Expanded(
child: PenCanvas(
pageSize: _world,
strokes: penStrokesFromInk(_strokes, _world),
transformationController: _transform,
tool: _canvasTool,
brush: _canvasBrush,
color: widget.color,
strokeWidth: widget.strokeWidth,
eraserRadius: kDefaultEraserRadius,
allowFingerDrawing: widget.allowFingerDrawing,
scaleEnabled: false,
panEnabled: false,
minScale: 1.0,
maxScale: 1.0,
onStrokeComplete: _onStrokeComplete,
onEraseStroke: _onErase,
pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)),
),
),
],
),
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
onPanUpdate: (d) => widget.onResizePx(d.delta.dx, d.delta.dy),
child: MouseRegion(
cursor: SystemMouseCursors.resizeUpLeftDownRight,
child: SizedBox(
width: 28,
height: 28,
child: Icon(
Icons.south_east,
size: 16,
color: cs.onSurface.withValues(alpha: 0.45),
),
),
),
),
),
],
),
);
}
}
class _StickyHeader extends StatelessWidget {
const _StickyHeader({
required this.onDragDelta,
required this.onClose,
required this.onDelete,
required this.cs,
});
final void Function(double dx, double dy) onDragDelta;
final VoidCallback onClose;
final VoidCallback onDelete;
final ColorScheme cs;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanUpdate: (d) => onDragDelta(d.delta.dx, d.delta.dy),
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: const BoxDecoration(
color: Color(0xFFFFE082),
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
),
child: Row(
children: [
const Icon(Icons.drag_indicator, size: 18),
const SizedBox(width: 4),
const Expanded(
child: Text(
'便利贴',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
),
Text(
'拖标题定位 · 角缩放',
style: TextStyle(
fontSize: 10,
color: cs.onSurface.withValues(alpha: 0.5),
),
),
IconButton(
tooltip: '删除',
icon: const Icon(Icons.delete_outline, size: 18),
visualDensity: VisualDensity.compact,
onPressed: onDelete,
),
IconButton(
tooltip: '收起',
icon: const Icon(Icons.close, size: 18),
visualDensity: VisualDensity.compact,
onPressed: onClose,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,292 @@
// lib/editor/engine/brush.dart
//
// Data-driven, Krita-compatible brush model — the extensibility seam for the
// pen engine. Each [BrushKind] maps to an immutable [BrushProfile] that fully
// describes how a stroke is captured (pressure pre-warp) and rendered
// (perfect_freehand geometry params + caps/taper). Adding a brush = adding one
// const entry to [kBrushPresets]; no render-path branching.
//
// Mirrors Krita's sensor→curve design (Pixel brush: each property is driven by
// a sensor through a response curve). Here the response curve is a pure power
// law `p^gamma` applied to pressure BEFORE perfect_freehand (rnote's
// `PressureCurve`: Pow2 = quadratic, Sqrt = √p), and the geometry knobs are
// perfect_freehand's `thinning`/`streamline`/`smoothing`/caps. A future `.kpp`
// (Krita brush preset) importer can produce [BrushProfile]s from the same
// fields — see TODO(brush-kpp-import).
//
// Source spec: docs/research/pen-brush-spec.md §1 (rnote pressure curve) and §4
// (per-brush perfect_freehand option tables). The numbers below are lifted from
// that spec verbatim.
import 'dart:ui' show Color, BlendMode;
import 'package:freezed_annotation/freezed_annotation.dart';
/// The four selectable brushes. Extensible: add a kind here + a preset in
/// [kBrushPresets]. The eraser is NOT a brush — it stays a separate tool.
///
/// The `@JsonValue` names are the STABLE on-disk identifiers persisted in the
/// sidecar (`EditorStroke.brush`); they are decoupled from the Dart enum
/// identifiers so renaming a constant here never breaks existing sidecars. A
/// brush whose stored name is unknown (e.g. a future brush opened by an older
/// build) is read back as [fountainPen] (see `EditorStroke.brush`'s JsonKey).
enum BrushKind {
/// Strong pressure→width (rnote Pow2 / quadratic), soft taper, solid ink.
@JsonValue('fountainPen')
fountainPen,
/// Near-constant thin width; pressure carries OPACITY (the ballpoint "tell").
@JsonValue('ballpoint')
ballpoint,
/// Broad, flat width, translucent, square (uncapped) ends.
@JsonValue('highlighter')
highlighter,
/// Moderate width + opacity from pressure (rnote Sqrt / √p), scratchy.
@JsonValue('pencil')
pencil,
}
/// Immutable, const description of one brush.
///
/// The capture path reads [pressureGamma] (the rnote power-law warp applied via
/// `PressureCurve(gamma: pressureGamma)` BEFORE perfect_freehand) and the render
/// path reads the perfect_freehand geometry fields ([pfThinning], [pfStreamline],
/// [pfSmoothing], [simulatePressure]) plus the cap/taper flags.
///
/// [opacity] / [blendMultiply] drive the painters' compositing via
/// [resolveStrokePaint] (closes TODO(brush-opacity)): opacity is multiplied
/// into the stroke color's alpha (pressure-tied for ballpoint/pencil — see
/// [resolveStrokeOpacity]) and [blendMultiply] selects [BlendMode.multiply].
class BrushProfile {
const BrushProfile({
required this.kind,
required this.baseWidthFraction,
required this.pressureGamma,
required this.pfThinning,
required this.pfStreamline,
required this.pfSmoothing,
required this.simulatePressure,
required this.capStart,
required this.capEnd,
required this.taper,
required this.opacity,
required this.blendMultiply,
});
/// Which brush this profile is for.
final BrushKind kind;
/// Suggested base stroke width as a fraction of page width (so it scales with
/// zoom, matching `PenStroke.width`). The editors may override with their own
/// configured pen/highlighter widths; this is the spec's nominal default
/// (spec §4 diameters, expressed as a page-width fraction).
final double baseWidthFraction;
/// rnote `PressureCurve` exponent applied to raw pressure at CAPTURE, before
/// perfect_freehand. `2.0` = Pow2 (quadratic, fountain pen); `0.5` = Sqrt
/// (pencil); `1.0` = Linear (ballpoint / highlighter). Fed through the
/// existing `PressureCurve(gamma: …)` — no new pow function (spec §1).
final double pressureGamma;
/// perfect_freehand `thinning`: how strongly (pre-warped) pressure modulates
/// width. `0.0` = constant width (highlighter); high = wide dynamic range
/// (fountain pen) (spec §4).
final double pfThinning;
/// perfect_freehand `streamline`: EMA low-pass on input positions (spec §4).
final double pfStreamline;
/// perfect_freehand `smoothing`: outline corner-softening (spec §4).
final double pfSmoothing;
/// perfect_freehand `simulatePressure`: when true, fakes pressure from
/// velocity. All four presets ship `false` so REAL stylus pressure (already
/// pre-warped by [pressureGamma]) drives width (spec §4). The render path
/// still falls back to simulation when the device reports NO usable pressure.
final bool simulatePressure;
/// Round cap on the start of the stroke (false = square end, highlighter).
final bool capStart;
/// Round cap on the end of the stroke (false = square end, highlighter).
final bool capEnd;
/// Whether the ends taper to a point (fountain pen) (spec §4).
final bool taper;
/// Per-stroke opacity in [0,1]; `1.0` = solid. For fountain pen / highlighter
/// this flat value is used; ballpoint/pencil derive opacity from pressure
/// instead (spec §3/§4) — see [resolveStrokeOpacity]. Applied by the painters
/// via [resolveStrokePaint] (multiplied into the stroke color's alpha).
final double opacity;
/// Whether the brush composites with [BlendMode.multiply] (highlighter
/// build-up / marker feel). Applied by [resolveStrokePaint].
final bool blendMultiply;
}
/// The 4 brush presets, populated from the spec §4 tables.
///
/// Widths are the spec's logical-px diameters re-expressed as page-width
/// fractions against the project's ~1000px logical page (the existing
/// pen/highlighter widths are 0.006 / 0.02). Fountain pen ≈ pen (0.006),
/// highlighter ≈ 0.02 so the existing pen/highlighter visuals are PRESERVED as
/// the fountainPen/highlighter presets (no regression).
const Map<BrushKind, BrushProfile> kBrushPresets = {
// Fountain pen — Surface feel: lower streamline (less lag), higher thinning
// for expressive width, pressure pre-warped to p² (Pow2 / quadratic).
// Solid ink (opacity 1.0). See also tipVelocityWidthScale (ink starvation).
BrushKind.fountainPen: BrushProfile(
kind: BrushKind.fountainPen,
baseWidthFraction: 0.006,
pressureGamma: 2.0,
pfThinning: 0.75,
pfStreamline: 0.22,
pfSmoothing: 0.5,
simulatePressure: false,
capStart: true,
capEnd: true,
// Light taper only; full taper made Chinese characters look frayed.
taper: false,
opacity: 1.0,
blendMultiply: false,
),
// Ballpoint — near-constant width, SOLID opacity. Lower streamline (~0.35)
// for lower latency; thinning 0.12 keeps width almost flat.
BrushKind.ballpoint: BrushProfile(
kind: BrushKind.ballpoint,
baseWidthFraction: 0.0022,
pressureGamma: 1.0,
pfThinning: 0.12,
pfStreamline: 0.35,
pfSmoothing: 0.5,
simulatePressure: false,
capStart: true,
capEnd: true,
taper: false,
opacity: 1.0,
blendMultiply: false,
),
// Highlighter — flat width (thinning 0), square (uncapped) ends, translucent +
// multiply build-up. streamline 0.3 for a bit less lag on broad strokes.
BrushKind.highlighter: BrushProfile(
kind: BrushKind.highlighter,
baseWidthFraction: 0.02,
pressureGamma: 1.0,
pfThinning: 0.0,
pfStreamline: 0.3,
pfSmoothing: 0.4,
simulatePressure: false,
capStart: false,
capEnd: false,
taper: false,
opacity: 0.35,
blendMultiply: true,
),
// Pencil — soft graphite via √p, moderate translucency. streamline 0.25.
BrushKind.pencil: BrushProfile(
kind: BrushKind.pencil,
baseWidthFraction: 0.003,
pressureGamma: 0.5,
pfThinning: 0.45,
pfStreamline: 0.25,
pfSmoothing: 0.45,
simulatePressure: false,
capStart: true,
capEnd: true,
taper: false,
opacity: 0.88,
blendMultiply: false,
),
};
/// Resolve the [BrushProfile] for [kind] (always present; const map).
BrushProfile brushProfileFor(BrushKind kind) => kBrushPresets[kind]!;
// ---- Compositing (opacity + blend) — closes TODO(brush-opacity) -------------
//
// perfect_freehand produces a single closed fill polygon per stroke; the
// painters then fill it with ONE Paint. These helpers resolve that Paint's
// alpha + blend mode from the stroke's [BrushProfile] so the four brushes feel
// distinct (the ballpoint/highlighter/pencil "soul"), while geometry stays in
// the freehand path. Both render paths (PenCanvas + the PDF
// `_PageOverlayPainter`) call [resolveStrokePaint] so they can never diverge.
/// Resolve the EFFECTIVE per-stroke opacity in [0,1] for [profile], given the
/// stroke's AVERAGE pressure [pressureAvg].
///
/// Krita-inspired (not a full brush engine): ballpoint stays essentially solid
/// (width carries the pressure feel); pencil uses a soft √p curve capped below
/// 1 so light strokes stay grey without multiply-style mud; fountain/highlighter
/// use the flat profile opacity. Per-dab / textured brushes remain deferred.
double resolveStrokeOpacity(BrushProfile profile, {double pressureAvg = 0.5}) {
final p = pressureAvg.clamp(0.0, 1.0);
switch (profile.kind) {
// Solid ink — tiny residual so "hover contact" can't punch full black holes
// into overlapping strokes, but no 0.55 floor translucency stacking.
case BrushKind.ballpoint:
return (0.92 + 0.08 * p).clamp(0.0, 1.0);
// Soft graphite: √p darkens quickly under pressure, capped by profile.
case BrushKind.pencil:
final soft = 0.50 + 0.38 * _sqrt01(p);
return soft.clamp(0.0, profile.opacity);
case BrushKind.fountainPen:
case BrushKind.highlighter:
return profile.opacity.clamp(0.0, 1.0);
}
}
double _sqrt01(double v) {
if (v <= 0) return 0;
if (v >= 1) return 1;
var x = v;
for (var i = 0; i < 8; i++) {
x = 0.5 * (x + v / x);
}
return x;
}
/// Multiply [opacity] (0..1) into [argb]'s existing alpha channel and return the
/// new ARGB int. Keeps any alpha the capture path already baked in (e.g. the
/// highlighter's 0x80 translucent capture) so this composes WITHOUT
/// double-counting — the profile opacity scales whatever alpha the color has.
int applyOpacityToArgb(int argb, double opacity) {
final baseAlpha = (argb >> 24) & 0xFF;
final scaled = (baseAlpha * opacity.clamp(0.0, 1.0)).round().clamp(0, 255);
return (scaled << 24) | (argb & 0x00FFFFFF);
}
/// The fully-resolved fill [Color] + [BlendMode] for one stroke, so every
/// painter can configure its `Paint` identically. [argb] is the stroke's stored
/// color; [pressureAvg] is the mean point pressure (`pressure ?? 0.5`).
///
/// - [color]: stroke color with `profile`-resolved opacity multiplied into its
/// alpha (pressure-tied for ballpoint/pencil; flat for fountain/highlighter).
/// - [blendMode]: [BlendMode.multiply] for the highlighter (marker build-up:
/// cross-stroke overlap darkens), [BlendMode.srcOver] otherwise. The stroke
/// is still drawn ONCE per render (single fill polygon) so its OWN self-
/// overlap never darkens — that single-draw invariant lives in the painters.
class ResolvedStrokePaint {
const ResolvedStrokePaint({required this.color, required this.blendMode});
final Color color;
final BlendMode blendMode;
}
/// Resolve the paint config for a stroke drawn with [kind]. See
/// [ResolvedStrokePaint]. TODO(brush-texture): pencil paper-grain texture is
/// still deferred — opacity is enough for this increment.
ResolvedStrokePaint resolveStrokePaint(
BrushKind kind,
int argb, {
double pressureAvg = 0.5,
}) {
final profile = brushProfileFor(kind);
final opacity = resolveStrokeOpacity(profile, pressureAvg: pressureAvg);
return ResolvedStrokePaint(
color: Color(applyOpacityToArgb(argb, opacity)),
blendMode: profile.blendMultiply ? BlendMode.multiply : BlendMode.srcOver,
);
}

View File

@@ -0,0 +1,32 @@
// lib/editor/engine/pen_physics.dart
//
// Simple physical tip model: modulate stroke width by tip velocity so fountain
// ink feels slightly thinner when moving fast (starvation), while ballpoint
// stays nearly velocity-invariant.
//
// Wired at capture: PenCanvas._toNormalized and PenEditorScreen._pressureWithPhysics.
import 'brush.dart';
/// Modulate width fraction by tip velocity (page-normalized units per second).
///
/// Fountain: faster → slightly thinner (ink starvation feel).
/// Ballpoint: nearly ignore velocity.
/// Pencil: mild thinning at speed.
/// Highlighter: ignore velocity (flat marker).
double tipVelocityWidthScale(BrushKind kind, double speedNormPerSec) {
final speed =
speedNormPerSec.isNaN || speedNormPerSec < 0 ? 0.0 : speedNormPerSec;
// Reference: ~2 page-widths/sec ≈ fast handwriting; clamp influence to [0,1].
final t = (speed / 2.0).clamp(0.0, 1.0);
switch (kind) {
case BrushKind.fountainPen:
return 1.0 - 0.15 * t;
case BrushKind.ballpoint:
return 1.0 - 0.02 * t;
case BrushKind.pencil:
return 1.0 - 0.08 * t;
case BrushKind.highlighter:
return 1.0;
}
}

View File

@@ -0,0 +1,143 @@
// lib/editor/engine/shape_geometry.dart
//
// Pure geometry for the SHAPE tool. Each shape is generated as a list of
// NORMALIZED [PenPoint]s (the same model freehand strokes use), so a shape is
// just a [PenStroke] — it reuses stroke rendering, persistence, erase, and undo
// with NO new model or storage. Points carry a constant pressure (1.0) so the
// brush renders them at a steady width (shapes don't taper with pressure).
//
// All inputs/outputs are in normalized page coordinates ([0,1] x [0,1]); the
// caller wraps the points in a PenStroke with the current brush color/width.
import 'dart:math' as math;
import '../canvas/editor_tool.dart';
import '../canvas/pen_stroke.dart';
import 'brush.dart';
/// Number of points sampled around an ellipse. Kept as a const so tests can pin
/// it (spec: "ellipse = sampled points ~48"). The polyline is closed, so the
/// last point repeats the first ⇒ [kEllipseSamples] + 1 total points.
const int kEllipseSamples = 48;
/// Constant pressure baked into every shape point so the brush renders a steady
/// width (no pressure taper for geometric shapes).
const double _kShapePressure = 1.0;
/// Geometric shapes must NOT inherit fountain thinning/taper — force a near-
/// constant-width brush so line/rect/ellipse look like ruler ink.
const BrushKind kShapeBrush = BrushKind.ballpoint;
/// Generate the normalized polyline for [kind] spanning [start] → [end].
///
/// * [ShapeKind.line] → 2 points.
/// * [ShapeKind.rectangle] → 5 points (closed: 4 corners + repeat of the
/// first), an axis-aligned box whose opposite corners are [start]/[end].
/// * [ShapeKind.ellipse] → [kEllipseSamples] + 1 points (closed), inscribed
/// in the [start]→[end] bounding box.
/// * [ShapeKind.arrow] → shaft (start → end) + two arrowhead segments,
/// emitted as a single polyline so it renders as one stroke.
List<PenPoint> generateShapePoints(ShapeKind kind, PenPoint start, PenPoint end) {
switch (kind) {
case ShapeKind.line:
return [
PenPoint(start.x, start.y, _kShapePressure),
PenPoint(end.x, end.y, _kShapePressure),
];
case ShapeKind.rectangle:
final l = math.min(start.x, end.x);
final r = math.max(start.x, end.x);
final t = math.min(start.y, end.y);
final b = math.max(start.y, end.y);
return [
PenPoint(l, t, _kShapePressure),
PenPoint(r, t, _kShapePressure),
PenPoint(r, b, _kShapePressure),
PenPoint(l, b, _kShapePressure),
PenPoint(l, t, _kShapePressure), // close
];
case ShapeKind.ellipse:
final cx = (start.x + end.x) / 2;
final cy = (start.y + end.y) / 2;
final rx = (end.x - start.x).abs() / 2;
final ry = (end.y - start.y).abs() / 2;
final pts = <PenPoint>[];
for (var i = 0; i <= kEllipseSamples; i++) {
final a = (i / kEllipseSamples) * 2 * math.pi;
pts.add(PenPoint(
cx + rx * math.cos(a),
cy + ry * math.sin(a),
_kShapePressure,
));
}
return pts;
case ShapeKind.arrow:
// Shaft start→end, then back up the shaft to draw the two head barbs so
// the whole arrow is one continuous polyline (no pen lifts).
final dx = end.x - start.x;
final dy = end.y - start.y;
final len = math.sqrt(dx * dx + dy * dy);
final pts = <PenPoint>[
PenPoint(start.x, start.y, _kShapePressure),
PenPoint(end.x, end.y, _kShapePressure),
];
if (len <= 1e-6) return pts; // degenerate: just the (near-zero) shaft
// Arrowhead: barbs at ±[_kArrowAngle] from the reversed shaft direction,
// [_kArrowHead] of the shaft length (capped) long.
final ang = math.atan2(dy, dx);
final head = math.min(len * _kArrowHeadFraction, _kArrowHeadMax);
for (final sign in const [1.0, -1.0]) {
final a = ang + math.pi + sign * _kArrowAngle;
pts.add(PenPoint(
end.x + head * math.cos(a),
end.y + head * math.sin(a),
_kShapePressure,
));
pts.add(PenPoint(end.x, end.y, _kShapePressure)); // back to the tip
}
return pts;
}
}
/// Arrowhead barb length as a fraction of the shaft length.
const double _kArrowHeadFraction = 0.25;
/// Hard cap on the barb length (normalized) so a long arrow's head stays sane.
const double _kArrowHeadMax = 0.06;
/// Half-angle of the arrowhead barbs from the shaft (radians ≈ 28°).
const double _kArrowAngle = 0.5;
/// Return a copy of [points] translated by ([dx],[dy]) in normalized coords,
/// preserving pressure/tilt. Used by the SELECT tool to drag a stroke.
List<PenPoint> translatePoints(List<PenPoint> points, double dx, double dy) =>
[for (final p in points) PenPoint(p.x + dx, p.y + dy, p.pressure, tilt: p.tilt)];
/// A translated copy of [stroke] (its points shifted by [dx],[dy]); color,
/// width, kind, and brush are preserved.
PenStroke translateStroke(PenStroke stroke, double dx, double dy) => PenStroke(
points: translatePoints(stroke.points, dx, dy),
color: stroke.color,
width: stroke.width,
kind: stroke.kind,
brush: stroke.brush,
);
/// Tight normalized bounds of [stroke]'s points, or null when it has no points.
/// Used by the SELECT tool to draw the selection bounding box.
({double left, double top, double right, double bottom})? penStrokeBounds(
PenStroke stroke) {
if (stroke.points.isEmpty) return null;
var l = double.infinity, t = double.infinity;
var r = double.negativeInfinity, b = double.negativeInfinity;
for (final p in stroke.points) {
if (p.x < l) l = p.x;
if (p.y < t) t = p.y;
if (p.x > r) r = p.x;
if (p.y > b) b = p.y;
}
return (left: l, top: t, right: r, bottom: b);
}

View File

@@ -0,0 +1,50 @@
// lib/editor/engine/stroke_bounds.dart
//
// Axis-aligned bounds of strokes in normalized content coordinates. Used for
// broad-phase culling (don't paint/erase/hit-test strokes whose box is off the
// viewport — the infinite board's R1 perf primitive), and as a cheap pre-filter
// before the exact per-point eraser test.
//
// Pure geometry over EditorStroke; no widgets/storage; fully unit-tested.
import 'dart:ui' show Rect;
import 'stroke_model.dart';
/// Tight axis-aligned bounds of [stroke] in normalized coords, or null when the
/// stroke has no points. A single-point stroke yields a zero-size rect at that
/// point.
Rect? strokeBounds(EditorStroke stroke) {
if (stroke.points.isEmpty) return null;
var minX = double.infinity, minY = double.infinity;
var maxX = double.negativeInfinity, maxY = double.negativeInfinity;
for (final p in stroke.points) {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
return Rect.fromLTRB(minX, minY, maxX, maxY);
}
/// Union bounds of [strokes], or null when none have points.
Rect? strokesBounds(Iterable<EditorStroke> strokes) {
Rect? acc;
for (final stroke in strokes) {
final b = strokeBounds(stroke);
if (b == null) continue;
acc = acc == null ? b : acc.expandToInclude(b);
}
return acc;
}
/// Whether [stroke]'s bounds overlap [viewport] (broad-phase visibility test).
/// Empty strokes are never visible. Touching edges count as overlapping.
bool strokeIntersects(EditorStroke stroke, Rect viewport) {
final b = strokeBounds(stroke);
if (b == null) return false;
return b.left <= viewport.right &&
b.right >= viewport.left &&
b.top <= viewport.bottom &&
b.bottom >= viewport.top;
}

View File

@@ -0,0 +1,106 @@
// lib/editor/engine/stroke_eraser.dart
//
// Pure stroke-eraser geometry for the BadNote editor (P0 engine layer).
//
// Operates on the live `PenStroke`/`PenPoint` model in NORMALIZED page
// coordinates ([0,1] x [0,1] relative to the page rectangle). It provides two
// erase modes:
//
// * [strokeHit] — whole-stroke proximity test (the legacy behavior
// that `pen_canvas._eraseAt` used: any point within
// the eraser circle ⇒ the entire stroke is removed).
// * [splitStrokeByCircle] — partial / segment erase: points inside the eraser
// circle are removed, and each maximal run of
// surviving consecutive points becomes its own
// sub-stroke. A long stroke grazed in the middle is
// cut into two pieces instead of vanishing whole.
//
// ASPECT: x and y are each normalized against a different page dimension, so an
// on-screen circular eraser maps to an ELLIPSE in normalized space. Callers pass
// [aspect] = pageHeight / pageWidth so the y delta is corrected and the eraser
// feels round on screen. [aspect] = 1.0 reproduces the legacy (uncorrected,
// width-normalized) distance.
import '../canvas/pen_stroke.dart';
/// Squared, aspect-corrected normalized distance from ([cx],[cy]) to [p].
double _dist2(PenPoint p, double cx, double cy, double aspect) {
final dx = p.x - cx;
final dy = (p.y - cy) * aspect;
return dx * dx + dy * dy;
}
/// True when any point of [stroke] lies within [radius] (normalized, in page-
/// width fractions) of the eraser center ([cx],[cy]). This is the whole-stroke
/// hit test — equivalent to the legacy `_eraseAt` proximity check.
bool strokeHit(
PenStroke stroke,
double cx,
double cy,
double radius, {
double aspect = 1.0,
}) {
final r2 = radius * radius;
for (final p in stroke.points) {
if (_dist2(p, cx, cy, aspect) < r2) return true;
}
return false;
}
/// Partial erase: remove every point of [stroke] within [radius] of the eraser
/// center ([cx],[cy]) and return the surviving sub-strokes (preserving color /
/// width / kind). Each maximal run of >= 2 consecutive surviving points becomes
/// one sub-stroke; orphaned single survivors are dropped (a 1-point dot left
/// between two erased gaps is visually negligible and avoids speckle).
///
/// Returns:
/// * `[stroke]` when nothing is erased (no point hit) — same identity, so the
/// caller can cheaply detect "no change".
/// * `[]` when the whole stroke is erased.
/// * 1+ new strokes otherwise (the cut pieces).
List<PenStroke> splitStrokeByCircle(
PenStroke stroke,
double cx,
double cy,
double radius, {
double aspect = 1.0,
}) {
final r2 = radius * radius;
final pts = stroke.points;
// Fast path: if no point is hit, the stroke is unchanged (return same object).
var anyHit = false;
for (final p in pts) {
if (_dist2(p, cx, cy, aspect) < r2) {
anyHit = true;
break;
}
}
if (!anyHit) return [stroke];
final result = <PenStroke>[];
var run = <PenPoint>[];
void flush() {
if (run.length >= 2) {
result.add(PenStroke(
points: List.of(run),
color: stroke.color,
width: stroke.width,
kind: stroke.kind,
));
}
run = <PenPoint>[];
}
for (final p in pts) {
if (_dist2(p, cx, cy, aspect) < r2) {
flush(); // hit a gap → close the current surviving run
} else {
run.add(p);
}
}
flush();
return result;
}

View File

@@ -0,0 +1,204 @@
// lib/editor/engine/stroke_geometry.dart
//
// Single source of stroke outline geometry for both screen render and export.
// The recipe is lifted verbatim from the proven live
// `ink_painters.buildStrokePath` (lib/editor/canvas/ink_painters.dart): points
// are scaled from normalized page coords to pixels, perfect_freehand produces
// the outline, and a closed fill Path is built. Keeping ONE implementation here
// kills the hairline-export divergence (R7).
import 'dart:ui';
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
import 'brush.dart';
import 'stroke_model.dart';
/// Canonical default for perfect_freehand's `thinning` (how strongly pressure
/// modulates stroke width). The SINGLE source of truth shared by the on-screen
/// painter ([buildStrokeOutline] here and `ink_painters.buildStrokePath`) and
/// the PDF export path, so screen and export can never diverge. `0.85` =
/// pressure visibly sweeps width; preserves the existing feel + export golden.
/// Overridable per-stroke via [PenConfig.pressureSensitivity].
const double kDefaultPenThinning = 0.85;
/// perfect_freehand input-smoothing parameters, shared (single source of truth)
/// by the on-screen painter and the export path so the two can never diverge
/// (guarded by the screen==export parity test). [kPenStreamline] lowers the
/// per-point lag from freehand's 0.5 default to 0.28: paired with
/// [StrokePredictor] lookahead this tracks the Surface Pen more tightly while
/// still damping digitizer jitter. [kPenSmoothing] keeps freehand's 0.5 corner
/// rounding.
const double kPenStreamline = 0.28;
const double kPenSmoothing = 0.5;
/// THE single perfect_freehand outline recipe — the raw outline points for a
/// stroke. Both the on-screen painter ([buildStrokeOutline] / the live
/// `ink_painters.buildStrokePath`) and the PDF export
/// (`pdf_service._buildFreehandPdfPath`) call THIS, so the `StrokeOptions`
/// (thinning / smoothing / streamline / simulatePressure) live in exactly one
/// place and screen↔export can never drift again (R7 — the hairline-export bug
/// was pdf_service hardcoding its own `thinning: 0.7, streamline: 0.5`).
///
/// Callers supply already-pixel-scaled [pfPoints] (because the two stroke
/// models scale differently) plus the per-stroke flags. Returns the closed
/// outline as `List<Offset>` (perfect_freehand 2.x); empty when freehand
/// produces nothing.
List<Offset> freehandOutlinePoints({
required List<pf.PointVector> pfPoints,
required double size,
required bool isHighlighter,
required bool hasRealPressure,
required bool isComplete,
double thinning = kDefaultPenThinning,
BrushProfile? brush,
}) {
if (pfPoints.isEmpty) return const <Offset>[];
return pf.getStroke(
pfPoints,
options: brush != null
// Brush-driven path: every geometry knob (thinning / streamline /
// smoothing / caps / taper / simulatePressure) comes from the
// BrushProfile so each brush renders distinctly. Pressure was already
// pre-warped by the brush's gamma at CAPTURE (PressureCurve), so the
// pre-warp is baked into pfPoints — perfect_freehand stays linear here.
// simulatePressure is forced true only when the device gave us NO real
// pressure, so velocity-thinning still kicks in for mice/trackpads.
? _optionsFromBrush(brush,
size: size,
isComplete: isComplete,
hasRealPressure: hasRealPressure)
: pf.StrokeOptions(
size: size,
// Highlighter keeps a constant width (no thinning); pen uses the
// configurable [thinning] so Surface-Pen pressure changes width.
thinning: isHighlighter ? 0.0 : thinning,
smoothing: kPenSmoothing,
streamline: kPenStreamline,
// Real stylus pressure -> don't simulate; no pressure -> let
// freehand fake it based on velocity (highlighter never simulates).
// perfect_freehand 2.x honors real pressure when simulatePressure
// is false.
simulatePressure: !hasRealPressure && !isHighlighter,
isComplete: isComplete,
),
);
}
/// Build perfect_freehand [pf.StrokeOptions] from a [BrushProfile] (spec §4).
///
/// Geometry only: [BrushProfile.opacity] / [BrushProfile.blendMultiply] are
/// consumed by the painters' [paintForEditorStroke] / `paintForStroke` (via
/// [resolveStrokePaint]), NOT here — this stays a pure outline recipe.
pf.StrokeOptions _optionsFromBrush(
BrushProfile brush, {
required double size,
required bool isComplete,
required bool hasRealPressure,
}) {
return pf.StrokeOptions(
size: size,
thinning: brush.pfThinning,
smoothing: brush.pfSmoothing,
streamline: brush.pfStreamline,
// Honor REAL pressure (already gamma-pre-warped at capture). Only fall back
// to velocity simulation when the device reported no usable pressure.
simulatePressure: brush.simulatePressure || !hasRealPressure,
start: pf.StrokeEndOptions.start(
cap: brush.capStart,
taperEnabled: brush.taper,
),
end: pf.StrokeEndOptions.end(
cap: brush.capEnd,
taperEnabled: brush.taper,
),
isComplete: isComplete,
);
}
/// Builds a closed, fillable outline [Path] for one [stroke], scaled into the
/// pixel space of [pageSize] (which maps normalized [0,1] coords to pixels).
///
/// [isComplete] should be false for the in-progress live stroke so freehand
/// tapers the trailing end correctly, and true for committed strokes.
///
/// [thinning] is perfect_freehand's pressure→width response (see
/// [kDefaultPenThinning]); highlighter always forces `0.0` (constant width).
///
/// Returns an empty [Path] when the stroke has no points (or freehand produces
/// no outline).
Path buildStrokeOutline(
EditorStroke stroke,
Size pageSize, {
required bool isComplete,
double thinning = kDefaultPenThinning,
}) {
final path = Path();
if (stroke.points.isEmpty) return path;
final pfPoints = stroke.points
.map(
(p) => pf.PointVector(
p.x * pageSize.width,
p.y * pageSize.height,
p.pressure ?? 0.5,
),
)
.toList();
// Resolve the brush so each stroke renders with its own geometry. The
// pressure pre-warp ([BrushProfile.pressureGamma]) was already applied at
// capture, so it is baked into the points here.
final brush = brushProfileFor(stroke.brush);
final outline = freehandOutlinePoints(
pfPoints: pfPoints,
size: stroke.width * pageSize.width,
isHighlighter: stroke.tool == EditorTool.highlighter,
hasRealPressure: stroke.points.any((p) => p.pressure != null),
isComplete: isComplete,
thinning: thinning,
brush: brush,
);
if (outline.isEmpty) return path;
path.moveTo(outline.first.dx, outline.first.dy);
for (var i = 1; i < outline.length; i++) {
path.lineTo(outline[i].dx, outline[i].dy);
}
path.close();
return path;
}
/// Mean point pressure (`pressure ?? 0.5`) of an [EditorStroke], for the
/// per-stroke opacity resolution (spec §3/§4 tie ballpoint/pencil opacity to
/// pressure).
double _avgPressure(EditorStroke stroke) {
if (stroke.points.isEmpty) return 0.5;
var sum = 0.0;
for (final p in stroke.points) {
sum += p.pressure ?? 0.5;
}
return sum / stroke.points.length;
}
/// THE single fill [Paint] for an [EditorStroke], with the brush's resolved
/// opacity (multiplied into the color's alpha) and blend mode applied — closes
/// TODO(brush-opacity). Shared by the committed [Picture] and live painters so
/// the EditorStroke render path composites exactly like the PenStroke one.
///
/// The stroke is drawn as ONE fill polygon, so a highlighter's own self-overlap
/// never darkens; cross-stroke overlap darkens via [BlendMode.multiply]
/// (marker build-up). TODO(brush-texture): pencil paper grain still deferred.
Paint paintForEditorStroke(EditorStroke stroke) {
final resolved = resolveStrokePaint(
stroke.brush,
stroke.color,
pressureAvg: _avgPressure(stroke),
);
return Paint()
..color = resolved.color
..blendMode = resolved.blendMode
..style = PaintingStyle.fill
..isAntiAlias = true;
}

View File

@@ -0,0 +1,52 @@
// lib/editor/engine/stroke_host.dart
//
// A CoordinateSpaceHost (plan principle #2: ONE host-agnostic ink engine). A
// host is anything ink attaches to — a PDF page, an infinite board region, or a
// (P5) CAS overlay — identified by [hostId], with a [contentSize] that defines
// the normalized↔pixel mapping, and a committed [StrokeStore]. The viewport
// mounts one AnnotationLayer per host; nothing in the engine knows whether it's
// a page or a board.
//
// Pure (no widgets/pdfrx); ties together StrokeStore (P0) + stroke_bounds
// broad-phase culling. Unit-tested.
import 'dart:ui' show Rect, Size;
import 'stroke_bounds.dart';
import 'stroke_model.dart';
import 'stroke_store.dart';
/// One ink host: identity + content geometry + its committed strokes.
class StrokeHost {
StrokeHost({
required this.hostId,
required this.contentSize,
StrokeStore? store,
}) : store = store ?? StrokeStore();
/// Stable id (e.g. `"doc:<id>:page:<n>"` or a board-region id) used as the
/// ink Picture cache key prefix + the persistence host id.
final String hostId;
/// Content size in logical px at scale 1; normalized [0,1] coords map onto it.
final Size contentSize;
/// Committed strokes for this host (revision-tracked).
final StrokeStore store;
/// Revision of the committed strokes (O(1) repaint gate passthrough).
int get revision => store.revision;
/// Committed strokes whose bounds overlap [viewportNormalized] — broad-phase
/// culling for the infinite board (skip off-screen strokes). For a bounded
/// PDF page the whole page is usually in view, so callers can skip this.
List<EditorStroke> strokesIn(Rect viewportNormalized) {
return [
for (final s in store.committed)
if (strokeIntersects(s, viewportNormalized)) s,
];
}
@override
String toString() => 'StrokeHost($hostId, $contentSize, rev=$revision)';
}

View File

@@ -0,0 +1,212 @@
// lib/editor/engine/stroke_model.dart
//
// Canonical, persistable stroke model for the BadNote editor engine.
//
// This is the single source of truth for ink strokes across the new own-canvas
// engine (screen render + export + persistence). It is a deliberate SUPERSET of
// both the in-memory live `PenStroke`/`PenPoint` (lib/editor/canvas/pen_stroke.dart)
// and the freezed/JSON `InkStroke`/`InkPoint` (lib/models/ink_stroke.dart) so the
// adapters below round-trip losslessly with `InkStroke` (SF1): `tilt`,
// `timestamp` and `pointerDeviceKind` are preserved, never dropped.
//
// Coordinate semantics (matching the live conventions):
// * Point x/y are NORMALIZED to the page rectangle, i.e. in [0,1].
// * Stroke `width` is a FRACTION of the page width, so it scales with zoom.
// @JsonKey is applied to freezed factory parameters (e.g. EditorStroke.brush)
// for fine-grained serialization control; freezed re-emits those annotations on
// generated getters where they're valid, so suppress the source-level
// invalid_annotation_target for the whole file (the documented freezed pattern).
// ignore_for_file: invalid_annotation_target
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:uuid/uuid.dart';
import '../../models/ink_point.dart';
import '../../models/ink_stroke.dart';
import '../../models/pen_tool.dart';
import '../../models/pointer_device_kind.dart';
import '../canvas/pen_stroke.dart';
import 'brush.dart';
part 'stroke_model.freezed.dart';
part 'stroke_model.g.dart';
const _uuid = Uuid();
/// The drawing tools the engine knows about. Extensible; P0 uses these three.
enum EditorTool {
@JsonValue('pen')
pen,
@JsonValue('highlighter')
highlighter,
@JsonValue('eraser')
eraser,
}
/// A single captured sample of a stroke.
///
/// [x]/[y] are normalized to the page rectangle ([0,1]). The remaining fields
/// are a superset of [InkPoint] (nullable here so the live capture path can
/// leave them unset, while [InkStroke] data round-trips intact through the
/// adapters below).
@freezed
abstract class EditorPoint with _$EditorPoint {
const factory EditorPoint({
required double x,
required double y,
double? pressure,
double? tilt,
int? timestamp,
InputDeviceKind? pointerDeviceKind,
}) = _EditorPoint;
factory EditorPoint.fromJson(Map<String, dynamic> json) =>
_$EditorPointFromJson(json);
}
/// A committed stroke in normalized page coordinates.
///
/// [width] is a fraction of page width (matches live `PenStroke.width`).
@freezed
abstract class EditorStroke with _$EditorStroke {
const EditorStroke._();
factory EditorStroke({
required String id,
required List<EditorPoint> points,
@Default(EditorTool.pen) EditorTool tool,
@Default(0xFF000000) int color,
@Default(0.003) double width,
@Default(false) bool filled,
String? textContent,
@Default(14.0) double fontSize,
// Brush the stroke was drawn with — drives the committed render path's
// perfect_freehand geometry + opacity/blend (resolveStrokePaint). Persisted
// as the stable `BrushKind` @JsonValue name (e.g. "ballpoint") so a
// ballpoint/highlighter/pencil stroke keeps its look across close/reopen.
// BACK-COMPAT: sidecars written before this field existed have no `brush`
// key, and an unknown name (a future brush opened by an older build) is
// tolerated — both fall back to fountainPen via the JsonKey below.
@JsonKey(
defaultValue: BrushKind.fountainPen,
unknownEnumValue: BrushKind.fountainPen,
)
@Default(BrushKind.fountainPen)
BrushKind brush,
}) = _EditorStroke;
/// Convenience constructor that generates a uuid [id] when none is supplied.
factory EditorStroke.create({
String? id,
required List<EditorPoint> points,
EditorTool tool = EditorTool.pen,
int color = 0xFF000000,
double width = 0.003,
bool filled = false,
String? textContent,
double fontSize = 14.0,
BrushKind brush = BrushKind.fountainPen,
}) =>
EditorStroke(
id: id ?? _uuid.v4(),
points: points,
tool: tool,
color: color,
width: width,
filled: filled,
textContent: textContent,
fontSize: fontSize,
brush: brush,
);
factory EditorStroke.fromJson(Map<String, dynamic> json) =>
_$EditorStrokeFromJson(json);
// ---- Adapters -----------------------------------------------------------
/// Adapts an in-memory live [PenStroke] (normalized; carries tilt when the
/// native pen plugin supplied it, else null; no timestamp/pointerDeviceKind).
factory EditorStroke.fromPenStroke(PenStroke stroke, {String? id}) =>
EditorStroke(
id: id ?? _uuid.v4(),
points: stroke.points
.map((p) =>
EditorPoint(x: p.x, y: p.y, pressure: p.pressure, tilt: p.tilt))
.toList(),
tool: switch (stroke.kind) {
PenStrokeKind.pen => EditorTool.pen,
PenStrokeKind.highlighter => EditorTool.highlighter,
},
color: stroke.color,
width: stroke.width,
brush: stroke.brush,
);
/// Lossless adapter from the freezed/JSON [InkStroke] model.
factory EditorStroke.fromInkStroke(InkStroke stroke) => EditorStroke(
id: stroke.id,
points: stroke.points
.map(
(p) => EditorPoint(
x: p.x,
y: p.y,
pressure: p.pressure,
tilt: p.tilt,
timestamp: p.timestamp,
pointerDeviceKind: p.pointerDeviceKind,
),
)
.toList(),
tool: _toolFromPenTool(stroke.tool),
color: stroke.color,
width: stroke.strokeWidth,
filled: stroke.filled,
textContent: stroke.textContent,
fontSize: stroke.fontSize,
// InkStroke has no brush field; derive from the tool so a loaded
// highlighter keeps the flat highlighter brush (pens → fountainPen).
brush: stroke.tool == PenTool.highlighter
? BrushKind.highlighter
: BrushKind.fountainPen,
);
/// Lossless adapter to the freezed/JSON [InkStroke] model. Null superset
/// fields fall back to [InkPoint]'s own defaults so the InkStroke round-trip
/// (fromInkStroke → toInkStroke) reproduces the original exactly.
InkStroke toInkStroke({DateTime? createdAt}) => InkStroke(
id: id,
points: points
.map(
(p) => InkPoint(
x: p.x,
y: p.y,
pressure: p.pressure ?? 0.5,
tilt: p.tilt ?? 0.0,
timestamp: p.timestamp ?? 0,
pointerDeviceKind:
p.pointerDeviceKind ?? InputDeviceKind.unknown,
),
)
.toList(),
tool: _toolToPenTool(tool),
color: color,
strokeWidth: width,
createdAt: createdAt ?? DateTime.fromMillisecondsSinceEpoch(0),
filled: filled,
textContent: textContent,
fontSize: fontSize,
);
static EditorTool _toolFromPenTool(PenTool tool) => switch (tool) {
PenTool.highlighter => EditorTool.highlighter,
PenTool.eraser => EditorTool.eraser,
_ => EditorTool.pen,
};
static PenTool _toolToPenTool(EditorTool tool) => switch (tool) {
EditorTool.pen => PenTool.pen,
EditorTool.highlighter => PenTool.highlighter,
EditorTool.eraser => PenTool.eraser,
};
}

View File

@@ -0,0 +1,687 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'stroke_model.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
EditorPoint _$EditorPointFromJson(Map<String, dynamic> json) {
return _EditorPoint.fromJson(json);
}
/// @nodoc
mixin _$EditorPoint {
double get x => throw _privateConstructorUsedError;
double get y => throw _privateConstructorUsedError;
double? get pressure => throw _privateConstructorUsedError;
double? get tilt => throw _privateConstructorUsedError;
int? get timestamp => throw _privateConstructorUsedError;
InputDeviceKind? get pointerDeviceKind => throw _privateConstructorUsedError;
/// Serializes this EditorPoint to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of EditorPoint
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$EditorPointCopyWith<EditorPoint> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $EditorPointCopyWith<$Res> {
factory $EditorPointCopyWith(
EditorPoint value,
$Res Function(EditorPoint) then,
) = _$EditorPointCopyWithImpl<$Res, EditorPoint>;
@useResult
$Res call({
double x,
double y,
double? pressure,
double? tilt,
int? timestamp,
InputDeviceKind? pointerDeviceKind,
});
}
/// @nodoc
class _$EditorPointCopyWithImpl<$Res, $Val extends EditorPoint>
implements $EditorPointCopyWith<$Res> {
_$EditorPointCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of EditorPoint
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? x = null,
Object? y = null,
Object? pressure = freezed,
Object? tilt = freezed,
Object? timestamp = freezed,
Object? pointerDeviceKind = freezed,
}) {
return _then(
_value.copyWith(
x: null == x
? _value.x
: x // ignore: cast_nullable_to_non_nullable
as double,
y: null == y
? _value.y
: y // ignore: cast_nullable_to_non_nullable
as double,
pressure: freezed == pressure
? _value.pressure
: pressure // ignore: cast_nullable_to_non_nullable
as double?,
tilt: freezed == tilt
? _value.tilt
: tilt // ignore: cast_nullable_to_non_nullable
as double?,
timestamp: freezed == timestamp
? _value.timestamp
: timestamp // ignore: cast_nullable_to_non_nullable
as int?,
pointerDeviceKind: freezed == pointerDeviceKind
? _value.pointerDeviceKind
: pointerDeviceKind // ignore: cast_nullable_to_non_nullable
as InputDeviceKind?,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$EditorPointImplCopyWith<$Res>
implements $EditorPointCopyWith<$Res> {
factory _$$EditorPointImplCopyWith(
_$EditorPointImpl value,
$Res Function(_$EditorPointImpl) then,
) = __$$EditorPointImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
double x,
double y,
double? pressure,
double? tilt,
int? timestamp,
InputDeviceKind? pointerDeviceKind,
});
}
/// @nodoc
class __$$EditorPointImplCopyWithImpl<$Res>
extends _$EditorPointCopyWithImpl<$Res, _$EditorPointImpl>
implements _$$EditorPointImplCopyWith<$Res> {
__$$EditorPointImplCopyWithImpl(
_$EditorPointImpl _value,
$Res Function(_$EditorPointImpl) _then,
) : super(_value, _then);
/// Create a copy of EditorPoint
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? x = null,
Object? y = null,
Object? pressure = freezed,
Object? tilt = freezed,
Object? timestamp = freezed,
Object? pointerDeviceKind = freezed,
}) {
return _then(
_$EditorPointImpl(
x: null == x
? _value.x
: x // ignore: cast_nullable_to_non_nullable
as double,
y: null == y
? _value.y
: y // ignore: cast_nullable_to_non_nullable
as double,
pressure: freezed == pressure
? _value.pressure
: pressure // ignore: cast_nullable_to_non_nullable
as double?,
tilt: freezed == tilt
? _value.tilt
: tilt // ignore: cast_nullable_to_non_nullable
as double?,
timestamp: freezed == timestamp
? _value.timestamp
: timestamp // ignore: cast_nullable_to_non_nullable
as int?,
pointerDeviceKind: freezed == pointerDeviceKind
? _value.pointerDeviceKind
: pointerDeviceKind // ignore: cast_nullable_to_non_nullable
as InputDeviceKind?,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$EditorPointImpl implements _EditorPoint {
const _$EditorPointImpl({
required this.x,
required this.y,
this.pressure,
this.tilt,
this.timestamp,
this.pointerDeviceKind,
});
factory _$EditorPointImpl.fromJson(Map<String, dynamic> json) =>
_$$EditorPointImplFromJson(json);
@override
final double x;
@override
final double y;
@override
final double? pressure;
@override
final double? tilt;
@override
final int? timestamp;
@override
final InputDeviceKind? pointerDeviceKind;
@override
String toString() {
return 'EditorPoint(x: $x, y: $y, pressure: $pressure, tilt: $tilt, timestamp: $timestamp, pointerDeviceKind: $pointerDeviceKind)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$EditorPointImpl &&
(identical(other.x, x) || other.x == x) &&
(identical(other.y, y) || other.y == y) &&
(identical(other.pressure, pressure) ||
other.pressure == pressure) &&
(identical(other.tilt, tilt) || other.tilt == tilt) &&
(identical(other.timestamp, timestamp) ||
other.timestamp == timestamp) &&
(identical(other.pointerDeviceKind, pointerDeviceKind) ||
other.pointerDeviceKind == pointerDeviceKind));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
x,
y,
pressure,
tilt,
timestamp,
pointerDeviceKind,
);
/// Create a copy of EditorPoint
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$EditorPointImplCopyWith<_$EditorPointImpl> get copyWith =>
__$$EditorPointImplCopyWithImpl<_$EditorPointImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$EditorPointImplToJson(this);
}
}
abstract class _EditorPoint implements EditorPoint {
const factory _EditorPoint({
required final double x,
required final double y,
final double? pressure,
final double? tilt,
final int? timestamp,
final InputDeviceKind? pointerDeviceKind,
}) = _$EditorPointImpl;
factory _EditorPoint.fromJson(Map<String, dynamic> json) =
_$EditorPointImpl.fromJson;
@override
double get x;
@override
double get y;
@override
double? get pressure;
@override
double? get tilt;
@override
int? get timestamp;
@override
InputDeviceKind? get pointerDeviceKind;
/// Create a copy of EditorPoint
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$EditorPointImplCopyWith<_$EditorPointImpl> get copyWith =>
throw _privateConstructorUsedError;
}
EditorStroke _$EditorStrokeFromJson(Map<String, dynamic> json) {
return _EditorStroke.fromJson(json);
}
/// @nodoc
mixin _$EditorStroke {
String get id => throw _privateConstructorUsedError;
List<EditorPoint> get points => throw _privateConstructorUsedError;
EditorTool get tool => throw _privateConstructorUsedError;
int get color => throw _privateConstructorUsedError;
double get width => throw _privateConstructorUsedError;
bool get filled => throw _privateConstructorUsedError;
String? get textContent => throw _privateConstructorUsedError;
double get fontSize =>
throw _privateConstructorUsedError; // Brush the stroke was drawn with — drives the committed render path's
// perfect_freehand geometry + opacity/blend (resolveStrokePaint). Persisted
// as the stable `BrushKind` @JsonValue name (e.g. "ballpoint") so a
// ballpoint/highlighter/pencil stroke keeps its look across close/reopen.
// BACK-COMPAT: sidecars written before this field existed have no `brush`
// key, and an unknown name (a future brush opened by an older build) is
// tolerated — both fall back to fountainPen via the JsonKey below.
@JsonKey(
defaultValue: BrushKind.fountainPen,
unknownEnumValue: BrushKind.fountainPen,
)
BrushKind get brush => throw _privateConstructorUsedError;
/// Serializes this EditorStroke to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of EditorStroke
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$EditorStrokeCopyWith<EditorStroke> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $EditorStrokeCopyWith<$Res> {
factory $EditorStrokeCopyWith(
EditorStroke value,
$Res Function(EditorStroke) then,
) = _$EditorStrokeCopyWithImpl<$Res, EditorStroke>;
@useResult
$Res call({
String id,
List<EditorPoint> points,
EditorTool tool,
int color,
double width,
bool filled,
String? textContent,
double fontSize,
@JsonKey(
defaultValue: BrushKind.fountainPen,
unknownEnumValue: BrushKind.fountainPen,
)
BrushKind brush,
});
}
/// @nodoc
class _$EditorStrokeCopyWithImpl<$Res, $Val extends EditorStroke>
implements $EditorStrokeCopyWith<$Res> {
_$EditorStrokeCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of EditorStroke
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? points = null,
Object? tool = null,
Object? color = null,
Object? width = null,
Object? filled = null,
Object? textContent = freezed,
Object? fontSize = null,
Object? brush = null,
}) {
return _then(
_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
points: null == points
? _value.points
: points // ignore: cast_nullable_to_non_nullable
as List<EditorPoint>,
tool: null == tool
? _value.tool
: tool // ignore: cast_nullable_to_non_nullable
as EditorTool,
color: null == color
? _value.color
: color // ignore: cast_nullable_to_non_nullable
as int,
width: null == width
? _value.width
: width // ignore: cast_nullable_to_non_nullable
as double,
filled: null == filled
? _value.filled
: filled // ignore: cast_nullable_to_non_nullable
as bool,
textContent: freezed == textContent
? _value.textContent
: textContent // ignore: cast_nullable_to_non_nullable
as String?,
fontSize: null == fontSize
? _value.fontSize
: fontSize // ignore: cast_nullable_to_non_nullable
as double,
brush: null == brush
? _value.brush
: brush // ignore: cast_nullable_to_non_nullable
as BrushKind,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$EditorStrokeImplCopyWith<$Res>
implements $EditorStrokeCopyWith<$Res> {
factory _$$EditorStrokeImplCopyWith(
_$EditorStrokeImpl value,
$Res Function(_$EditorStrokeImpl) then,
) = __$$EditorStrokeImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
String id,
List<EditorPoint> points,
EditorTool tool,
int color,
double width,
bool filled,
String? textContent,
double fontSize,
@JsonKey(
defaultValue: BrushKind.fountainPen,
unknownEnumValue: BrushKind.fountainPen,
)
BrushKind brush,
});
}
/// @nodoc
class __$$EditorStrokeImplCopyWithImpl<$Res>
extends _$EditorStrokeCopyWithImpl<$Res, _$EditorStrokeImpl>
implements _$$EditorStrokeImplCopyWith<$Res> {
__$$EditorStrokeImplCopyWithImpl(
_$EditorStrokeImpl _value,
$Res Function(_$EditorStrokeImpl) _then,
) : super(_value, _then);
/// Create a copy of EditorStroke
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? points = null,
Object? tool = null,
Object? color = null,
Object? width = null,
Object? filled = null,
Object? textContent = freezed,
Object? fontSize = null,
Object? brush = null,
}) {
return _then(
_$EditorStrokeImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
points: null == points
? _value._points
: points // ignore: cast_nullable_to_non_nullable
as List<EditorPoint>,
tool: null == tool
? _value.tool
: tool // ignore: cast_nullable_to_non_nullable
as EditorTool,
color: null == color
? _value.color
: color // ignore: cast_nullable_to_non_nullable
as int,
width: null == width
? _value.width
: width // ignore: cast_nullable_to_non_nullable
as double,
filled: null == filled
? _value.filled
: filled // ignore: cast_nullable_to_non_nullable
as bool,
textContent: freezed == textContent
? _value.textContent
: textContent // ignore: cast_nullable_to_non_nullable
as String?,
fontSize: null == fontSize
? _value.fontSize
: fontSize // ignore: cast_nullable_to_non_nullable
as double,
brush: null == brush
? _value.brush
: brush // ignore: cast_nullable_to_non_nullable
as BrushKind,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$EditorStrokeImpl extends _EditorStroke {
_$EditorStrokeImpl({
required this.id,
required final List<EditorPoint> points,
this.tool = EditorTool.pen,
this.color = 0xFF000000,
this.width = 0.003,
this.filled = false,
this.textContent,
this.fontSize = 14.0,
@JsonKey(
defaultValue: BrushKind.fountainPen,
unknownEnumValue: BrushKind.fountainPen,
)
this.brush = BrushKind.fountainPen,
}) : _points = points,
super._();
factory _$EditorStrokeImpl.fromJson(Map<String, dynamic> json) =>
_$$EditorStrokeImplFromJson(json);
@override
final String id;
final List<EditorPoint> _points;
@override
List<EditorPoint> get points {
if (_points is EqualUnmodifiableListView) return _points;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_points);
}
@override
@JsonKey()
final EditorTool tool;
@override
@JsonKey()
final int color;
@override
@JsonKey()
final double width;
@override
@JsonKey()
final bool filled;
@override
final String? textContent;
@override
@JsonKey()
final double fontSize;
// Brush the stroke was drawn with — drives the committed render path's
// perfect_freehand geometry + opacity/blend (resolveStrokePaint). Persisted
// as the stable `BrushKind` @JsonValue name (e.g. "ballpoint") so a
// ballpoint/highlighter/pencil stroke keeps its look across close/reopen.
// BACK-COMPAT: sidecars written before this field existed have no `brush`
// key, and an unknown name (a future brush opened by an older build) is
// tolerated — both fall back to fountainPen via the JsonKey below.
@override
@JsonKey(
defaultValue: BrushKind.fountainPen,
unknownEnumValue: BrushKind.fountainPen,
)
final BrushKind brush;
@override
String toString() {
return 'EditorStroke(id: $id, points: $points, tool: $tool, color: $color, width: $width, filled: $filled, textContent: $textContent, fontSize: $fontSize, brush: $brush)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$EditorStrokeImpl &&
(identical(other.id, id) || other.id == id) &&
const DeepCollectionEquality().equals(other._points, _points) &&
(identical(other.tool, tool) || other.tool == tool) &&
(identical(other.color, color) || other.color == color) &&
(identical(other.width, width) || other.width == width) &&
(identical(other.filled, filled) || other.filled == filled) &&
(identical(other.textContent, textContent) ||
other.textContent == textContent) &&
(identical(other.fontSize, fontSize) ||
other.fontSize == fontSize) &&
(identical(other.brush, brush) || other.brush == brush));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
const DeepCollectionEquality().hash(_points),
tool,
color,
width,
filled,
textContent,
fontSize,
brush,
);
/// Create a copy of EditorStroke
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$EditorStrokeImplCopyWith<_$EditorStrokeImpl> get copyWith =>
__$$EditorStrokeImplCopyWithImpl<_$EditorStrokeImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$EditorStrokeImplToJson(this);
}
}
abstract class _EditorStroke extends EditorStroke {
factory _EditorStroke({
required final String id,
required final List<EditorPoint> points,
final EditorTool tool,
final int color,
final double width,
final bool filled,
final String? textContent,
final double fontSize,
@JsonKey(
defaultValue: BrushKind.fountainPen,
unknownEnumValue: BrushKind.fountainPen,
)
final BrushKind brush,
}) = _$EditorStrokeImpl;
_EditorStroke._() : super._();
factory _EditorStroke.fromJson(Map<String, dynamic> json) =
_$EditorStrokeImpl.fromJson;
@override
String get id;
@override
List<EditorPoint> get points;
@override
EditorTool get tool;
@override
int get color;
@override
double get width;
@override
bool get filled;
@override
String? get textContent;
@override
double get fontSize; // Brush the stroke was drawn with — drives the committed render path's
// perfect_freehand geometry + opacity/blend (resolveStrokePaint). Persisted
// as the stable `BrushKind` @JsonValue name (e.g. "ballpoint") so a
// ballpoint/highlighter/pencil stroke keeps its look across close/reopen.
// BACK-COMPAT: sidecars written before this field existed have no `brush`
// key, and an unknown name (a future brush opened by an older build) is
// tolerated — both fall back to fountainPen via the JsonKey below.
@override
@JsonKey(
defaultValue: BrushKind.fountainPen,
unknownEnumValue: BrushKind.fountainPen,
)
BrushKind get brush;
/// Create a copy of EditorStroke
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$EditorStrokeImplCopyWith<_$EditorStrokeImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,88 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'stroke_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$EditorPointImpl _$$EditorPointImplFromJson(Map<String, dynamic> json) =>
_$EditorPointImpl(
x: (json['x'] as num).toDouble(),
y: (json['y'] as num).toDouble(),
pressure: (json['pressure'] as num?)?.toDouble(),
tilt: (json['tilt'] as num?)?.toDouble(),
timestamp: (json['timestamp'] as num?)?.toInt(),
pointerDeviceKind: $enumDecodeNullable(
_$InputDeviceKindEnumMap,
json['pointerDeviceKind'],
),
);
Map<String, dynamic> _$$EditorPointImplToJson(_$EditorPointImpl instance) =>
<String, dynamic>{
'x': instance.x,
'y': instance.y,
'pressure': instance.pressure,
'tilt': instance.tilt,
'timestamp': instance.timestamp,
'pointerDeviceKind': _$InputDeviceKindEnumMap[instance.pointerDeviceKind],
};
const _$InputDeviceKindEnumMap = {
InputDeviceKind.touch: 'touch',
InputDeviceKind.mouse: 'mouse',
InputDeviceKind.stylus: 'stylus',
InputDeviceKind.invertedStylus: 'invertedStylus',
InputDeviceKind.trackpad: 'trackpad',
InputDeviceKind.unknown: 'unknown',
};
_$EditorStrokeImpl _$$EditorStrokeImplFromJson(Map<String, dynamic> json) =>
_$EditorStrokeImpl(
id: json['id'] as String,
points: (json['points'] as List<dynamic>)
.map((e) => EditorPoint.fromJson(e as Map<String, dynamic>))
.toList(),
tool:
$enumDecodeNullable(_$EditorToolEnumMap, json['tool']) ??
EditorTool.pen,
color: (json['color'] as num?)?.toInt() ?? 0xFF000000,
width: (json['width'] as num?)?.toDouble() ?? 0.003,
filled: json['filled'] as bool? ?? false,
textContent: json['textContent'] as String?,
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 14.0,
brush:
$enumDecodeNullable(
_$BrushKindEnumMap,
json['brush'],
unknownValue: BrushKind.fountainPen,
) ??
BrushKind.fountainPen,
);
Map<String, dynamic> _$$EditorStrokeImplToJson(_$EditorStrokeImpl instance) =>
<String, dynamic>{
'id': instance.id,
'points': instance.points,
'tool': _$EditorToolEnumMap[instance.tool]!,
'color': instance.color,
'width': instance.width,
'filled': instance.filled,
'textContent': instance.textContent,
'fontSize': instance.fontSize,
'brush': _$BrushKindEnumMap[instance.brush]!,
};
const _$EditorToolEnumMap = {
EditorTool.pen: 'pen',
EditorTool.highlighter: 'highlighter',
EditorTool.eraser: 'eraser',
};
const _$BrushKindEnumMap = {
BrushKind.fountainPen: 'fountainPen',
BrushKind.ballpoint: 'ballpoint',
BrushKind.highlighter: 'highlighter',
BrushKind.pencil: 'pencil',
};

View File

@@ -0,0 +1,56 @@
// Lightweight stroke prediction — extrapolates the next point from recent
// velocity so the live stroke tip leads the digitizer slightly (lower perceived
// latency). Not a full ink-stroke-modeler; intentionally small and testable.
import 'dart:ui';
class PredictedPoint {
const PredictedPoint(this.offset, this.pressure);
final Offset offset;
final double pressure;
}
class StrokePredictor {
StrokePredictor({this.lookaheadMs = 8});
/// How far ahead to project, in milliseconds of recent velocity.
final double lookaheadMs;
Offset? _prev;
double? _prevPressure;
DateTime? _prevAt;
Offset _velocity = Offset.zero;
void reset() {
_prev = null;
_prevPressure = null;
_prevAt = null;
_velocity = Offset.zero;
}
/// Feed a real sample; returns an optional predicted tip ahead of [point].
PredictedPoint? observe(Offset point, double pressure, {DateTime? at}) {
final now = at ?? DateTime.now();
if (_prev != null && _prevAt != null) {
final dtMs = now.difference(_prevAt!).inMicroseconds / 1000.0;
if (dtMs > 0.5 && dtMs < 80) {
final raw = (point - _prev!) * (1000.0 / dtMs);
// EMA blend to avoid jerky predictions.
_velocity = Offset(
_velocity.dx * 0.55 + raw.dx * 0.45,
_velocity.dy * 0.55 + raw.dy * 0.45,
);
}
}
_prev = point;
_prevPressure = pressure;
_prevAt = now;
if (_velocity.distance < 40) return null; // idle / slow — no predict
final tip = point + _velocity * (lookaheadMs / 1000.0);
return PredictedPoint(tip, pressure);
}
/// Last known pressure (for predicted tip).
double get lastPressure => _prevPressure ?? 0.5;
}

View File

@@ -0,0 +1,80 @@
// lib/editor/engine/stroke_simplify.dart
//
// RamerDouglasPeucker stroke point reduction. A fast Surface-Pen stroke can
// land hundreds of nearly-collinear samples; thinning them before persistence
// shrinks the DB row + speeds re-rasterization (R1/R10 perf) with no visible
// change. Endpoints + perceptually-significant vertices are kept; pressure/tilt
// ride along on the retained points.
//
// Pure geometry over EditorStroke (normalized coords); fully unit-tested. The
// commit path can call this before saveHost; the live in-progress stroke is left
// untouched so drawing stays crisp.
import 'stroke_model.dart';
/// Returns [stroke] with its points reduced by RDP at [tolerance] (perpendicular
/// distance in normalized units; larger = more aggressive). Strokes with <= 2
/// points, or a non-positive tolerance, are returned unchanged.
EditorStroke simplifyStroke(EditorStroke stroke, {double tolerance = 0.0008}) {
final pts = stroke.points;
if (pts.length <= 2 || tolerance <= 0) return stroke;
final keep = List<bool>.filled(pts.length, false);
keep[0] = true;
keep[pts.length - 1] = true;
_rdp(pts, 0, pts.length - 1, tolerance * tolerance, keep);
final reduced = <EditorPoint>[
for (var i = 0; i < pts.length; i++)
if (keep[i]) pts[i],
];
if (reduced.length == pts.length) return stroke;
return stroke.copyWith(points: reduced);
}
// Iterative-friendly recursion over the index range [first, last].
void _rdp(
List<EditorPoint> pts,
int first,
int last,
double tolSq,
List<bool> keep,
) {
if (last <= first + 1) return;
var maxDistSq = 0.0;
var index = -1;
final ax = pts[first].x, ay = pts[first].y;
final bx = pts[last].x, by = pts[last].y;
for (var i = first + 1; i < last; i++) {
final d = _perpDistSq(pts[i].x, pts[i].y, ax, ay, bx, by);
if (d > maxDistSq) {
maxDistSq = d;
index = i;
}
}
if (maxDistSq > tolSq && index != -1) {
keep[index] = true;
_rdp(pts, first, index, tolSq, keep);
_rdp(pts, index, last, tolSq, keep);
}
}
/// Squared perpendicular distance of (px,py) from the segment (ax,ay)-(bx,by).
/// Degenerate segment (a==b) falls back to squared distance to the point.
double _perpDistSq(
double px,
double py,
double ax,
double ay,
double bx,
double by,
) {
final dx = bx - ax, dy = by - ay;
final lenSq = dx * dx + dy * dy;
if (lenSq == 0) {
final ex = px - ax, ey = py - ay;
return ex * ex + ey * ey;
}
final cross = (px - ax) * dy - (py - ay) * dx;
return (cross * cross) / lenSq;
}

View File

@@ -0,0 +1,56 @@
// lib/editor/engine/stroke_store.dart
//
// Mutable, revision-tracked store for committed EditorStrokes.
//
// Every mutation bumps [revision] (monotonic int). Consumers use the revision
// as an O(1) repaint gate: if revision has not changed since the last paint,
// nothing needs to be redrawn (StaticInkPainter.shouldRepaint).
import 'stroke_model.dart';
/// Holds the ordered list of committed [EditorStroke]s for one ink host (e.g.
/// a page or annotation layer). Every mutating operation bumps [revision].
///
/// This class is intentionally NOT a ChangeNotifier / Listenable — callers
/// poll the revision number from within CustomPainter.shouldRepaint, so no
/// subscription machinery is needed here.
class StrokeStore {
final List<EditorStroke> _strokes = [];
int _revision = 0;
/// Monotonically increasing counter. Bumped on every mutation.
int get revision => _revision;
/// Unmodifiable ordered list of committed strokes.
List<EditorStroke> get committed => List.unmodifiable(_strokes);
/// Appends [stroke] and bumps the revision.
void add(EditorStroke stroke) {
_strokes.add(stroke);
_revision++;
}
/// Removes the stroke with the given [id] (no-op if not found) and bumps
/// the revision only when a stroke was actually removed.
void removeById(String id) {
final before = _strokes.length;
_strokes.removeWhere((s) => s.id == id);
if (_strokes.length != before) {
_revision++;
}
}
/// Replaces the entire stroke list and bumps the revision.
void replaceAll(List<EditorStroke> strokes) {
_strokes
..clear()
..addAll(strokes);
_revision++;
}
/// Clears all strokes and bumps the revision.
void clear() {
_strokes.clear();
_revision++;
}
}

View File

@@ -0,0 +1,75 @@
// lib/editor/engine/undo_stack.dart
//
// Generic undo/redo stack with a fixed capacity.
//
// RECORD DISCIPLINE: call `record(currentState)` BEFORE applying a mutation.
// The stack saves the pre-mutation snapshot so that `undo` can restore it.
//
// Example:
// final stack = UndoStack<List<PenStroke>>(cap: 50);
// // User draws a stroke:
// stack.record(List.unmodifiable(strokes)); // snapshot before mutation
// strokes = [...strokes, newStroke]; // apply mutation
//
// Snapshots are treated as opaque, immutable values; the caller is responsible
// for passing copies/immutable lists rather than mutable references.
/// A capped undo/redo stack for arbitrary snapshot types.
///
/// Capacity defaults to 50 entries. When the cap is reached the oldest
/// undo snapshot is silently dropped to make room.
class UndoStack<T> {
UndoStack({int cap = 50}) : _cap = cap;
final int _cap;
// Index 0 = oldest, last = most-recent snapshot available for undo.
final List<T> _undoStack = [];
final List<T> _redoStack = [];
/// True when there is at least one snapshot that can be undone.
bool get canUndo => _undoStack.isNotEmpty;
/// True when there is at least one snapshot that can be redone.
bool get canRedo => _redoStack.isNotEmpty;
/// Save [snapshot] (the state BEFORE a mutation) onto the undo stack and
/// clear the redo stack (any branched future is discarded).
///
/// If the stack is at capacity the oldest snapshot is dropped.
void record(T snapshot) {
if (_undoStack.length >= _cap) {
_undoStack.removeAt(0);
}
_undoStack.add(snapshot);
_redoStack.clear();
}
/// Undo the last recorded mutation.
///
/// Returns the snapshot to restore, pushing [current] (the live state at
/// the moment of calling) onto the redo stack. Returns `null` if [canUndo]
/// is false.
T? undo(T current) {
if (!canUndo) return null;
_redoStack.add(current);
return _undoStack.removeLast();
}
/// Redo the last undone mutation.
///
/// Returns the snapshot to restore and pushes it back onto the undo stack
/// so it can be undone again. Returns `null` if [canRedo] is false.
T? redo() {
if (!canRedo) return null;
final snapshot = _redoStack.removeLast();
_undoStack.add(snapshot);
return snapshot;
}
/// Clear both stacks.
void clear() {
_undoStack.clear();
_redoStack.clear();
}
}

View File

@@ -0,0 +1,38 @@
// lib/editor/input/diagnostic_logger.dart
//
// Compatibility facade over [BadNoteLog] + [PenEventRing]. The PDF editor's
// toolbar toggle still calls start/stop; globally, [BadNoteLog.start] runs at
// app launch so packaged builds always have a session file.
import 'dart:async';
import '../../diagnostics/badnote_log.dart';
class DiagnosticLogger {
DiagnosticLogger._();
static final DiagnosticLogger instance = DiagnosticLogger._();
bool _verbose = false;
bool get isActive => _verbose || BadNoteLog.instance.path != null;
/// Absolute path of the structured session log (preferred), else null.
String? get path => BadNoteLog.instance.path;
/// Begin a verbose input session (also ensures the global log is running).
Future<void> start() async {
_verbose = true;
await BadNoteLog.instance.start();
BadNoteLog.instance.info(LogSubsystem.diag, 'verbose_input_on');
}
void log(String line) {
BadNoteLog.instance.debug(LogSubsystem.penNative, line);
}
Future<void> stop() async {
if (!_verbose) return;
_verbose = false;
BadNoteLog.instance.info(LogSubsystem.diag, 'verbose_input_off');
await BadNoteLog.instance.flush();
}
}

View File

@@ -0,0 +1,46 @@
// lib/editor/input/input_arbiter.dart
//
// Pure draw-vs-pan/zoom arbitration for the pen-first canvas (P0 step 4 —
// extracted verbatim from `pen_canvas.dart` so the make-or-break gesture rules
// are decided by ONE testable place rather than inline in a StatefulWidget).
//
// The model (clean-room from Saber, proven live):
// - A DRAW gesture is exactly ONE active pointer that is a stylus / inverted
// stylus / mouse, OR (when the finger-drawing toggle is on) a single finger.
// - >= 2 active pointers ALWAYS means pan/zoom (pinch); never draw.
// - Palm rejection: a finger never draws unless the user explicitly enabled
// finger-drawing — so a resting palm pans (or is ignored) instead of marking.
// - A hardware pen button mapped to `pan` suppresses drawing so the shared
// InteractiveViewer pans instead.
//
// These are PURE functions (no widget/IO state) so the whole truth table is
// unit-tested; `pen_canvas.dart` owns the live pointer map and delegates the
// decisions here.
import 'package:flutter/gestures.dart' show PointerDeviceKind;
/// Whether [kind] is a pen (tip or flipped eraser end).
bool isStylusKind(PointerDeviceKind kind) =>
kind == PointerDeviceKind.stylus ||
kind == PointerDeviceKind.invertedStylus;
/// Decide whether the gesture currently forming should DRAW.
///
/// True iff there is exactly one active pointer, drawing is not suppressed by a
/// hardware pan button, and the pointer is a draw device:
/// - stylus / inverted stylus → always draws,
/// - mouse → always draws (desktop authoring),
/// - touch → draws only when [fingerDrawingEnabled] (else it pans / is palm).
bool shouldDraw({
required int activePointerCount,
required PointerDeviceKind kind,
required bool fingerDrawingEnabled,
required bool hwPanActive,
}) {
if (activePointerCount != 1) return false;
if (hwPanActive) return false;
if (isStylusKind(kind)) return true;
if (kind == PointerDeviceKind.mouse) return true;
if (kind == PointerDeviceKind.touch) return fingerDrawingEnabled;
return false;
}

View File

@@ -0,0 +1,310 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import 'pressure_curve.dart' show kNaturalPressureGamma;
/// Default eraser radius as a fraction of page width (the legacy fixed value,
/// now the default of the configurable [PenConfig.eraserRadius]).
const double kDefaultEraserRadius = 0.02;
/// Action that can be triggered by a hardware pen button or the eraser end.
enum PenButtonAction {
none,
eraser,
undo,
toggleTool,
pan,
/// Rising-edge: switch to the universal stroke [select] tool (OneNote-like).
select,
/// Hold to temporarily enable PDF text selection.
selectText,
}
/// Immutable configuration for pen input behaviour.
///
/// Persisted under SharedPreferences key [PenConfigController.prefsKey].
class PenConfig {
const PenConfig({
this.sideButton = PenButtonAction.selectText,
this.eraserEnd = PenButtonAction.eraser,
this.pressureGamma = kNaturalPressureGamma,
this.palmRejectionMs = 150.0,
this.fingerDrawing = false,
this.penWidth = 0.004,
this.highlighterWidth = 0.02,
this.pressureSensitivity = kDefaultPenThinning,
this.eraserRadius = kDefaultEraserRadius,
this.eraserWholeStroke = false,
}) : assert(pressureGamma >= 0.3 && pressureGamma <= 3.0,
'pressureGamma must be in [0.3, 3.0]'),
assert(palmRejectionMs >= 0.0 && palmRejectionMs <= 500.0,
'palmRejectionMs must be in [0, 500]'),
assert(pressureSensitivity >= 0.0 && pressureSensitivity <= 1.0,
'pressureSensitivity must be in [0, 1]'),
assert(eraserRadius >= 0.005 && eraserRadius <= 0.1,
'eraserRadius must be in [0.005, 0.1]');
/// Which action fires when the side barrel button is held.
final PenButtonAction sideButton;
/// Which action fires when the eraser end of the pen is used.
final PenButtonAction eraserEnd;
/// Gamma exponent for the pressure curve: effective = pressure ^ gamma.
/// Range: [0.3, 3.0], default 1.0 (linear).
final double pressureGamma;
/// Duration in milliseconds after a pen-down event during which touch
/// contact is treated as palm and rejected.
/// Range: [0, 500], default 150.
final double palmRejectionMs;
/// Whether finger touch strokes are drawn when no pen is present.
final bool fingerDrawing;
/// Pen stroke width as a fraction of the canvas width.
final double penWidth;
/// Highlighter stroke width as a fraction of the canvas width.
final double highlighterWidth;
/// How strongly stylus pressure modulates stroke width — maps directly to
/// perfect_freehand's `thinning`. Range [0,1]; `0` = constant width,
/// higher = pressure sweeps width more (Saber's `StrokeOptions.thinning`
/// model). Default [kDefaultPenThinning] so the out-of-box feel and the
/// export golden are unchanged.
final double pressureSensitivity;
/// Eraser radius as a fraction of page width. Range [0.005, 0.1], default
/// [kDefaultEraserRadius]. Controls both the live erase hit area and the
/// on-screen eraser cursor.
final double eraserRadius;
/// When true the eraser removes a WHOLE stroke on contact (OneNote-style
/// stroke eraser); when false it does a partial / segment erase (the default,
/// rnote-style point eraser).
final bool eraserWholeStroke;
PenConfig copyWith({
PenButtonAction? sideButton,
PenButtonAction? eraserEnd,
double? pressureGamma,
double? palmRejectionMs,
bool? fingerDrawing,
double? penWidth,
double? highlighterWidth,
double? pressureSensitivity,
double? eraserRadius,
bool? eraserWholeStroke,
}) {
return PenConfig(
sideButton: sideButton ?? this.sideButton,
eraserEnd: eraserEnd ?? this.eraserEnd,
pressureGamma: pressureGamma ?? this.pressureGamma,
palmRejectionMs: palmRejectionMs ?? this.palmRejectionMs,
fingerDrawing: fingerDrawing ?? this.fingerDrawing,
penWidth: penWidth ?? this.penWidth,
highlighterWidth: highlighterWidth ?? this.highlighterWidth,
pressureSensitivity: pressureSensitivity ?? this.pressureSensitivity,
eraserRadius: eraserRadius ?? this.eraserRadius,
eraserWholeStroke: eraserWholeStroke ?? this.eraserWholeStroke,
);
}
Map<String, dynamic> toJson() => {
'sideButton': sideButton.name,
'eraserEnd': eraserEnd.name,
'pressureGamma': pressureGamma,
'palmRejectionMs': palmRejectionMs,
'fingerDrawing': fingerDrawing,
'penWidth': penWidth,
'highlighterWidth': highlighterWidth,
'pressureSensitivity': pressureSensitivity,
'eraserRadius': eraserRadius,
'eraserWholeStroke': eraserWholeStroke,
};
factory PenConfig.fromJson(Map<String, dynamic> json) {
return PenConfig(
sideButton:
PenButtonAction.values.asNameMap()[json['sideButton'] as String? ?? ''] ??
PenButtonAction.eraser,
eraserEnd:
PenButtonAction.values.asNameMap()[json['eraserEnd'] as String? ?? ''] ??
PenButtonAction.eraser,
pressureGamma:
(json['pressureGamma'] as num?)?.toDouble() ?? kNaturalPressureGamma,
palmRejectionMs: (json['palmRejectionMs'] as num?)?.toDouble() ?? 150.0,
fingerDrawing: json['fingerDrawing'] as bool? ?? false,
penWidth: (json['penWidth'] as num?)?.toDouble() ?? 0.004,
highlighterWidth: (json['highlighterWidth'] as num?)?.toDouble() ?? 0.02,
pressureSensitivity:
(json['pressureSensitivity'] as num?)?.toDouble() ?? kDefaultPenThinning,
eraserRadius:
(json['eraserRadius'] as num?)?.toDouble() ?? kDefaultEraserRadius,
eraserWholeStroke: json['eraserWholeStroke'] as bool? ?? false,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PenConfig &&
runtimeType == other.runtimeType &&
sideButton == other.sideButton &&
eraserEnd == other.eraserEnd &&
pressureGamma == other.pressureGamma &&
palmRejectionMs == other.palmRejectionMs &&
fingerDrawing == other.fingerDrawing &&
penWidth == other.penWidth &&
highlighterWidth == other.highlighterWidth &&
pressureSensitivity == other.pressureSensitivity &&
eraserRadius == other.eraserRadius &&
eraserWholeStroke == other.eraserWholeStroke;
@override
int get hashCode => Object.hash(
sideButton,
eraserEnd,
pressureGamma,
palmRejectionMs,
fingerDrawing,
penWidth,
highlighterWidth,
pressureSensitivity,
eraserRadius,
eraserWholeStroke,
);
}
/// Applies a gamma power curve to a raw pressure value.
///
/// Returns `pressure.clamp(0, 1) ^ gamma` as a [double].
double applyPressureCurve(double pressure, double gamma) =>
pow(pressure.clamp(0.0, 1.0), gamma).toDouble();
/// Manages [PenConfig] persistence and live updates.
///
/// Load with [PenConfigController.load], then listen to changes via
/// [ChangeNotifier]. Setters immediately update the in-memory value,
/// notify listeners, and persist to SharedPreferences.
class PenConfigController extends ChangeNotifier {
PenConfigController._(this._prefs, this._value);
/// The SharedPreferences key under which [PenConfig] JSON is stored.
static const prefsKey = 'pen_config_v1';
/// Marker so the legacy-gamma migration in [load] runs at most once.
static const _gammaMigratedKey = 'pen_config_gamma_migrated_v1';
final SharedPreferences _prefs;
PenConfig _value;
/// The current pen configuration.
PenConfig get value => _value;
/// Loads persisted config from SharedPreferences. Falls back to defaults
/// if no config has been saved yet or if the stored JSON is invalid.
static Future<PenConfigController> load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(prefsKey);
PenConfig config;
if (raw == null) {
config = const PenConfig();
} else {
try {
config = PenConfig.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
} catch (_) {
config = const PenConfig();
}
}
// One-time migration: before this build, pressureGamma was never applied to
// strokes (a dead slider), so a stored 1.0 is the legacy inert default, not
// a deliberate "linear feel" choice. Upgrade it ONCE to the natural curve so
// the pen feels right out of the box. Guarded by a marker key so that, after
// migrating, the user is free to set gamma back to 1.0 and have it stick.
if (!(prefs.getBool(_gammaMigratedKey) ?? false)) {
if (config.pressureGamma == 1.0) {
config = config.copyWith(pressureGamma: kNaturalPressureGamma);
await prefs.setString(prefsKey, jsonEncode(config.toJson()));
}
await prefs.setBool(_gammaMigratedKey, true);
}
return PenConfigController._(prefs, config);
}
Future<void> _persist() async {
await _prefs.setString(prefsKey, jsonEncode(_value.toJson()));
}
Future<void> setSideButton(PenButtonAction action) async {
_value = _value.copyWith(sideButton: action);
notifyListeners();
await _persist();
}
Future<void> setEraserEnd(PenButtonAction action) async {
_value = _value.copyWith(eraserEnd: action);
notifyListeners();
await _persist();
}
/// Sets [PenConfig.pressureGamma]. Clamped to [0.3, 3.0].
Future<void> setPressureGamma(double gamma) async {
_value = _value.copyWith(pressureGamma: gamma.clamp(0.3, 3.0));
notifyListeners();
await _persist();
}
/// Sets [PenConfig.palmRejectionMs]. Clamped to [0, 500].
Future<void> setPalmRejectionMs(double ms) async {
_value = _value.copyWith(palmRejectionMs: ms.clamp(0.0, 500.0));
notifyListeners();
await _persist();
}
Future<void> setFingerDrawing(bool enabled) async {
_value = _value.copyWith(fingerDrawing: enabled);
notifyListeners();
await _persist();
}
Future<void> setPenWidth(double width) async {
_value = _value.copyWith(penWidth: width);
notifyListeners();
await _persist();
}
Future<void> setHighlighterWidth(double width) async {
_value = _value.copyWith(highlighterWidth: width);
notifyListeners();
await _persist();
}
/// Sets [PenConfig.pressureSensitivity]. Clamped to [0, 1].
/// Sets [PenConfig.eraserRadius]. Clamped to [0.005, 0.1].
Future<void> setEraserRadius(double radius) async {
_value = _value.copyWith(eraserRadius: radius.clamp(0.005, 0.1));
await _persist();
}
/// Sets [PenConfig.eraserWholeStroke] (true = OneNote-style stroke eraser).
Future<void> setEraserWholeStroke(bool whole) async {
_value = _value.copyWith(eraserWholeStroke: whole);
await _persist();
}
Future<void> setPressureSensitivity(double sensitivity) async {
_value = _value.copyWith(pressureSensitivity: sensitivity.clamp(0.0, 1.0));
notifyListeners();
await _persist();
}
}

View File

@@ -0,0 +1,201 @@
// lib/editor/input/pen_input_service.dart
//
// Dart side of the native Windows pen observer (`windows/runner/pen_channel.cpp`).
//
// Streams barrel / eraser / tilt / PRESSURE from WM_POINTER + GetPointerPenInfo.
// Flutter's PointerEvent.pressure on Windows is unreliable (often flat); native
// pressure (0..1024 → [0,1]) is preferred when [PenHardwareState.pressureValid].
import 'dart:async';
import 'package:flutter/services.dart';
import '../../diagnostics/badnote_log.dart';
import '../../diagnostics/pen_event_ring.dart';
import 'diagnostic_logger.dart';
/// Latest hardware pen state delivered by the native observer.
class PenHardwareState {
const PenHardwareState({
this.barrel = false,
this.inverted = false,
this.eraser = false,
this.tiltX = 0.0,
this.tiltY = 0.0,
this.pressure = 0.0,
this.pressureValid = false,
});
final bool barrel;
final bool inverted;
final bool eraser;
final double tiltX;
final double tiltY;
/// Normalized stylus pressure in [0,1] when [pressureValid] is true.
final double pressure;
final bool pressureValid;
double get tiltMagnitude {
final t = tiltX * tiltX + tiltY * tiltY;
return t <= 0 ? 0.0 : _sqrt(t);
}
static const empty = PenHardwareState();
}
double _sqrt(double v) {
if (v <= 0) return 0;
var x = v;
var last = 0.0;
for (var i = 0; i < 12 && x != last; i++) {
last = x;
x = 0.5 * (x + v / x);
}
return x;
}
class PenInputService {
PenInputService._();
static final PenInputService instance = PenInputService._();
static const EventChannel _channel = EventChannel('badnote/pen');
StreamSubscription<dynamic>? _sub;
PenHardwareState _current = PenHardwareState.empty;
PenHardwareState get current => _current;
bool get isActive => _active;
bool _active = false;
final List<VoidCallback> _listeners = <VoidCallback>[];
/// Notify when native hardware state changes (barrel / pressure / tilt).
void addListener(VoidCallback listener) => _listeners.add(listener);
void removeListener(VoidCallback listener) => _listeners.remove(listener);
void _notifyListeners() {
for (final l in List<VoidCallback>.of(_listeners)) {
l();
}
}
int _diagPtr = 0;
int _diagPen = 0;
int _diagMouse = 0;
int _diagMsg = 0;
int _orPtrFlags = 0;
int _orPenFlags = 0;
int _orPenMask = 0;
int _btnChangeLast = 0;
int _tiltAbsMax = 0;
double _pressureMaxSeen = 0;
String _hex(int v) => '0x${v.toRadixString(16)}';
String get debugSummary => _active
? 'native ptr=$_diagPtr pen=$_diagPen mouse=$_diagMouse msg=${_hex(_diagMsg)}'
'\n orPtrFlags=${_hex(_orPtrFlags)} orPenFlags=${_hex(_orPenFlags)}'
' mask=${_hex(_orPenMask)} btnChg=$_btnChangeLast tiltMax=$_tiltAbsMax'
'\n pressure=${_current.pressureValid ? _current.pressure.toStringAsFixed(3) : "n/a"}'
' maxSeen=${_pressureMaxSeen.toStringAsFixed(3)}'
: 'native: channel silent (no events)';
void start() {
if (_sub != null) return;
try {
_sub = _channel.receiveBroadcastStream().listen(
_onEvent,
onError: (Object _) {},
cancelOnError: false,
);
} catch (_) {}
}
void _onEvent(dynamic event) {
if (event is! Map) return;
final flags = (event['flags'] as num?)?.toInt() ?? 0;
final pressureValid = ((event['pressureValid'] as num?)?.toInt() ?? 0) != 0;
final pressure = (event['pressure'] as num?)?.toDouble() ?? 0.0;
_current = PenHardwareState(
barrel: flags & 0x1 != 0,
inverted: flags & 0x2 != 0,
eraser: flags & 0x4 != 0,
tiltX: (event['tiltX'] as num?)?.toDouble() ?? 0.0,
tiltY: (event['tiltY'] as num?)?.toDouble() ?? 0.0,
pressure: pressure.clamp(0.0, 1.0),
pressureValid: pressureValid,
);
_diagPtr = (event['diagPtr'] as num?)?.toInt() ?? _diagPtr;
_diagPen = (event['diagPen'] as num?)?.toInt() ?? _diagPen;
_diagMouse = (event['diagMouse'] as num?)?.toInt() ?? _diagMouse;
_diagMsg = (event['diagMsg'] as num?)?.toInt() ?? _diagMsg;
_orPtrFlags = (event['orPtrFlags'] as num?)?.toInt() ?? _orPtrFlags;
_orPenFlags = (event['orPenFlags'] as num?)?.toInt() ?? _orPenFlags;
_orPenMask = (event['orPenMask'] as num?)?.toInt() ?? _orPenMask;
_btnChangeLast = (event['btnChangeLast'] as num?)?.toInt() ?? _btnChangeLast;
_tiltAbsMax = (event['tiltAbsMax'] as num?)?.toInt() ?? _tiltAbsMax;
_pressureMaxSeen =
(event['pressureMaxSeen'] as num?)?.toDouble() ?? _pressureMaxSeen;
if (pressureValid && pressure > _pressureMaxSeen) {
_pressureMaxSeen = pressure;
}
_active = true;
final rawPtr = (event['rawPtrFlags'] as num?)?.toInt() ?? 0;
final rawPen = (event['rawPenFlags'] as num?)?.toInt() ?? 0;
final rawMask = (event['rawPenMask'] as num?)?.toInt() ?? 0;
final btnChange = (event['btnChange'] as num?)?.toInt() ?? 0;
final key =
'$rawPtr,$rawPen,$rawMask,$btnChange,${_current.tiltX},${_current.tiltY},'
'${pressureValid ? pressure.toStringAsFixed(2) : "x"}';
if (key != _lastPenLogKey) {
_lastPenLogKey = key;
PenEventRing.instance.recordHardware(
barrel: _current.barrel,
eraser: _current.eraser,
inverted: _current.inverted,
tiltX: _current.tiltX,
tiltY: _current.tiltY,
);
BadNoteLog.instance.debug(
LogSubsystem.penNative,
'pen_hw',
fields: {
'ptrFlags': '0x${rawPtr.toRadixString(16)}',
'penFlags': '0x${rawPen.toRadixString(16)}',
'mask': '0x${rawMask.toRadixString(16)}',
'btnChg': btnChange,
'tiltX': _current.tiltX,
'tiltY': _current.tiltY,
'pressure': pressureValid ? pressure : null,
'pressureValid': pressureValid,
'barrel': _current.barrel,
'eraser': _current.eraser,
'inverted': _current.inverted,
},
);
DiagnosticLogger.instance.log(
'PEN ptrFlags=0x${rawPtr.toRadixString(16)} '
'penFlags=0x${rawPen.toRadixString(16)} '
'mask=0x${rawMask.toRadixString(16)} btnChg=$btnChange '
'tilt=${_current.tiltX.toStringAsFixed(0)},${_current.tiltY.toStringAsFixed(0)} '
'p=${pressureValid ? pressure.toStringAsFixed(3) : "n/a"} '
'msg=0x${_diagMsg.toRadixString(16)} resolved=0x${flags.toRadixString(16)}',
);
}
_notifyListeners();
}
String _lastPenLogKey = '';
void stop() {
_sub?.cancel();
_sub = null;
_active = false;
_current = PenHardwareState.empty;
}
}

View File

@@ -0,0 +1,213 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../engine/brush.dart';
/// OneNote-style independent pen slot: brush + color + thickness together.
///
/// Selecting a slot restores all three; color dots / thickness controls edit
/// only the active slot.
class PenSlot {
const PenSlot({
required this.id,
required this.brush,
required this.color,
required this.width,
});
final String id;
/// Brush kind for this slot (fountain / ballpoint / pencil — not highlighter).
final BrushKind brush;
final Color color;
/// Stroke width as a fraction of page width.
final double width;
PenSlot copyWith({
String? id,
BrushKind? brush,
Color? color,
double? width,
}) {
return PenSlot(
id: id ?? this.id,
brush: brush ?? this.brush,
color: color ?? this.color,
width: width ?? this.width,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'brush': brush.name,
'color': color.toARGB32(),
'width': width,
};
factory PenSlot.fromJson(Map<String, dynamic> json) {
final brushName = json['brush'] as String? ?? '';
return PenSlot(
id: json['id'] as String? ?? 'slot_0',
brush: BrushKind.values.asNameMap()[brushName] ?? BrushKind.fountainPen,
color: Color(json['color'] as int? ?? 0xFF000000),
width: (json['width'] as num?)?.toDouble() ?? 0.006,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PenSlot &&
runtimeType == other.runtimeType &&
id == other.id &&
brush == other.brush &&
color.toARGB32() == other.color.toARGB32() &&
width == other.width;
@override
int get hashCode => Object.hash(id, brush, color.toARGB32(), width);
}
/// Default pen slots seeded OneNote-style (fountain / ballpoint / pencil).
List<PenSlot> kDefaultPenSlots() => const [
PenSlot(
id: 'slot_0',
brush: BrushKind.fountainPen,
color: Colors.black,
width: 0.006,
),
PenSlot(
id: 'slot_1',
brush: BrushKind.ballpoint,
color: Colors.blue,
width: 0.0022,
),
PenSlot(
id: 'slot_2',
brush: BrushKind.pencil,
color: Colors.green,
width: 0.003,
),
];
/// S / M / L thickness presets (page-width fractions) for the toolbar picker.
const double kThicknessSmall = 0.0022;
const double kThicknessMedium = 0.006;
const double kThicknessLarge = 0.012;
/// Allowed range for slot stroke width (page-width fraction).
const double kPenSlotWidthMin = 0.001;
const double kPenSlotWidthMax = 0.05;
/// Manages independent [PenSlot]s with SharedPreferences persistence.
///
/// Load with [PenSlotsController.load], then listen via [ChangeNotifier].
class PenSlotsController extends ChangeNotifier {
PenSlotsController._(this._prefs, this._slots, this._activeId);
/// SharedPreferences key for the slots JSON blob.
static const prefsKey = 'pen_slots_v1';
final SharedPreferences _prefs;
List<PenSlot> _slots;
String _activeId;
List<PenSlot> get slots => List.unmodifiable(_slots);
String get activeId => _activeId;
PenSlot get active {
for (final s in _slots) {
if (s.id == _activeId) return s;
}
return _slots.first;
}
/// Loads persisted slots, or seeds [kDefaultPenSlots] on first run / corrupt
/// JSON.
static Future<PenSlotsController> load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(prefsKey);
var slots = kDefaultPenSlots();
var activeId = slots.first.id;
if (raw != null) {
try {
final map = jsonDecode(raw) as Map<String, dynamic>;
final list = map['slots'] as List<dynamic>?;
if (list != null && list.isNotEmpty) {
slots = [
for (final e in list)
PenSlot.fromJson(e as Map<String, dynamic>),
];
}
final storedActive = map['activeId'] as String?;
if (storedActive != null &&
slots.any((s) => s.id == storedActive)) {
activeId = storedActive;
} else {
activeId = slots.first.id;
}
} catch (_) {
slots = kDefaultPenSlots();
activeId = slots.first.id;
}
}
return PenSlotsController._(prefs, slots, activeId);
}
Future<void> _persist() async {
await _prefs.setString(
prefsKey,
jsonEncode({
'activeId': _activeId,
'slots': [for (final s in _slots) s.toJson()],
}),
);
}
int _indexOfActive() {
final i = _slots.indexWhere((s) => s.id == _activeId);
return i >= 0 ? i : 0;
}
void _replaceActive(PenSlot next) {
final i = _indexOfActive();
_slots = [..._slots]..[i] = next;
}
/// Selects [id] as the active slot (restores brush + color + width).
Future<void> select(String id) async {
if (!_slots.any((s) => s.id == id)) return;
if (_activeId == id) return;
_activeId = id;
notifyListeners();
await _persist();
}
/// Sets the active slot's color.
Future<void> setActiveColor(Color c) async {
_replaceActive(active.copyWith(color: c));
notifyListeners();
await _persist();
}
/// Sets the active slot's stroke width (clamped).
Future<void> setActiveWidth(double w) async {
final clamped = w.clamp(kPenSlotWidthMin, kPenSlotWidthMax);
_replaceActive(active.copyWith(width: clamped));
notifyListeners();
await _persist();
}
/// Sets the active slot's brush kind.
Future<void> setActiveBrush(BrushKind b) async {
if (b == BrushKind.highlighter) return;
_replaceActive(active.copyWith(brush: b));
notifyListeners();
await _persist();
}
}

View File

@@ -0,0 +1,112 @@
// lib/editor/input/pressure_curve.dart
//
// Configurable pen-pressure response (F5 — the user's repeated "可配置笔" ask).
// Raw normalized stylus pressure [0,1] is pre-shaped here before it reaches
// perfect_freehand, giving two user-facing knobs:
// - [floor]: a minimum output (the plan's marker "min-width floor" — a fixed-
// pressure marker uses floor≈1.0; a pen uses 0.0).
// - [gamma]: the response exponent — γ<1 makes light touches register more
// width (more sensitive), γ>1 requires firmer pressure (less sensitive).
//
// Named [PressureCurveShape] presets mirror rnote-style curves (linear / soft /
// Pow2 / cubic / log / sqrt) via [PressureCurve.shaped]. Logarithmic uses
// ln(1+k·p)/ln(1+k); all others use p^gamma.
//
// Pure value type (widget-free, storage-free) so the full mapping is unit
// tested; PenConfig / the canvas wire it later (the wiring touches the live
// draw path and is validated on-device).
import 'dart:math' as math;
/// Default pressure-response exponent. <1 so light-to-medium pressure registers
/// more width — the responsive, rnote/OneNote-like feel — instead of the raw
/// linear mapping that made the pen feel like a pressure-sensitive finger.
const double kNaturalPressureGamma = 0.7;
/// Default minimum shaped pressure: even the lightest touch keeps ~12% of the
/// dynamic range so thin strokes have body instead of scratchy near-zero width.
const double kNaturalPressureFloor = 0.12;
/// Steepness for [PressureCurveShape.logarithmic]: `ln(1+k·p)/ln(1+k)`.
const double kLogarithmicPressureK = 9.0;
/// Named rnote-style pressure-response shapes.
enum PressureCurveShape {
/// Identity: gamma 1.
linear,
/// Light-touch sensitive: gamma ≈ 0.6.
soft,
/// rnote Pow2 / fountain: gamma 2.
quadratic,
/// gamma 3.
cubic,
/// Log curve: ln(1+k·p)/ln(1+k).
logarithmic,
/// Pencil: gamma 0.5.
sqrt,
}
/// Maps raw normalized pressure to a shaped response in `[floor, 1]`.
class PressureCurve {
const PressureCurve({
this.floor = 0.0,
this.gamma = 1.0,
this.shape,
}) : assert(floor >= 0.0 && floor < 1.0),
assert(gamma > 0.0);
/// Named-shape factory. Sets [gamma] for power-law shapes; logarithmic
/// ignores gamma and uses [kLogarithmicPressureK] in [apply].
factory PressureCurve.shaped(
PressureCurveShape shape, {
double floor = 0.0,
}) {
switch (shape) {
case PressureCurveShape.linear:
return PressureCurve(floor: floor, gamma: 1.0, shape: shape);
case PressureCurveShape.soft:
return PressureCurve(floor: floor, gamma: 0.6, shape: shape);
case PressureCurveShape.quadratic:
return PressureCurve(floor: floor, gamma: 2.0, shape: shape);
case PressureCurveShape.cubic:
return PressureCurve(floor: floor, gamma: 3.0, shape: shape);
case PressureCurveShape.logarithmic:
return PressureCurve(floor: floor, gamma: 1.0, shape: shape);
case PressureCurveShape.sqrt:
return PressureCurve(floor: floor, gamma: 0.5, shape: shape);
}
}
/// Minimum output (>=0, <1). 0 = full dynamic range; raise toward 1 for a
/// fixed-pressure feel (marker).
final double floor;
/// Response exponent (>0). 1 = linear; <1 = more sensitive at light pressure;
/// >1 = firmer. Unused when [shape] is [PressureCurveShape.logarithmic].
final double gamma;
/// Optional named shape. When [PressureCurveShape.logarithmic], [apply] uses
/// the log formula; otherwise (or when null) uses `p^gamma`.
final PressureCurveShape? shape;
/// Linear, full-range pen response (identity).
static const PressureCurve linear = PressureCurve();
/// Shape [pressure] (clamped to [0,1]) into `[floor, 1]`.
double apply(double pressure) {
final p = pressure.isNaN ? 0.0 : pressure.clamp(0.0, 1.0);
final double shaped;
if (shape == PressureCurveShape.logarithmic) {
shaped = math.log(1.0 + kLogarithmicPressureK * p) /
math.log(1.0 + kLogarithmicPressureK);
} else {
shaped = gamma == 1.0 ? p : math.pow(p, gamma).toDouble();
}
return floor + (1.0 - floor) * shaped;
}
}

View File

@@ -0,0 +1,88 @@
// lib/editor/input/tool_settings.dart
//
// Per-tool settings memory for the tool palette (F1/F5/F11). Each tool keeps its
// OWN color + width, so switching pen → highlighter → pen restores the pen's
// last color/width instead of bleeding the highlighter's. Immutable value type
// with copy-on-write updates; the toolbar holds one of these and persists it.
//
// Pure (no widgets/storage); fully unit-tested.
import 'package:flutter/foundation.dart';
enum EditorToolType { pen, highlighter, eraser }
/// Color (ARGB int) + width (fraction of page width) for one tool. The eraser
/// ignores color but keeps a width (its radius).
@immutable
class ToolConfig {
const ToolConfig({required this.color, required this.width});
final int color;
final double width;
ToolConfig copyWith({int? color, double? width}) =>
ToolConfig(color: color ?? this.color, width: width ?? this.width);
@override
bool operator ==(Object other) =>
other is ToolConfig && other.color == color && other.width == width;
@override
int get hashCode => Object.hash(color, width);
}
/// The active tool + each tool's remembered [ToolConfig].
@immutable
class ToolSettings {
const ToolSettings({required this.active, required Map<EditorToolType, ToolConfig> configs})
: _configs = configs;
/// Sensible starting state: black thin pen, yellow fat highlighter, medium
/// eraser; pen active.
factory ToolSettings.defaults() => const ToolSettings(
active: EditorToolType.pen,
configs: {
EditorToolType.pen: ToolConfig(color: 0xFF000000, width: 0.003),
EditorToolType.highlighter:
ToolConfig(color: 0x80FFEB3B, width: 0.02),
EditorToolType.eraser: ToolConfig(color: 0x00000000, width: 0.02),
},
);
final EditorToolType active;
final Map<EditorToolType, ToolConfig> _configs;
ToolConfig configFor(EditorToolType tool) =>
_configs[tool] ?? const ToolConfig(color: 0xFF000000, width: 0.003);
ToolConfig get activeConfig => configFor(active);
/// Switch the active tool (each tool's own color/width is remembered).
ToolSettings withActive(EditorToolType tool) =>
ToolSettings(active: tool, configs: _configs);
ToolSettings _withConfig(EditorToolType tool, ToolConfig config) {
return ToolSettings(
active: active,
configs: {..._configs, tool: config},
);
}
/// Set the ACTIVE tool's color (no-op semantics for the eraser are the
/// caller's choice; the value is still stored).
ToolSettings withColor(int color) =>
_withConfig(active, activeConfig.copyWith(color: color));
/// Set the ACTIVE tool's width.
ToolSettings withWidth(double width) =>
_withConfig(active, activeConfig.copyWith(width: width));
@override
bool operator ==(Object other) =>
other is ToolSettings &&
other.active == active &&
mapEquals(other._configs, _configs);
@override
int get hashCode => Object.hash(active, Object.hashAll(_configs.entries));
}

View File

@@ -0,0 +1,75 @@
// lib/editor/layout/double_page_layout.dart
//
// Two-up (spread) layout foundation for book-like reading (F2/F3 — the user's
// explicit "two-page spread" ask). Pages are grouped into spread ROWS; each row
// then stacks vertically exactly like continuous-single, so windowing reuses
// PageStackMetrics on the row heights.
//
// Pure geometry (no widgets / pdfrx): the row-mounting widget is device-gated;
// the pairing + row-height math is here, fully unit-tested. Building it ahead of
// its phase is zero-rework-risk (it is not rendering and the P0.5 perf gate
// can't invalidate pure geometry).
import 'page_viewport.dart';
import '../pdf/pdf_document_source.dart';
/// Groups page indices into two-up spread rows. With [coverAlone], page 0 sits
/// on its own row (a book cover / title page) and the rest pair 1-2, 3-4, …;
/// otherwise pages pair from 0. A trailing odd page occupies a single-page row.
List<List<int>> pairIntoRows(int pageCount, {bool coverAlone = false}) {
assert(pageCount >= 0);
final rows = <List<int>>[];
var i = 0;
if (coverAlone && pageCount > 0) {
rows.add(<int>[0]);
i = 1;
}
while (i < pageCount) {
if (i + 1 < pageCount) {
rows.add(<int>[i, i + 1]);
i += 2;
} else {
rows.add(<int>[i]);
i += 1;
}
}
return rows;
}
/// Height of each spread row when each page is fit to HALF of [columnWidth]
/// (two pages share the column). A row's height is the tallest of its pages so
/// both pages sit on a common baseline. Non-positive page widths contribute 0.
List<double> spreadRowHeights(
PageDocumentSource source,
double columnWidth,
List<List<int>> rows,
) {
assert(columnWidth >= 0);
final half = columnWidth / 2;
return rows.map((row) {
var tallest = 0.0;
for (final pageIndex in row) {
final size = source.pageSize(pageIndex);
if (size.width <= 0) continue;
final fit = half * (size.height / size.width);
if (fit > tallest) tallest = fit;
}
return tallest;
}).toList(growable: false);
}
/// Continuous-DOUBLE stacking metrics: pair pages into spread rows, then stack
/// the rows vertically. [PageStackMetrics.visibleRange] over the result yields
/// the visible ROW range; map rows back to pages via the [pairIntoRows] result.
PageStackMetrics spreadStackMetrics(
PageDocumentSource source,
double columnWidth, {
bool coverAlone = false,
double gap = 0.0,
}) {
final rows = pairIntoRows(source.pageCount, coverAlone: coverAlone);
return PageStackMetrics(
pageHeights: spreadRowHeights(source, columnWidth, rows),
gap: gap,
);
}

View File

@@ -0,0 +1,196 @@
// lib/editor/layout/page_viewport.dart
//
// Continuous-single layout geometry: the PURE windowing math that decides which
// pages are mounted for a given scroll position (P0.5 step 10). Pages stack
// vertically; only the pages intersecting the viewport ± a cache band are
// mounted (windowed lazy hosting → 60fps on a 300-page doc, R1).
//
// This file is intentionally widget-free and pdfrx-free: the actual page
// mounting (a re-laid-out PdfPageView + the AnnotationLayer per page) and the
// zoom-settle DPI refresh are device-gated and live in the viewport WIDGET +
// pdf/page_tile.dart. The geometry is separated so it is unit-testable without
// a GPU or a real document.
import 'dart:math' as math;
/// An inclusive range of page indices to mount. [isEmpty] when nothing
/// intersects (e.g. an empty document or a scroll position past the end with no
/// cache band reaching back).
class PageWindow {
const PageWindow(this.first, this.last);
/// Sentinel empty window.
static const PageWindow empty = PageWindow(0, -1);
final int first;
final int last;
bool get isEmpty => last < first;
int get count => isEmpty ? 0 : (last - first + 1);
bool contains(int index) => !isEmpty && index >= first && index <= last;
@override
bool operator ==(Object other) =>
other is PageWindow && other.first == first && other.last == last;
@override
int get hashCode => Object.hash(first, last);
@override
String toString() => isEmpty ? 'PageWindow.empty' : 'PageWindow($first..$last)';
}
/// Vertical stacking metrics for continuous-single layout.
///
/// Page `i` occupies the half-open band `[offsetOf(i), offsetOf(i) + heightOf(i))`
/// in content (scale-1) coordinates, with [gap] inserted between consecutive
/// pages. Cumulative tops are precomputed so [visibleRange] is O(log n) per
/// scroll frame.
class PageStackMetrics {
PageStackMetrics({required List<double> pageHeights, this.gap = 0.0})
: assert(gap >= 0),
_heights = List<double>.unmodifiable(pageHeights),
_tops = _cumulativeTops(pageHeights, gap);
final List<double> _heights;
/// Top edge of each page in content coordinates (length == pageCount).
final List<double> _tops;
/// Gap between consecutive pages in content units.
final double gap;
int get pageCount => _heights.length;
/// Total scrollable content height (0 when there are no pages).
double get totalExtent {
if (_heights.isEmpty) return 0;
return _tops.last + _heights.last;
}
double heightOf(int index) => _heights[index];
/// Top edge (content coordinate) of page [index].
double offsetOf(int index) => _tops[index];
static List<double> _cumulativeTops(List<double> heights, double gap) {
final tops = List<double>.filled(heights.length, 0);
var acc = 0.0;
for (var i = 0; i < heights.length; i++) {
tops[i] = acc;
acc += heights[i] + gap;
}
return List<double>.unmodifiable(tops);
}
/// The inclusive page range intersecting the viewport
/// `[scrollOffset, scrollOffset + viewportExtent)` grown by [cacheExtent] on
/// each side. Pages whose band overlaps the grown window (even partially) are
/// included; the result is clamped to `[0, pageCount-1]`.
///
/// Returns [PageWindow.empty] for an empty document or a window that does not
/// reach any page.
PageWindow visibleRange(
double scrollOffset,
double viewportExtent, {
double cacheExtent = 0.0,
}) {
if (_heights.isEmpty) return PageWindow.empty;
assert(viewportExtent >= 0);
assert(cacheExtent >= 0);
final double windowTop = scrollOffset - cacheExtent;
final double windowBottom = scrollOffset + viewportExtent + cacheExtent;
// No overlap with the content at all.
if (windowBottom <= 0 || windowTop >= totalExtent) {
return PageWindow.empty;
}
final int first = _firstIntersecting(windowTop);
final int last = _lastIntersecting(windowBottom);
if (last < first) return PageWindow.empty;
return PageWindow(first, last);
}
/// Maximum scroll offset so the last page bottom rests at the viewport
/// bottom (never negative — a document shorter than the viewport can't
/// scroll).
double maxScrollExtent(double viewportExtent) {
final max = totalExtent - viewportExtent;
return max > 0 ? max : 0.0;
}
/// Clamps [scrollOffset] into the legal `[0, maxScrollExtent]` range.
double clampScroll(double scrollOffset, double viewportExtent) {
final max = maxScrollExtent(viewportExtent);
if (scrollOffset < 0) return 0.0;
return scrollOffset > max ? max : scrollOffset;
}
/// The "current" page for a scroll position: the page covering the LARGEST
/// portion of the viewport `[scrollOffset, scrollOffset + viewportExtent)`.
/// Drives the page-number indicator + thumbnail-grid highlight (F4). Returns
/// 0 for an empty document.
int dominantPageAt(double scrollOffset, double viewportExtent) {
if (_heights.isEmpty) return 0;
final window = visibleRange(scrollOffset, viewportExtent);
if (window.isEmpty) {
// Past the end / before the start → clamp to nearest real page.
return scrollOffset <= 0 ? 0 : pageCount - 1;
}
final viewTop = scrollOffset;
final viewBottom = scrollOffset + viewportExtent;
var best = window.first;
var bestOverlap = -1.0;
for (var i = window.first; i <= window.last; i++) {
final top = _tops[i];
final bottom = top + _heights[i];
final overlap =
math.min(bottom, viewBottom) - math.max(top, viewTop);
if (overlap > bestOverlap) {
bestOverlap = overlap;
best = i;
}
}
return best;
}
/// Lowest index whose band bottom is strictly after [y] (i.e. the first page
/// that the window's top edge does not sit fully below).
int _firstIntersecting(double y) {
// Find the first page whose bottom edge (top + height) > y.
var lo = 0;
var hi = _heights.length - 1;
var result = _heights.length - 1;
while (lo <= hi) {
final mid = (lo + hi) >> 1;
final bottom = _tops[mid] + _heights[mid];
if (bottom > y) {
result = mid;
hi = mid - 1;
} else {
lo = mid + 1;
}
}
return math.max(0, result);
}
/// Highest index whose top edge is strictly before [y].
int _lastIntersecting(double y) {
var lo = 0;
var hi = _heights.length - 1;
var result = 0;
while (lo <= hi) {
final mid = (lo + hi) >> 1;
if (_tops[mid] < y) {
result = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return math.min(_heights.length - 1, result);
}
}

View File

@@ -0,0 +1,39 @@
// lib/editor/layout/viewport_fit.dart
//
// Pure fit/centering math for the viewport's initial transform + the reader
// "fit width / fit page" actions (F3). Today this lives as untested ad-hoc
// arithmetic in pen_editor_screen._centerPage; extracting it here makes it
// testable and shared by the new viewport widget.
//
// Content coordinates are scale-1 logical px; the returned scale + offset place
// content inside the viewport. No widgets beyond dart:ui Size/Offset.
import 'dart:ui' show Offset, Size;
/// Scale so content width fills the viewport width (fit-to-width, the
/// continuous-single default). 0 for non-positive content width.
double fitWidthScale(Size content, double viewportWidth) {
if (content.width <= 0) return 0;
return viewportWidth / content.width;
}
/// Scale so the whole content fits inside the viewport (letterboxed) —
/// min(widthFit, heightFit). 0 for non-positive content extents.
double fitPageScale(Size content, Size viewport) {
if (content.width <= 0 || content.height <= 0) return 0;
final w = viewport.width / content.width;
final h = viewport.height / content.height;
return w < h ? w : h;
}
/// Top-left translation that centers [content] scaled by [scale] within
/// [viewport]. When the scaled content is larger than the viewport on an axis
/// the offset is negative (content overflows equally on both sides).
Offset centerOffset(Size content, Size viewport, double scale) {
final scaledW = content.width * scale;
final scaledH = content.height * scale;
return Offset(
(viewport.width - scaledW) / 2.0,
(viewport.height - scaledH) / 2.0,
);
}

View File

@@ -0,0 +1,85 @@
// lib/editor/link/link_graph.dart
//
// Pure bidirectional-link (双链) core for the sticky-note / board system (F7).
// Notes reference each other with `[[target]]` wiki-style links; this builds the
// forward + backlink indices so a note can show "what links here" (backlinks).
//
// Deliberately pure + widget-free + storage-free: nodes are (id, text) pairs and
// links are matched by the bracketed TARGET string (a note title or id). The
// board UI + persistence wrap this; the graph logic is fully unit-testable.
/// Matches `[[target]]` spans. The target is everything up to the first `]`,
/// trimmed; empty targets (`[[]]`) are ignored by [parseLinkTargets].
final RegExp _linkPattern = RegExp(r'\[\[([^\]]*)\]\]');
/// Extracts the ordered, de-duplicated list of `[[link]]` targets in [text].
/// Targets are trimmed; blank targets are skipped. Order is first-occurrence.
List<String> parseLinkTargets(String text) {
final seen = <String>{};
final result = <String>[];
for (final match in _linkPattern.allMatches(text)) {
final target = (match.group(1) ?? '').trim();
if (target.isEmpty) continue;
if (seen.add(target)) result.add(target);
}
return result;
}
/// An immutable forward + backward link index over a set of nodes.
///
/// Build with [LinkGraph.fromTexts] (id → raw text, links parsed from the text)
/// or [LinkGraph.fromLinks] (id → explicit target list). Links are matched by
/// the bracketed target string; a target that is not itself a node id is still
/// recorded (a dangling link) so [danglingTargets] can surface broken links.
class LinkGraph {
LinkGraph._(this._forward, this._backward, this._nodeIds);
/// Build from raw note texts, parsing `[[targets]]` out of each.
factory LinkGraph.fromTexts(Map<String, String> textById) {
return LinkGraph.fromLinks({
for (final entry in textById.entries)
entry.key: parseLinkTargets(entry.value),
});
}
/// Build from explicit per-node target lists.
factory LinkGraph.fromLinks(Map<String, List<String>> targetsById) {
final forward = <String, Set<String>>{};
final backward = <String, Set<String>>{};
final nodeIds = targetsById.keys.toSet();
for (final entry in targetsById.entries) {
final from = entry.key;
final targets = forward.putIfAbsent(from, () => <String>{});
for (final to in entry.value) {
if (to == from) continue; // ignore self-links
targets.add(to);
backward.putIfAbsent(to, () => <String>{}).add(from);
}
}
return LinkGraph._(forward, backward, nodeIds);
}
final Map<String, Set<String>> _forward;
final Map<String, Set<String>> _backward;
final Set<String> _nodeIds;
/// Targets that [id] links TO (outbound). Empty when [id] has no links.
Set<String> linksFrom(String id) =>
Set.unmodifiable(_forward[id] ?? const <String>{});
/// Node ids that link TO [id] (inbound / backlinks). Empty when nothing
/// references [id].
Set<String> backlinksOf(String id) =>
Set.unmodifiable(_backward[id] ?? const <String>{});
/// All link targets that are not themselves known node ids (broken links).
Set<String> danglingTargets() {
final targets = <String>{};
for (final set in _forward.values) {
targets.addAll(set);
}
targets.removeAll(_nodeIds);
return Set.unmodifiable(targets);
}
}

View File

@@ -0,0 +1,103 @@
// lib/editor/notebook/ink_stroke_adapter.dart
//
// Bridge between the legacy note/ppt storage model (`InkStroke`, ABSOLUTE pixel
// coordinates, `PenTool`) and the pen-first canvas model (`PenStroke`,
// NORMALIZED [0,1] coordinates, `PenStrokeKind`). The pen-first canvas is the
// single performant inking engine, so notes and slides are rebuilt on top of it
// and persisted back as `InkStroke` via this adapter.
//
// Coordinates are normalized against a logical page rectangle: ink absolute
// (x,y) -> pen (x/pageW, y/pageH) and back. Stroke width is likewise expressed
// as a fraction of the page width on the pen side and as absolute pixels on the
// ink side. Only freehand pen/highlighter strokes round-trip; shape/text
// `PenTool`s have no pen-canvas representation and are dropped (the pen-first
// note is handwriting-first — see the rebuild roadmap).
import 'dart:ui' show Size;
import '../../models/ink_point.dart';
import '../../models/ink_stroke.dart';
import '../../models/pen_tool.dart';
import '../canvas/pen_stroke.dart';
import '../engine/brush.dart';
/// Logical page rectangle a blank note is inked on (portrait, ~A4 √2 ratio).
/// Strokes are normalized against this so they stay pinned under zoom/pan.
const Size kNoteLogicalPage = Size(1000, 1414);
/// True when [tool] is a freehand mark the pen canvas can render
/// (pen/marker/highlighter). Shapes and text are not representable.
bool isFreehandTool(PenTool tool) =>
tool == PenTool.pen ||
tool == PenTool.marker ||
tool == PenTool.highlighter;
/// Maps an ink [PenTool] to the pen-canvas stroke kind.
PenStrokeKind penKindFromTool(PenTool tool) =>
tool == PenTool.highlighter ? PenStrokeKind.highlighter : PenStrokeKind.pen;
/// Maps a pen-canvas stroke kind back to a [PenTool].
PenTool toolFromPenKind(PenStrokeKind kind) =>
kind == PenStrokeKind.highlighter ? PenTool.highlighter : PenTool.pen;
/// Convert a stored [InkStroke] (absolute px on [page]) to a [PenStroke]
/// (normalized). Returns null for non-freehand strokes (shapes/text), which the
/// pen canvas cannot draw.
PenStroke? penStrokeFromInk(InkStroke s, Size page) {
if (!isFreehandTool(s.tool)) return null;
if (s.points.isEmpty) return null;
final w = page.width <= 0 ? 1.0 : page.width;
final h = page.height <= 0 ? 1.0 : page.height;
return PenStroke(
points: [
for (final p in s.points)
PenPoint(p.x / w, p.y / h, p.pressure, tilt: p.tilt),
],
color: s.color,
width: s.strokeWidth / w,
kind: penKindFromTool(s.tool),
// Brush isn't persisted yet (TODO(brush-persist)); derive from the tool so
// a loaded highlighter renders with the flat highlighter brush and pens
// fall back to the fountainPen default.
brush: s.tool == PenTool.highlighter
? BrushKind.highlighter
: BrushKind.fountainPen,
);
}
/// Convert a freshly drawn [PenStroke] (normalized) back to an [InkStroke]
/// (absolute px on [page]) for persistence. [id] and [createdAt] come from the
/// caller (uuid + clock) so this stays pure/deterministic.
InkStroke inkStrokeFromPen(
PenStroke s,
Size page, {
required String id,
required DateTime createdAt,
}) {
final w = page.width <= 0 ? 1.0 : page.width;
final h = page.height <= 0 ? 1.0 : page.height;
return InkStroke(
id: id,
points: [
for (final p in s.points)
InkPoint(
x: p.x * w,
y: p.y * h,
pressure: p.pressure ?? 0.5,
tilt: p.tilt ?? 0.0,
timestamp: 0,
),
],
tool: toolFromPenKind(s.kind),
color: s.color,
strokeWidth: s.width * w,
createdAt: createdAt,
);
}
/// Convert a list of stored ink strokes to pen strokes, dropping the ones the
/// canvas cannot represent (shapes/text). Order is preserved.
List<PenStroke> penStrokesFromInk(Iterable<InkStroke> strokes, Size page) =>
[for (final s in strokes) penStrokeFromInk(s, page)]
.whereType<PenStroke>()
.toList();

View File

@@ -0,0 +1,121 @@
// lib/editor/notebook/page_map.dart
//
// The one-notebook-per-PDF logical page model (F6). A notebook is an ordered
// list of LOGICAL pages; each is either a SOURCE page (shows PDF page N, vector
// preserved) or a BLANK page inserted between/after PDF pages (SpeedyNote-style
// binding the user asked for). Ink hosts attach to logical pages, so inserting
// or reordering pages must NOT renumber the PDF underlay — the source index is
// carried on each page.
//
// Pure, immutable value type (every edit returns a NEW PageMap) so the model is
// fully unit-tested; the `notebook_pages` table + the viewport wire it later.
import 'package:flutter/foundation.dart';
enum NotebookPageKind { source, blank }
/// One logical page. [sourcePageIndex] is the 0-based PDF page it renders, or
/// null for a [NotebookPageKind.blank] inserted page.
@immutable
class NotebookPage {
const NotebookPage.source(this.sourcePageIndex)
: kind = NotebookPageKind.source;
const NotebookPage.blank()
: kind = NotebookPageKind.blank,
sourcePageIndex = null;
final NotebookPageKind kind;
final int? sourcePageIndex;
bool get isBlank => kind == NotebookPageKind.blank;
bool get isSource => kind == NotebookPageKind.source;
@override
bool operator ==(Object other) =>
other is NotebookPage &&
other.kind == kind &&
other.sourcePageIndex == sourcePageIndex;
@override
int get hashCode => Object.hash(kind, sourcePageIndex);
@override
String toString() =>
isBlank ? 'NotebookPage.blank' : 'NotebookPage.source($sourcePageIndex)';
}
/// Ordered logical page list with copy-on-write edits.
@immutable
class PageMap {
const PageMap(this.pages);
/// Identity map: one logical SOURCE page per PDF page, in order.
factory PageMap.fromSource(int sourcePageCount) {
assert(sourcePageCount >= 0);
return PageMap(
List<NotebookPage>.unmodifiable(
List<NotebookPage>.generate(
sourcePageCount,
(i) => NotebookPage.source(i),
),
),
);
}
final List<NotebookPage> pages;
int get length => pages.length;
bool get isEmpty => pages.isEmpty;
NotebookPage operator [](int index) => pages[index];
/// Source PDF page rendered at logical [index], or null when it's a blank.
int? sourceIndexAt(int index) => pages[index].sourcePageIndex;
int get blankCount => pages.where((p) => p.isBlank).length;
int get sourceCount => pages.where((p) => p.isSource).length;
/// Insert a blank page at logical [index] (0..length). Throws [RangeError]
/// for an out-of-range index.
PageMap insertBlankAt(int index) {
RangeError.checkValueInInterval(index, 0, length, 'index');
final next = List<NotebookPage>.of(pages)
..insert(index, const NotebookPage.blank());
return PageMap(List<NotebookPage>.unmodifiable(next));
}
/// Insert a blank page immediately after logical [index] (-1 prepends).
PageMap insertBlankAfter(int index) {
RangeError.checkValueInInterval(index, -1, length - 1, 'index');
return insertBlankAt(index + 1);
}
/// Remove the logical page at [index].
PageMap removeAt(int index) {
RangeError.checkValidIndex(index, pages, 'index');
final next = List<NotebookPage>.of(pages)..removeAt(index);
return PageMap(List<NotebookPage>.unmodifiable(next));
}
/// Move the page at [from] to position [to] (drag-reorder).
PageMap move(int from, int to) {
RangeError.checkValidIndex(from, pages, 'from');
RangeError.checkValueInInterval(to, 0, length - 1, 'to');
if (from == to) return this;
final next = List<NotebookPage>.of(pages);
final page = next.removeAt(from);
next.insert(to, page);
return PageMap(List<NotebookPage>.unmodifiable(next));
}
@override
bool operator ==(Object other) =>
other is PageMap && listEquals(other.pages, pages);
@override
int get hashCode => Object.hashAll(pages);
@override
String toString() => 'PageMap($pages)';
}

View File

@@ -0,0 +1,137 @@
// lib/editor/pdf/page_tile_cache.dart
//
// Bounded LRU cache of rasterized PAGE tiles (ui.Image), DPI-bucketed.
//
// This is the "heavy" cache (a single A4 page at 3× DPI is ~18 MB) and is
// DELIBERATELY SEPARATE from the resolution-independent ink Picture cache
// (render/ink_picture_cache.dart): ink is vector and valid at any zoom, but a
// page bitmap is only crisp at the DPI it was rasterized for, so its key
// carries a DPI bucket (R11 / MF2). On zoom-settle the page_tile renderer
// re-rasterizes at the new bucket and put()s it here; matrix-upscale of a lower
// bucket is the accepted transient until the new tile lands.
//
// Tiles are rendered ASYNCHRONOUSLY (pdfrx PdfPage.render / a re-laid-out
// PdfPageView), so the cache is get()/put() — NOT getOrBuild — and the caller
// owns the async render. Evicted images are disposed via a post-frame callback
// so Flutter's raster thread is never asked to free a ui.Image it may still be
// sampling this frame.
import 'dart:collection';
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
/// Identity of a cached page tile: which host (page) and which DPI bucket.
///
/// The DPI bucket (an integer, e.g. round(scale × base-DPI) snapped to a step)
/// keeps the key space small so a smooth pinch doesn't spawn a distinct tile
/// per frame — only per bucket.
@immutable
class TileKey {
const TileKey(this.hostId, this.dpiBucket);
final String hostId;
final int dpiBucket;
@override
bool operator ==(Object other) =>
other is TileKey &&
other.hostId == hostId &&
other.dpiBucket == dpiBucket;
@override
int get hashCode => Object.hash(hostId, dpiBucket);
@override
String toString() => 'TileKey($hostId @dpi$dpiBucket)';
}
/// Bounded LRU cache of page-tile [ui.Image]s keyed by [TileKey].
///
/// Capacity is a TILE COUNT (not bytes); size the window to the device memory
/// budget — full-DPI tiles for visible ±1 pages, off-window pages downgraded to
/// a 1× tier elsewhere (see the plan's R10 resolution). Eviction disposes the
/// image post-frame.
class PageTileCache {
PageTileCache({int maxTiles = 6})
: assert(maxTiles > 0),
_maxTiles = maxTiles;
final int _maxTiles;
// Insertion-ordered; accessed entries are moved to the back so the front is
// always the least-recently-used.
final LinkedHashMap<TileKey, ui.Image> _cache =
LinkedHashMap<TileKey, ui.Image>();
/// Number of tiles currently retained.
int get length => _cache.length;
/// The keys currently retained, most-recently-used LAST.
Iterable<TileKey> get keys => _cache.keys;
/// Returns the cached image for [key] (promoting it to most-recently-used),
/// or null on a miss. The caller renders + [put]s on a miss.
ui.Image? get(TileKey key) {
final image = _cache.remove(key);
if (image == null) return null;
_cache[key] = image; // promote to MRU
return image;
}
/// Inserts [image] for [key], evicting the least-recently-used tiles beyond
/// the cap. If a DIFFERENT image was already stored for [key], the old one is
/// disposed (post-frame). Re-putting the identical image is a no-op promote.
void put(TileKey key, ui.Image image) {
final existing = _cache.remove(key);
if (existing != null && !identical(existing, image)) {
_disposeDeferred(existing);
}
_cache[key] = image;
while (_cache.length > _maxTiles) {
final lruKey = _cache.keys.first;
_disposeDeferred(_cache.remove(lruKey)!);
}
}
/// Evicts every tile whose host is NOT in [liveHostIds] (e.g. pages that
/// scrolled out of the mounted window). Disposed post-frame.
void evictHostsExcept(Set<String> liveHostIds) {
final doomed = _cache.keys
.where((k) => !liveHostIds.contains(k.hostId))
.toList(growable: false);
for (final key in doomed) {
_disposeDeferred(_cache.remove(key)!);
}
}
/// Disposes all retained tiles (post-frame). Call from the owner's dispose.
void dispose() {
final images = List<ui.Image>.from(_cache.values);
_cache.clear();
for (final image in images) {
_disposeDeferred(image);
}
}
static void _disposeDeferred(ui.Image image) {
// Defer to after the current frame so the raster thread is done with it.
// If no binding/frame is scheduled (e.g. a unit test that never pumps),
// fall back to disposing on the next microtask so images aren't leaked.
final binding = WidgetsBinding.instance;
binding.addPostFrameCallback((_) => image.dispose());
binding.scheduleFrame();
}
}
/// Snaps a continuous render scale to a coarse DPI bucket so a smooth pinch
/// re-uses tiles instead of spawning one per frame. [step] is the bucket
/// granularity in the same units as [scale] (e.g. 0.5). The result is capped at
/// [maxBucket] to bound retained-tile memory (the plan's ~3× cap, R11).
int dpiBucketFor(double scale, {double step = 0.5, int maxBucket = 6}) {
if (!scale.isFinite || scale <= 0) return 1;
final bucket = (scale / step).ceil();
if (bucket < 1) return 1;
return bucket > maxBucket ? maxBucket : bucket;
}

View File

@@ -0,0 +1,61 @@
// Double-buffer helper on top of [PageTileCache] to kill zoom white-flash:
// keep painting the last good tile while a higher-DPI raster is in flight.
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
import 'page_tile_cache.dart';
/// Holds the "last good" page image for the currently visible page so a zoom
/// settle never exposes an empty frame (plan W2 / R11).
class PageTileLayer extends ChangeNotifier {
PageTileLayer({PageTileCache? cache}) : _cache = cache ?? PageTileCache();
final PageTileCache _cache;
ui.Image? _lastGood;
TileKey? _lastKey;
PageTileCache get cache => _cache;
ui.Image? get lastGood => _lastGood;
TileKey? get lastKey => _lastKey;
/// Snap continuous zoom to a coarse DPI bucket (avoids a tile per frame).
static int dpiBucketFor(double zoom, {double baseDpi = 96, double step = 0.5}) {
final raw = zoom / step;
final snapped = raw.round().clamp(1, 16);
return (snapped * step * baseDpi).round();
}
/// Promote [image] as the last-good tile for [key].
void put(TileKey key, ui.Image image) {
_cache.put(key, image);
_lastGood = image;
_lastKey = key;
notifyListeners();
}
/// Prefer exact bucket; else fall back to last-good so zoom never blanks.
ui.Image? resolve(TileKey key) {
final hit = _cache.get(key);
if (hit != null) {
_lastGood = hit;
_lastKey = key;
return hit;
}
return _lastGood;
}
void clear() {
_lastGood = null;
_lastKey = null;
_cache.dispose();
notifyListeners();
}
@override
void dispose() {
clear();
super.dispose();
}
}

View File

@@ -0,0 +1,45 @@
// lib/editor/pdf/pdf_document_source.dart
//
// A minimal abstraction over a paginated document (a pdfrx PdfDocument in
// production) consumed by the layout + render layer. Keeping the layout math
// behind this seam lets continuous-single windowing (layout/page_viewport.dart)
// and tiling be unit-tested against a fake source — no pdfium, no GPU, no real
// PDF (the production pdfrx adapter is a thin device-side wrapper added with the
// viewport widget / page_tile renderer, which are device-gated).
import 'dart:ui' show Size;
import '../layout/page_viewport.dart';
/// Read-only page geometry for a paginated document.
abstract class PageDocumentSource {
/// Number of pages (>= 0).
int get pageCount;
/// Intrinsic size of page [index] in PDF points (width/height > 0).
Size pageSize(int index);
}
/// Builds continuous-single stacking metrics by fitting every page to a single
/// [columnWidth] (fit-to-width, the continuous-single mode): each page's
/// laid-out height is `columnWidth × (pageHeight / pageWidth)`, preserving its
/// aspect ratio. [gap] is inserted between pages (content units).
///
/// Pages reporting a non-positive width are treated as zero-height (defensive;
/// real pages always have a positive width) so a malformed page can't throw.
///
/// Returns the metrics needed by [PageStackMetrics.visibleRange]; pair this with
/// the device-gated page-mounting widget.
PageStackMetrics pageStackMetricsForWidth(
PageDocumentSource source,
double columnWidth, {
double gap = 0.0,
}) {
assert(columnWidth >= 0);
final heights = List<double>.generate(source.pageCount, (i) {
final size = source.pageSize(i);
if (size.width <= 0) return 0.0;
return columnWidth * (size.height / size.width);
});
return PageStackMetrics(pageHeights: heights, gap: gap);
}

View File

@@ -0,0 +1,42 @@
// lib/editor/pdf/pdfrx_page_document_source.dart
//
// Production [PageDocumentSource] backed by a pdfrx PdfDocument — the thin
// device-side adapter that feeds real page geometry into the (pure, tested)
// continuous-single layout math.
//
// SOURCE-PIN (plan SF4 / P0.5 step 12): this file references the exact pdfrx
// 2.4.4 page-geometry API (`PdfDocument.pages`, `PdfPage.width`, `PdfPage.height`).
// Because it lives under lib/editor/, the Oracle's `flutter analyze lib/editor`
// type-checks it against the installed pdfrx on every run — so a version bump
// that renames/retypes these members FAILS analysis instead of silently
// drifting. (Static pin only: exercising it needs pdfium at runtime, which is
// the device-gated path.)
import 'dart:ui' show Size;
import 'package:pdfrx/pdfrx.dart';
import 'pdf_document_source.dart';
/// Snapshots page sizes (PDF points) from an open pdfrx [PdfDocument] so the
/// layout layer can read them synchronously (pdfrx loads pages asynchronously;
/// once open, `document.pages[i].width/height` are available).
class PdfrxPageDocumentSource implements PageDocumentSource {
const PdfrxPageDocumentSource.fromSizes(this._sizes);
/// Snapshot the intrinsic size of every page from an open [document].
factory PdfrxPageDocumentSource.fromDocument(PdfDocument document) {
final sizes = document.pages
.map((page) => Size(page.width, page.height))
.toList(growable: false);
return PdfrxPageDocumentSource.fromSizes(sizes);
}
final List<Size> _sizes;
@override
int get pageCount => _sizes.length;
@override
Size pageSize(int index) => _sizes[index];
}

View File

@@ -0,0 +1,36 @@
// lib/editor/pdf/slide_export.dart
//
// Pure geometry for exporting pen-first slide annotations to PDF. Because the
// pen canvas captures strokes NORMALIZED to the page rect ([0,1]), the export
// just maps each normalized point into the slide image's draw rectangle on the
// PDF page — no live-widget-size guessing, which is what made the old PPT
// exporter misalign ink (see the removed ppt_annotator_screen comment).
import 'dart:ui' show Offset, Rect, Size;
/// The rectangle a slide [image] occupies when drawn "contain"-fit and centered
/// on a PDF page of size [page]. Mirrors the live canvas's fit-to-view so the
/// exported ink lands exactly where it was drawn.
Rect slideDrawRect(Size page, Size image) {
final iw = image.width <= 0 ? 1.0 : image.width;
final ih = image.height <= 0 ? 1.0 : image.height;
final scale = (page.width / iw) < (page.height / ih)
? page.width / iw
: page.height / ih;
final drawW = iw * scale;
final drawH = ih * scale;
final offX = (page.width - drawW) / 2;
final offY = (page.height - drawH) / 2;
return Rect.fromLTWH(offX, offY, drawW, drawH);
}
/// Map a normalized stroke point ([0,1] of the page rect) to an absolute point
/// inside the slide's [drawRect] on the PDF page.
Offset normToSlide(double nx, double ny, Rect drawRect) =>
Offset(drawRect.left + nx * drawRect.width,
drawRect.top + ny * drawRect.height);
/// Absolute pen width (PDF units) for a stroke whose width is a fraction of the
/// page width, scaled into [drawRect].
double slideStrokeWidth(double normalizedWidth, Rect drawRect) =>
normalizedWidth * drawRect.width;

View File

@@ -1,187 +0,0 @@
// lib/editor/pdf/spike_app.dart
//
// THROWAWAY M1 spike app shell (plan §10). Wraps [SpikeEditorPane] with an
// on-screen frame-timing HUD (median build & raster ms over the last ~120
// frames) and an ink-load toggle, so MUST #4/#5 are observable on-device when
// launched via `flutter run -t lib/editor/pdf/spike_main.dart` on the tablet.
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:pdfrx/pdfrx.dart';
import 'spike_editor_pane.dart';
class SpikeApp extends StatelessWidget {
const SpikeApp({super.key, required this.pdfPath, this.denseStrokesAsset});
final String pdfPath;
final String? denseStrokesAsset;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'BadNote M1 Spike',
debugShowCheckedModeBanner: false,
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
home: SpikeHome(
pdfPath: pdfPath,
denseStrokesAsset: denseStrokesAsset,
),
);
}
}
class SpikeHome extends StatefulWidget {
const SpikeHome({super.key, required this.pdfPath, this.denseStrokesAsset});
final String pdfPath;
final String? denseStrokesAsset;
@override
State<SpikeHome> createState() => _SpikeHomeState();
}
class _SpikeHomeState extends State<SpikeHome> {
final GlobalKey<SpikeEditorPaneState> _paneKey =
GlobalKey<SpikeEditorPaneState>();
final PdfViewerController _controller = PdfViewerController();
bool _inkLoad = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
SpikeEditorPane(
key: _paneKey,
controller: _controller,
pdfPath: widget.pdfPath,
denseStrokesAsset: widget.denseStrokesAsset,
),
const Positioned(top: 8, left: 8, child: FrameTimingHud()),
],
),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FloatingActionButton.extended(
heroTag: 'inkload',
onPressed: () async {
final next = !_inkLoad;
await _paneKey.currentState?.setInkLoad(next);
setState(() => _inkLoad = next);
},
label: Text(_inkLoad ? 'Ink load: ON' : 'Ink load: OFF'),
icon: const Icon(Icons.brush),
),
],
),
);
}
}
/// On-screen median build/raster frame-time HUD, driven by
/// [SchedulerBinding.addTimingsCallback]. Shows the median of the last
/// [_window] frames for both the build (`buildDuration`) and raster
/// (`rasterDuration`) phases — the two halves of the 16.6ms budget tracked by
/// MUST #4/#5.
class FrameTimingHud extends StatefulWidget {
const FrameTimingHud({super.key});
@override
State<FrameTimingHud> createState() => _FrameTimingHudState();
}
class _FrameTimingHudState extends State<FrameTimingHud> {
static const int _window = 120;
final List<double> _build = <double>[];
final List<double> _raster = <double>[];
double _medBuild = 0;
double _medRaster = 0;
double _p95Build = 0;
double _p95Raster = 0;
@override
void initState() {
super.initState();
SchedulerBinding.instance.addTimingsCallback(_onTimings);
}
@override
void dispose() {
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
super.dispose();
}
void _onTimings(List<FrameTiming> timings) {
for (final t in timings) {
_build.add(t.buildDuration.inMicroseconds / 1000.0);
_raster.add(t.rasterDuration.inMicroseconds / 1000.0);
}
while (_build.length > _window) {
_build.removeAt(0);
}
while (_raster.length > _window) {
_raster.removeAt(0);
}
if (!mounted) return;
setState(() {
_medBuild = _percentile(_build, 50);
_medRaster = _percentile(_raster, 50);
_p95Build = _percentile(_build, 95);
_p95Raster = _percentile(_raster, 95);
});
}
static double _percentile(List<double> values, int p) {
if (values.isEmpty) return 0;
final sorted = List<double>.from(values)..sort();
final idx = ((p / 100.0) * (sorted.length - 1)).round();
return sorted[idx.clamp(0, sorted.length - 1)];
}
@override
Widget build(BuildContext context) {
Color budget(double ms) => ms <= 16.6
? Colors.greenAccent
: (ms <= 22 ? Colors.amberAccent : Colors.redAccent);
return IgnorePointer(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(8),
),
child: DefaultTextStyle(
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Colors.white,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('frames: ${_build.length}/$_window'),
Text.rich(TextSpan(children: [
const TextSpan(text: 'build med '),
TextSpan(
text: '${_medBuild.toStringAsFixed(1)}ms',
style: TextStyle(color: budget(_medBuild))),
TextSpan(text: ' p95 ${_p95Build.toStringAsFixed(1)}ms'),
])),
Text.rich(TextSpan(children: [
const TextSpan(text: 'raster med '),
TextSpan(
text: '${_medRaster.toStringAsFixed(1)}ms',
style: TextStyle(color: budget(_medRaster))),
TextSpan(text: ' p95 ${_p95Raster.toStringAsFixed(1)}ms'),
])),
],
),
),
),
);
}
}

View File

@@ -1,344 +0,0 @@
// lib/editor/pdf/spike_editor_pane.dart
//
// THROWAWAY M1 spike widget (plan §10 / MUST #2, #4, #5). Hosts a pdfrx
// PdfViewer.file and exercises the three things the M1 gate must prove:
//
// 1. Coordinate correctness (MUST #2): a `pageOverlaysBuilder` paints a
// diagnostic crosshair at normalized (0.5, 0.5) using
// `canvas.scale(size.width, size.height)`, with the CustomPaint sized to
// `pageRect.size` (plan §2.1). This dot MUST sit at the visual page center
// at every zoom level. `coordinate_assertion_test.dart` asserts this.
//
// 2. Pen/touch arbitration (MUST #3): a `viewerOverlayBuilder` wraps a
// `PenCaptureRegion` so pen events draw a live viewer-level stroke while
// touch scrolls and pinch zooms — same overlay, no mode switch.
//
// 3. Ink-overlay build cost (MUST #5): a toggle injects ~N synthetic strokes
// per page (from dense_strokes.json) into the page overlay so the perf
// bench can measure BUILD time with a non-trivial ui.Picture per page.
//
// This file is NOT production code and is excluded from the real editor.
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'pen_capture_region.dart';
/// Normalized page-space point the diagnostic marker is painted at. The M1
/// coordinate assertion checks this maps to the page-center pixel at all zooms.
const Offset kMarkerNormalized = Offset(0.5, 0.5);
/// A single captured pen sample in normalized page space, tagged with its page.
class _PenSample {
const _PenSample(this.pageIndex, this.normalized);
final int pageIndex;
final Offset normalized;
}
/// Spike editor pane. Provide a [pdfPath] to a local PDF (e.g.
/// test/assets/large_300p.pdf). [denseStrokesAsset] is a filesystem PATH to the
/// synthetic ink load (MUST #5); if null the ink-load toggle is inert.
class SpikeEditorPane extends StatefulWidget {
const SpikeEditorPane({
super.key,
required this.pdfPath,
this.denseStrokesAsset,
this.strokesPerPage = 300,
this.strokeCountKey = '2000',
this.onViewerReady,
this.controller,
});
final String pdfPath;
final String? denseStrokesAsset;
final int strokesPerPage;
/// Which top-level array in dense_strokes.json to draw from ("2000"/"5000").
final String strokeCountKey;
/// Forwarded from pdfrx once the document is laid out and interactive.
final void Function(PdfDocument document, PdfViewerController controller)?
onViewerReady;
/// Optional externally-owned controller (tests drive zoom through this).
final PdfViewerController? controller;
@override
State<SpikeEditorPane> createState() => SpikeEditorPaneState();
}
class SpikeEditorPaneState extends State<SpikeEditorPane> {
late final PdfViewerController _controller =
widget.controller ?? PdfViewerController();
/// Live pen strokes captured via PenCaptureRegion (viewer-level overlay).
final List<List<_PenSample>> _penStrokes = <List<_PenSample>>[];
List<_PenSample>? _activeStroke;
/// Synthetic strokes for the ink-load gate, lazily loaded. Each entry is a
/// list of normalized polylines (one stroke = list of points).
List<List<Offset>>? _syntheticStrokes;
bool _inkLoadEnabled = false;
bool _loadingSynthetic = false;
bool get inkLoadEnabled => _inkLoadEnabled;
/// Toggle the dense synthetic-ink overlay (MUST #5). Loads the asset on first
/// enable. Public so the perf bench can drive it programmatically.
Future<void> setInkLoad(bool enabled) async {
if (enabled && _syntheticStrokes == null) {
await _loadSyntheticStrokes();
}
if (mounted) setState(() => _inkLoadEnabled = enabled);
}
Future<void> _loadSyntheticStrokes() async {
final asset = widget.denseStrokesAsset;
if (asset == null || _loadingSynthetic) return;
_loadingSynthetic = true;
try {
// [asset] is a filesystem path (e.g. test/assets/dense_strokes.json),
// not a bundled rootBundle key — regenerate via tool/gen_dense_strokes.dart.
final raw = await File(asset).readAsString();
final decoded = jsonDecode(raw) as Map<String, dynamic>;
final strokesJson =
(decoded[widget.strokeCountKey] as List<dynamic>? ?? const []);
final result = <List<Offset>>[];
for (final s in strokesJson) {
final points = (s as Map<String, dynamic>)['points'] as List<dynamic>;
final poly = <Offset>[];
for (final p in points) {
final pt = p as Map<String, dynamic>;
poly.add(Offset(
(pt['x'] as num).toDouble(),
(pt['y'] as num).toDouble(),
));
}
if (poly.length >= 2) result.add(poly);
}
_syntheticStrokes = result;
} finally {
_loadingSynthetic = false;
}
}
// --- Pen capture (viewer-level) ---------------------------------------
void _onPenEvent(PointerEvent event) {
// Convert global → document → which page + normalized page coords.
final doc = _controller.globalToDocument(event.position);
if (doc == null) return;
final hit = _documentToPage(doc);
if (hit == null) return;
if (event is PointerDownEvent) {
_activeStroke = <_PenSample>[hit];
_penStrokes.add(_activeStroke!);
setState(() {});
} else if (event is PointerMoveEvent) {
_activeStroke?.add(hit);
setState(() {});
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
_activeStroke = null;
}
}
/// Maps a document-space point to (pageIndex, normalized-in-page) using the
/// controller's page layout rects (document coordinates). Returns null if the
/// point is outside every page box.
_PenSample? _documentToPage(Offset doc) {
if (!_controller.isReady) return null;
final rects = _controller.layout.pageLayouts;
for (var i = 0; i < rects.length; i++) {
final r = rects[i];
if (r.contains(doc)) {
final nx = ((doc.dx - r.left) / r.width).clamp(0.0, 1.0);
final ny = ((doc.dy - r.top) / r.height).clamp(0.0, 1.0);
return _PenSample(i, Offset(nx, ny));
}
}
return null;
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
PdfViewer.file(
widget.pdfPath,
controller: _controller,
params: PdfViewerParams(
onViewerReady: widget.onViewerReady,
// (1) Per-page overlay: diagnostic center marker + optional synthetic
// ink. CustomPaint is sized to pageRect.size so canvas.scale maps
// normalized [0,1] → zoomed pixels (plan §2.1).
pageOverlaysBuilder: (context, pageRectInViewer, page) {
final pageIndex = page.pageNumber - 1;
return [
SizedBox.fromSize(
size: pageRectInViewer.size,
child: CustomPaint(
painter: _SpikeInkPainter(
synthetic:
_inkLoadEnabled ? _strokesForPage(pageIndex) : null,
),
),
),
];
},
// (2) Viewer-level overlay: pen capture + live pen rendering. Touch
// falls through to pdfrx for scroll/zoom (per-kind hit-test split).
viewerOverlayBuilder: (context, size, handleLinkTap) {
return [
Positioned.fill(
child: PenCaptureRegion(
onPenEvent: _onPenEvent,
child: IgnorePointer(
child: CustomPaint(
size: size,
painter: _LivePenPainter(
strokes: _penStrokes,
controller: _controller,
),
),
),
),
),
];
},
),
),
],
);
}
/// Deterministic per-page slice of the synthetic stroke pool so each page
/// shows ~[widget.strokesPerPage] strokes without loading 300× the data.
List<List<Offset>> _strokesForPage(int pageIndex) {
final pool = _syntheticStrokes;
if (pool == null || pool.isEmpty) return const [];
final n = widget.strokesPerPage.clamp(0, pool.length);
final start = (pageIndex * n) % pool.length;
final out = <List<Offset>>[];
for (var i = 0; i < n; i++) {
out.add(pool[(start + i) % pool.length]);
}
return out;
}
// Note: PdfViewerController is not a Listenable/ChangeNotifier we own a
// lifecycle for; pdfrx attaches/detaches it via the PdfViewer. No dispose().
}
/// Paints the diagnostic center marker (always) plus synthetic ink (when the
/// MUST #5 load is enabled), in normalized [0,1] page space scaled to the
/// CustomPaint size (== zoomed page box). This is what the coordinate assertion
/// inspects.
class _SpikeInkPainter extends CustomPainter {
_SpikeInkPainter({this.synthetic});
final List<List<Offset>>? synthetic;
@override
void paint(Canvas canvas, Size size) {
canvas.save();
// Map normalized [0,1] → zoomed pixels (plan §2.1).
canvas.scale(size.width, size.height);
// Synthetic ink load (MUST #5): a non-trivial set of polylines per page.
final syn = synthetic;
if (syn != null && syn.isNotEmpty) {
final inkPaint = Paint()
..color = const Color(0x5500AAFF)
..style = PaintingStyle.stroke
// Stroke width is in normalized units post-scale; keep it page-relative
// and hairline-ish so 300 strokes are visible but cheap.
..strokeWidth = 0.002
..strokeCap = StrokeCap.round;
for (final poly in syn) {
if (poly.length < 2) continue;
final path = Path()..moveTo(poly.first.dx, poly.first.dy);
for (var i = 1; i < poly.length; i++) {
path.lineTo(poly[i].dx, poly[i].dy);
}
canvas.drawPath(path, inkPaint);
}
}
canvas.restore();
// Diagnostic crosshair at normalized (0.5,0.5) — drawn in PIXEL space (after
// restore) so its line thickness is constant on screen and its CENTER is at
// exactly size.width*0.5, size.height*0.5. The coordinate assertion checks
// this pixel.
final center = Offset(
size.width * kMarkerNormalized.dx,
size.height * kMarkerNormalized.dy,
);
final markerPaint = Paint()
..color = const Color(0xFFFF0066)
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
const arm = 16.0;
canvas.drawLine(
center.translate(-arm, 0), center.translate(arm, 0), markerPaint);
canvas.drawLine(
center.translate(0, -arm), center.translate(0, arm), markerPaint);
canvas.drawCircle(center, 3.0, Paint()..color = const Color(0xFFFF0066));
}
@override
bool shouldRepaint(covariant _SpikeInkPainter oldDelegate) =>
oldDelegate.synthetic != synthetic;
}
/// Paints live pen strokes captured by the PenCaptureRegion. Strokes are stored
/// in normalized page space, so for each sample we re-project page→document→
/// local each paint via the controller (keeps strokes glued to pages under
/// scroll/zoom — the §2.1 property, exercised at the viewer level here).
class _LivePenPainter extends CustomPainter {
_LivePenPainter({required this.strokes, required this.controller})
: super(repaint: controller);
final List<List<_PenSample>> strokes;
final PdfViewerController controller;
@override
void paint(Canvas canvas, Size size) {
if (!controller.isReady) return;
final rects = controller.layout.pageLayouts;
final paint = Paint()
..color = const Color(0xFF1565C0)
..style = PaintingStyle.stroke
..strokeWidth = 3.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
for (final stroke in strokes) {
Path? path;
for (final s in stroke) {
if (s.pageIndex >= rects.length) continue;
final r = rects[s.pageIndex];
// normalized page → document
final docPt = Offset(
r.left + s.normalized.dx * r.width,
r.top + s.normalized.dy * r.height,
);
// document → local (viewer) coords
final local = controller.documentToLocal(docPt);
if (path == null) {
path = Path()..moveTo(local.dx, local.dy);
} else {
path.lineTo(local.dx, local.dy);
}
}
if (path != null) canvas.drawPath(path, paint);
}
}
@override
bool shouldRepaint(covariant _LivePenPainter oldDelegate) => true;
}

View File

@@ -1,31 +0,0 @@
// lib/editor/pdf/spike_launcher.dart
//
// THROWAWAY M1 entry: lets the user open the pdfrx pen/perf spike from the
// running app (so the CI-built Windows package can exercise MUST #3/#4/#5 on a
// real Surface Pen with the user's OWN large PDFs). Remove together with the
// rest of lib/editor/pdf/spike_* once M1 is signed off.
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import '../canvas/pen_editor_screen.dart';
/// Opens a file picker for a PDF, then pushes the NEW pen-first canvas editor.
///
/// The 🧪 entry now opens the clean-room canvas (lib/editor/canvas/), which
/// OWNS the gesture pipeline (pressure, pinch-zoom, palm rejection). The old
/// spike_* files are left in place but no longer wired to this entry.
Future<void> openM1Spike(BuildContext context) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
);
final path = result?.files.single.path;
if (path == null) return;
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenEditorScreen(pdfPath: path),
),
);
}

View File

@@ -1,62 +0,0 @@
// lib/editor/pdf/spike_main.dart
//
// Standalone entry point for the THROWAWAY M1 pdfrx spike (plan §10).
//
// Launch on the Windows tablet (or any desktop with a display):
// flutter run -t lib/editor/pdf/spike_main.dart
//
// It opens test/assets/large_300p.pdf in [SpikeEditorPane] with the
// frame-timing HUD and ink-load toggle, so the M1 perf/pen gates are
// observable on-device.
//
// IMPORTANT: pen capture requires the kind-aware [PenCaptureBinding] (installed
// below before pdfrx init). pdfrx itself is initialized via
// pdfrxFlutterInitialize() — confirmed from pdfrx 2.4.4 example/pdf_combine.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'pen_capture_region.dart';
import 'spike_app.dart';
/// Default benchmark asset (300-page PDF generated by tool/gen_bench_pdf.dart).
const String _kDefaultPdfRelPath = 'test/assets/large_300p.pdf';
/// Filesystem path for the synthetic ink load (regenerate via
/// tool/gen_dense_strokes.dart; not bundled — read from disk at the project root).
const String _kDenseStrokesAsset = 'test/assets/dense_strokes.json';
void main(List<String> args) {
// Kind-aware binding MUST be installed before runApp so PenCaptureRegion can
// gate hit-testing by pointer kind (see pen_capture_region.dart header).
PenCaptureBinding.ensureInitialized();
// pdfrx native engine init (pdfrx 2.4.4 example pattern).
pdfrxFlutterInitialize();
// Allow overriding the PDF path as the first CLI arg (otherwise the default
// 300-page bench asset relative to the project root / cwd).
final pdfPath = args.isNotEmpty ? args.first : _resolvePdfPath();
runApp(
SpikeApp(
pdfPath: pdfPath,
denseStrokesAsset: _kDenseStrokesAsset,
),
);
}
/// Resolve the bench PDF path. `flutter run` sets cwd to the project root, so
/// the relative asset path works on desktop; we also try a couple of fallbacks.
String _resolvePdfPath() {
final candidates = <String>[
_kDefaultPdfRelPath,
'${Directory.current.path}/$_kDefaultPdfRelPath',
];
for (final c in candidates) {
if (File(c).existsSync()) return c;
}
// Return the primary path anyway; pdfrx will surface a clear load error.
return _kDefaultPdfRelPath;
}

View File

@@ -0,0 +1,149 @@
// lib/editor/persistence/editor_repository.dart
//
// MF3 diff-write contract: per-host diff of stroke ids against the last
// persisted set. Only changed/new rows are upserted; only removed rows are
// deleted. All mutations run in ONE transaction per saveHost call.
// The in-memory _persistedIds map is updated only after the transaction
// commits successfully.
import 'dart:convert';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import '../engine/stroke_model.dart';
import '../../services/database_service.dart';
/// Repository for persisting [EditorStroke]s to the `ink` table.
///
/// Host-id scheme: `"page:<pageIndex>"` where pageIndex is the zero-based
/// index of the page within its document. For example, page 0 of a document
/// uses host_id `"page:0"`.
///
/// [loadDocument] uses a single batched query over all ink rows whose
/// host_id begins with `"page:"` for the document, grouped by host_id.
/// [saveHost] implements the MF3 diff-write contract.
class EditorRepository {
EditorRepository(this._db);
final Database _db;
/// Per host_id, the set of stroke ids that were last persisted to the DB.
/// Updated only after a successful transaction commit.
final Map<String, Set<String>> _persistedIds = {};
// ── Factory ────────────────────────────────────────────────────────────
/// Convenience constructor that initialises from [DatabaseService].
static Future<EditorRepository> fromService(DatabaseService service) async {
return EditorRepository(service.database);
}
// ── Load ───────────────────────────────────────────────────────────────
/// Load all ink rows for [documentId] in a single batched query.
///
/// Returns a map keyed by host_id (e.g. `"page:0"`) whose values are
/// the strokes for that host in ascending ordinal order.
///
/// The host_id scheme is: host_kind = `"page"`, host_id = `"page:<i>"`.
Future<Map<String, List<EditorStroke>>> loadDocument(
String documentId,
) async {
// All page hosts for a document share the prefix "page:" inside host_id.
// We tag them with document_id via the host_id prefix convention:
// host_id = "doc:<documentId>:page:<pageIndex>"
final rows = await _db.query(
'ink',
where: 'host_kind = ? AND host_id LIKE ?',
whereArgs: ['page', 'doc:$documentId:page:%'],
orderBy: 'host_id ASC, ordinal ASC',
);
final result = <String, List<EditorStroke>>{};
for (final row in rows) {
final hostId = row['host_id'] as String;
final strokeJson =
jsonDecode(row['stroke_json'] as String) as Map<String, dynamic>;
final stroke = EditorStroke.fromJson(strokeJson);
result.putIfAbsent(hostId, () => []).add(stroke);
}
// Populate _persistedIds from what we just read so that subsequent
// saveHost calls can diff correctly even on a fresh repository instance.
for (final entry in result.entries) {
_persistedIds[entry.key] = entry.value.map((s) => s.id).toSet();
}
return result;
}
// ── Save (MF3 diff-write contract) ────────────────────────────────────
/// Persist [strokes] for the given host ([hostKind], [hostId]).
///
/// Diff against the last-known persisted id-set:
/// - NEW / CHANGED rows → INSERT OR REPLACE (upsert)
/// - REMOVED rows → DELETE
///
/// All mutations execute in a single transaction. [_persistedIds] is
/// updated only after the transaction commits.
Future<void> saveHost(
String hostKind,
String hostId,
List<EditorStroke> strokes,
) async {
final incoming = strokes;
final incomingIds = incoming.map((s) => s.id).toSet();
final persisted = _persistedIds[hostId] ?? {};
final toDelete = persisted.difference(incomingIds);
final toUpsert =
incoming.where((s) => !persisted.contains(s.id)).toList();
// Fast path: nothing to do.
if (toDelete.isEmpty && toUpsert.isEmpty) return;
final now = DateTime.now().millisecondsSinceEpoch;
await _db.transaction((txn) async {
// Upsert new/changed rows.
for (var i = 0; i < incoming.length; i++) {
final stroke = incoming[i];
if (!persisted.contains(stroke.id)) {
await txn.rawInsert(
'''INSERT INTO ink (id, host_kind, host_id, stroke_json, ordinal, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
stroke_json = excluded.stroke_json,
ordinal = excluded.ordinal,
updated_at = excluded.updated_at''',
[
stroke.id,
hostKind,
hostId,
jsonEncode(stroke.toJson()),
i,
now,
],
);
}
}
// Delete removed rows.
for (final id in toDelete) {
await txn.delete('ink', where: 'id = ?', whereArgs: [id]);
}
});
// Update persisted id-set only after successful commit.
_persistedIds[hostId] = Set<String>.from(incomingIds);
}
// ── Host-id helpers ───────────────────────────────────────────────────
/// Build the canonical host_id for a document page.
static String pageHostId(String documentId, int pageIndex) =>
'doc:$documentId:page:$pageIndex';
}

View File

@@ -0,0 +1,98 @@
// lib/editor/persistence/save_scheduler.dart
//
// Debounced save scheduler. The snapshot/JSON is captured SYNCHRONOUSLY by
// the schedule() call before any await, so callers do not need to worry about
// the in-memory state mutating between schedule() and the actual write.
import 'dart:async';
import '../engine/stroke_model.dart';
import 'editor_repository.dart';
/// Debounced save scheduler that batches rapid successive changes to a host
/// into a single [EditorRepository.saveHost] call.
///
/// Usage:
/// ```dart
/// final scheduler = SaveScheduler(repository);
/// scheduler.schedule('page', hostId, List.from(strokes));
/// // …later, on dispose / navigate away:
/// await scheduler.flush();
/// scheduler.dispose();
/// ```
class SaveScheduler {
SaveScheduler(
this._repository, {
Duration debounce = const Duration(milliseconds: 800),
}) : _debounce = debounce;
final EditorRepository _repository;
final Duration _debounce;
// One pending timer + captured snapshot per host.
final Map<String, Timer> _timers = {};
final Map<String, _PendingWrite> _pending = {};
bool _disposed = false;
// ── Public API ─────────────────────────────────────────────────────────
/// Schedule a save for ([hostKind], [hostId]).
///
/// [strokes] is captured synchronously (defensive copy via the caller's
/// `List.from(…)` convention or equivalent) so mutations after this call
/// do not affect what is written.
void schedule(
String hostKind,
String hostId,
List<EditorStroke> strokes,
) {
if (_disposed) return;
// Capture the snapshot synchronously before any async gap.
_pending[hostId] = _PendingWrite(hostKind: hostKind, strokes: strokes);
_timers[hostId]?.cancel();
_timers[hostId] = Timer(_debounce, () => _fire(hostId));
}
/// Force-write all pending saves immediately and wait for them to complete.
Future<void> flush() async {
final hosts = List<String>.from(_pending.keys);
for (final hostId in hosts) {
_timers[hostId]?.cancel();
_timers.remove(hostId);
await _fire(hostId);
}
}
/// Cancel all pending timers and release resources.
///
/// Call [flush] first if you need pending writes to complete.
void dispose() {
_disposed = true;
for (final timer in _timers.values) {
timer.cancel();
}
_timers.clear();
_pending.clear();
}
// ── Internal ───────────────────────────────────────────────────────────
Future<void> _fire(String hostId) async {
final write = _pending.remove(hostId);
_timers.remove(hostId);
if (write == null) return;
await _repository.saveHost(write.hostKind, hostId, write.strokes);
}
}
// ---------------------------------------------------------------------------
class _PendingWrite {
const _PendingWrite({required this.hostKind, required this.strokes});
final String hostKind;
final List<EditorStroke> strokes;
}

View File

@@ -0,0 +1,58 @@
// lib/editor/persistence/sidecar_flush_observer.dart
//
// Phase 6 / §F.3 of the file-based storage plan (docs/plans/2026-06-24-file-
// based-storage.md): app-lifecycle flush hardening.
//
// The per-file SidecarRepository debounces writes by 800 ms. That window is the
// data-loss gap on a Windows tablet: if the OS suspends or closes the app
// before the timer fires, the last strokes never reach disk. This observer
// listens for the app leaving the foreground and DRAINS every open repo's
// pending write before the process can be frozen, so "never lose the last
// strokes on app close" holds even when the editor's own dispose() doesn't run.
//
// Registered once in BadNoteApp; it delegates to
// [SidecarRepositoryRegistry.flushAll], which awaits every repo's flush().
import 'package:flutter/widgets.dart';
import 'sidecar_repository.dart';
/// A [WidgetsBindingObserver] that flushes all open sidecar repositories when
/// the app leaves the foreground (`inactive`/`paused`/`detached`/`hidden`).
class SidecarFlushObserver with WidgetsBindingObserver {
/// Whether the observer is currently registered with the binding.
bool get isAttached => _attached;
bool _attached = false;
/// Register with [WidgetsBinding.instance] so lifecycle changes are observed.
void attach() {
if (_attached) return;
WidgetsBinding.instance.addObserver(this);
_attached = true;
}
/// Stop observing lifecycle changes.
void detach() {
if (!_attached) return;
WidgetsBinding.instance.removeObserver(this);
_attached = false;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
// Any transition out of the foreground is a potential suspend/kill point:
// drain pending sidecar writes now (the editors' own dispose() may never
// run when the OS freezes the process).
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
case AppLifecycleState.paused:
case AppLifecycleState.detached:
// Fire-and-forget at the framework boundary, but each write is atomic
// and awaited inside flushAll, so a half-written sidecar is impossible.
SidecarRepositoryRegistry.flushAll();
case AppLifecycleState.resumed:
break;
}
}
}

View File

@@ -0,0 +1,440 @@
// lib/editor/persistence/sidecar_repository.dart
//
// Phase 2 of the file-based storage plan (docs/plans/2026-06-24-file-based-
// storage.md §F): the PDF editor's persistence sink. Replaces the SQLite-backed
// EditorRepository/SaveScheduler/DatabaseService trio for the pen editor with a
// single per-file SIDECAR (`<sourceFile>.badnote.json`) living ALONGSIDE the
// source file, so annotations travel with the file ("跟着文件走").
//
// Design:
// * The repository owns the canonical in-memory [BadnoteSidecar]. Callers
// mutate it through the schedule* methods, which (1) update the in-memory
// model SYNCHRONOUSLY (so the snapshot can't be corrupted by a later edit
// mid-write — the SaveScheduler discipline, §F.2) and (2) arm a single
// debounce timer that writes the WHOLE sidecar atomically (§F.1, via
// SidecarStore.writeAtomic — temp + rename + .bak).
// * The unit of debounce is the whole document sidecar (sidecars are small —
// sparse normalized strokes), one atomic write per debounce window.
// * Identity is the SOURCE FILE PATH, not the old djb2 path-hash document id.
// The sidecar IS the identity.
import 'dart:async';
import 'dart:io';
import '../../models/bookmark.dart';
import '../../models/scratch_link.dart';
import '../../storage/badnote_sidecar.dart';
import '../../storage/sidecar_store.dart';
import '../engine/stroke_model.dart';
/// Suffix appended to a source-file path to form its sidecar path.
const String kSidecarSuffix = '.badnote.json';
/// Process-wide registry of OPEN [SidecarRepository] instances (Phase 6 / §F.3).
///
/// The 800 ms debounce timer only protects against losing work to a crash that
/// happens *between* edits; it does NOT help when the OS suspends or kills the
/// app mid-window (the main data-loss window on a Windows tablet). The app's
/// lifecycle observer ([SidecarFlushObserver]) calls [flushAll] on
/// `paused`/`inactive`/`detached` to drain every open repo's pending write
/// before the process can be frozen.
///
/// A repo registers itself in [open] and removes itself in [dispose], so the
/// set always reflects exactly the editors holding unsaved sidecar state.
class SidecarRepositoryRegistry {
SidecarRepositoryRegistry._();
static final Set<SidecarRepository> _open = <SidecarRepository>{};
/// The currently open repositories (for tests / inspection).
static Set<SidecarRepository> get open => Set.unmodifiable(_open);
/// Flush every open repository's pending debounced write and await them all.
/// Safe to call repeatedly; a repo with nothing pending is a cheap no-op.
static Future<void> flushAll() async {
// Snapshot first: a flush may complete and (in a future) trigger disposal,
// which mutates `_open` — iterating a copy avoids concurrent-modification.
final repos = List<SidecarRepository>.of(_open);
await Future.wait(repos.map((r) => r.flush()));
}
/// The open repository for [sourceFilePath], or null if none is open. Lets a
/// background task (e.g. OCR) write through the SAME in-memory sidecar the
/// editor holds, instead of racing it with a second open handle.
static SidecarRepository? forPath(String sourceFilePath) {
for (final r in _open) {
if (r.sourceFilePath == sourceFilePath) return r;
}
return null;
}
static void _register(SidecarRepository repo) => _open.add(repo);
static void _unregister(SidecarRepository repo) => _open.remove(repo);
/// Test-only: drop all registrations so one test can't leak repos into the
/// next. Does NOT flush or dispose them.
static void resetForTest() => _open.clear();
}
/// Per-file persistence for the pen editor. Loads the sidecar for a source file
/// path, holds it in memory, and debounces atomic writes back to disk.
class SidecarRepository {
SidecarRepository._({
required this.sourceFilePath,
required BadnoteSidecar sidecar,
Duration debounce = const Duration(milliseconds: 800),
}) : _sidecar = sidecar,
_debounce = debounce;
/// Absolute path to the annotated source file (e.g. the vault PDF copy).
final String sourceFilePath;
/// The sidecar file: `<sourceFilePath>.badnote.json`.
File get sidecarFile => File('$sourceFilePath$kSidecarSuffix');
final Duration _debounce;
BadnoteSidecar _sidecar;
Timer? _timer;
bool _disposed = false;
/// How many editors currently hold this repo. [open] reuses an existing
/// instance and bumps the count; [dispose] only tears down at zero so a
/// split-view / sticky overlay cannot clobber the PDF editor's sidecar.
int _retainCount = 1;
/// Tail of the in-flight write chain. Writes are serialized through this so a
/// debounce-timer write and a concurrent lifecycle [flush] can't race on the
/// same `.tmp`/rename (which would throw on the loser). Each write always
/// persists the LATEST snapshot, so collapsing overlapping writes is safe.
Future<void> _writeChain = Future<void>.value();
/// Open (or create) the repository for [sourceFilePath]. Reads the existing
/// sidecar if present (falling back to its `.bak`), else starts empty.
///
/// Reuses an already-open repo for the same path (retain-counted) so a
/// scratchpad overlay / split view cannot race the PDF editor with a second
/// in-memory snapshot that would overwrite scratchpad ink on flush.
static Future<SidecarRepository> open(
String sourceFilePath, {
String? docType,
Duration debounce = const Duration(milliseconds: 800),
}) async {
final existing = SidecarRepositoryRegistry.forPath(sourceFilePath);
if (existing != null && !existing._disposed) {
existing._retainCount++;
return existing;
}
final file = File('$sourceFilePath$kSidecarSuffix');
final loaded = await SidecarStore.read(file);
var sidecar = loaded ??
BadnoteSidecar(
sourceFile: _basename(sourceFilePath),
docType: docType,
pageCount: docType == 'notebook' ? 1 : null,
createdAt: DateTime.now().toUtc(),
);
// Standalone notebooks always carry an explicit pageCount (min 1). Older
// sidecars that omit it are normalized in-memory on open.
if (docType == 'notebook' &&
(sidecar.pageCount == null || sidecar.pageCount! < 1)) {
sidecar = BadnoteSidecar(
version: sidecar.version,
sourceFile: sidecar.sourceFile,
docType: sidecar.docType,
title: sidecar.title,
pageCount: 1,
rotation: sidecar.rotation,
createdAt: sidecar.createdAt,
updatedAt: sidecar.updatedAt,
strokes: sidecar.strokes,
highlights: sidecar.highlights,
texts: sidecar.texts,
bookmarks: sidecar.bookmarks,
scratchLinks: sidecar.scratchLinks,
legacyAnnotations: sidecar.legacyAnnotations,
ocrText: sidecar.ocrText,
pageText: sidecar.pageText,
legacyId: sidecar.legacyId,
background: sidecar.background,
);
}
final repo = SidecarRepository._(
sourceFilePath: sourceFilePath,
sidecar: sidecar,
debounce: debounce,
);
SidecarRepositoryRegistry._register(repo);
return repo;
}
// ── Loaded snapshot accessors (read at open) ───────────────────────────────
/// Page index → committed strokes loaded from the sidecar.
Map<int, List<EditorStroke>> get loadedStrokes => _sidecar.strokes;
/// Page index → highlight rects loaded from the sidecar.
Map<int, List<SidecarHighlight>> get loadedHighlights => _sidecar.highlights;
/// Page index → typed-text annotations loaded from the sidecar.
Map<int, List<SidecarText>> get loadedTexts => _sidecar.texts;
/// Scratch-link anchors loaded from the sidecar.
List<SidecarScratchLink> get loadedScratchLinks => _sidecar.scratchLinks;
/// Bookmarks loaded from the sidecar.
List<Bookmark> get loadedBookmarks => _sidecar.bookmarks;
/// The current in-memory sidecar (for tests / inspection).
BadnoteSidecar get sidecar => _sidecar;
/// The standalone-notebook title loaded from the sidecar, or null.
String? get loadedTitle => _sidecar.title;
/// The page-background template name loaded from the sidecar, or null
/// (missing → blank, decoded by the editor).
String? get loadedBackground => _sidecar.background;
// ── Mutations (synchronous in-memory update + debounced atomic write) ──────
/// Replace the standalone-notebook title and schedule a save. No-op if the
/// title is unchanged.
void scheduleTitleSave(String title) {
if (_sidecar.title == title) return;
_replace(title: title);
}
/// Replace the page-background template (a [NoteBackground] enum name) and
/// schedule a save. No-op if unchanged.
void scheduleBackgroundSave(String background) {
if (_sidecar.background == background) return;
_replace(background: background);
}
/// Replace the handwriting-OCR search text and schedule a save (Phase 6
/// search index). No-op if unchanged.
void scheduleOcrTextSave(String? ocrText) {
final next = (ocrText != null && ocrText.isEmpty) ? null : ocrText;
if (_sidecar.ocrText == next) return;
_replace(ocrText: next, clearOcrText: next == null);
}
/// The OCR text loaded from the sidecar, or null.
String? get loadedOcrText => _sidecar.ocrText;
/// Replace the document-body search text (PDF embedded text layer, or
/// background OCR of a rasterized PDF — see [PdfTextIndexer]) and schedule a
/// save. No-op if unchanged. An empty string is normalized to null.
void schedulePageTextSave(String? pageText) {
final next = (pageText != null && pageText.isEmpty) ? null : pageText;
if (_sidecar.pageText == next) return;
_replace(pageText: next, clearPageText: next == null);
}
/// The document-body search text loaded from the sidecar, or null.
String? get loadedPageText => _sidecar.pageText;
/// Replace the committed strokes for [pageIndex] and schedule a save.
void scheduleStrokeSave(int pageIndex, List<EditorStroke> strokes) {
final next = Map<int, List<EditorStroke>>.from(_sidecar.strokes);
if (strokes.isEmpty) {
next.remove(pageIndex);
} else {
next[pageIndex] = List<EditorStroke>.of(strokes);
}
_replace(strokes: next);
}
/// Replace the standalone-notebook page count and schedule a save. No-op if
/// unchanged. [count] is clamped to at least 1.
void schedulePageCountSave(int count) {
final next = count < 1 ? 1 : count;
if (_sidecar.pageCount == next) return;
_replace(pageCount: next);
}
/// Replace the highlight rects for [pageIndex] and schedule a save.
void scheduleHighlightSave(int pageIndex, List<SidecarHighlight> highlights) {
final next = Map<int, List<SidecarHighlight>>.from(_sidecar.highlights);
if (highlights.isEmpty) {
next.remove(pageIndex);
} else {
next[pageIndex] = List<SidecarHighlight>.of(highlights);
}
_replace(highlights: next);
}
/// Replace the typed-text annotations for [pageIndex] and schedule a save.
void scheduleTextsSave(int pageIndex, List<SidecarText> texts) {
final next = Map<int, List<SidecarText>>.from(_sidecar.texts);
if (texts.isEmpty) {
next.remove(pageIndex);
} else {
next[pageIndex] = List<SidecarText>.of(texts);
}
_replace(texts: next);
}
/// Add (or update) a scratch-link anchor, preserving any existing scratchpad,
/// and schedule a save.
void scheduleScratchLinkUpsert(ScratchLink link) {
final next = List<SidecarScratchLink>.of(_sidecar.scratchLinks);
final idx = next.indexWhere((s) => s.link.id == link.id);
if (idx == -1) {
next.add(SidecarScratchLink(link: link));
} else {
next[idx] = SidecarScratchLink(
link: link,
scratchpad: next[idx].scratchpad,
);
}
_replace(scratchLinks: next);
}
/// Remove the scratch-link anchor (and its embedded scratchpad) by [linkId].
void scheduleScratchLinkDelete(String linkId) {
final next = _sidecar.scratchLinks
.where((s) => s.link.id != linkId)
.toList(growable: false);
_replace(scratchLinks: List<SidecarScratchLink>.of(next));
}
/// Replace the embedded scratchpad of the anchor [linkId] and schedule a save.
/// No-op if the anchor isn't present.
void scheduleScratchpadSave(String linkId, SidecarScratchpad scratchpad) {
final next = List<SidecarScratchLink>.of(_sidecar.scratchLinks);
final idx = next.indexWhere((s) => s.link.id == linkId);
if (idx == -1) return;
next[idx] = SidecarScratchLink(link: next[idx].link, scratchpad: scratchpad);
_replace(scratchLinks: next);
}
/// Add (or update, by id) a bookmark and schedule a save.
void scheduleBookmarkUpsert(Bookmark bookmark) {
final next = List<Bookmark>.of(_sidecar.bookmarks);
final idx = next.indexWhere((b) => b.id == bookmark.id);
if (idx == -1) {
next.add(bookmark);
} else {
next[idx] = bookmark;
}
_replace(bookmarks: next);
}
/// Remove the bookmark by [bookmarkId] and schedule a save.
void scheduleBookmarkDelete(String bookmarkId) {
final next =
_sidecar.bookmarks.where((b) => b.id != bookmarkId).toList();
_replace(bookmarks: next);
}
/// Replace the whole bookmark list and schedule a save.
void scheduleBookmarksSave(List<Bookmark> bookmarks) {
_replace(bookmarks: List<Bookmark>.of(bookmarks));
}
/// The embedded scratchpad for [linkId], or null if the anchor is unknown.
SidecarScratchpad? scratchpadFor(String linkId) {
for (final s in _sidecar.scratchLinks) {
if (s.link.id == linkId) return s.scratchpad;
}
return null;
}
// ── Flush / dispose ────────────────────────────────────────────────────────
/// Write any pending change immediately and wait for it (and any in-flight
/// write) to land. If the debounce timer is still armed, fire one final write
/// of the latest snapshot; otherwise just drain whatever write is in flight.
Future<void> flush() async {
if (_timer != null) {
_timer!.cancel();
_timer = null;
await _write();
return;
}
// No pending edit, but a fire-and-forget timer write may still be running:
// await the chain so the bytes are on disk before we return.
await _writeChain;
}
/// Cancel pending timers. Call [flush] first to persist pending writes.
void dispose() {
if (_disposed) return;
if (_retainCount > 1) {
_retainCount--;
return;
}
_disposed = true;
_timer?.cancel();
_timer = null;
SidecarRepositoryRegistry._unregister(this);
}
// ── Internal ───────────────────────────────────────────────────────────────
/// Build a new sidecar (touching `updatedAt`) from the current one with the
/// given fields replaced, then arm the debounce timer. Snapshot is captured
/// synchronously here so a later edit can't corrupt an in-flight write.
void _replace({
String? title,
int? pageCount,
Map<int, List<EditorStroke>>? strokes,
Map<int, List<SidecarHighlight>>? highlights,
Map<int, List<SidecarText>>? texts,
List<Bookmark>? bookmarks,
List<SidecarScratchLink>? scratchLinks,
String? ocrText,
bool clearOcrText = false,
String? pageText,
bool clearPageText = false,
String? background,
}) {
if (_disposed) return;
_sidecar = BadnoteSidecar(
version: _sidecar.version,
sourceFile: _sidecar.sourceFile,
docType: _sidecar.docType,
title: title ?? _sidecar.title,
pageCount: pageCount ?? _sidecar.pageCount,
rotation: _sidecar.rotation,
createdAt: _sidecar.createdAt,
updatedAt: DateTime.now().toUtc(),
strokes: strokes ?? _sidecar.strokes,
highlights: highlights ?? _sidecar.highlights,
texts: texts ?? _sidecar.texts,
bookmarks: bookmarks ?? _sidecar.bookmarks,
scratchLinks: scratchLinks ?? _sidecar.scratchLinks,
ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText),
pageText: clearPageText ? null : (pageText ?? _sidecar.pageText),
background: background ?? _sidecar.background,
);
_timer?.cancel();
_timer = Timer(_debounce, () {
_timer = null;
// Fire-and-forget; the next schedule simply re-arms the timer and the
// atomic write guarantees no torn file.
_write();
});
}
/// Serialize writes through [_writeChain] so overlapping flushes never race
/// on the temp file. Each link writes the latest in-memory snapshot at the
/// moment it runs; an error in one write doesn't break the chain for the next.
Future<void> _write() {
final next = _writeChain.then((_) async {
final snapshot = _sidecar;
await SidecarStore.writeAtomic(sidecarFile, snapshot);
});
// Keep the chain alive past a failed write (e.g. transient FS error).
_writeChain = next.catchError((_) {});
return next;
}
static String _basename(String path) {
final norm = path.replaceAll('\\', '/');
final i = norm.lastIndexOf('/');
return i == -1 ? norm : norm.substring(i + 1);
}
}

View File

@@ -0,0 +1,71 @@
// lib/editor/render/annotation_layer.dart
//
// Composites the static committed-stroke layer and the live in-progress layer
// into a single widget. Wrap the page widget with this to get ink rendering.
//
// Layout:
// RepaintBoundary
// └─ Stack
// ├─ CustomPaint(StaticInkPainter) ← repaints only on revision bump
// └─ CustomPaint(LiveInkPainter) ← repaints on every pointer move
import 'package:flutter/material.dart';
import '../engine/stroke_model.dart';
import '../engine/stroke_store.dart';
import 'ink_picture_cache.dart';
import 'live_ink_painter.dart';
import 'static_ink_painter.dart';
/// A [StatelessWidget] that renders committed and live ink strokes over a
/// [pageSize]-sized area.
///
/// Place it as an overlay on top of the page content; it is fully transparent
/// where no strokes are drawn.
///
/// [hostId] identifies the ink host (e.g. page id) and is used as the cache
/// key prefix so multiple pages can share an [InkPictureCache] instance.
class AnnotationLayer extends StatelessWidget {
const AnnotationLayer({
super.key,
required this.hostId,
required this.store,
required this.liveStroke,
required this.pageSize,
required this.cache,
});
final String hostId;
final StrokeStore store;
/// The stroke currently being drawn, or null when idle.
final EditorStroke? liveStroke;
final Size pageSize;
final InkPictureCache cache;
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: Stack(
children: [
CustomPaint(
size: pageSize,
painter: StaticInkPainter(
hostId: hostId,
store: store,
pageSize: pageSize,
cache: cache,
),
),
CustomPaint(
size: pageSize,
painter: LiveInkPainter(
live: liveStroke,
pageSize: pageSize,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,87 @@
// lib/editor/render/ink_picture_cache.dart
//
// Bounded LRU cache of ui.Picture objects keyed by "hostId:revision".
//
// Resolution-independent: ink is vector, so a single Picture is valid at any
// zoom level. There are NO DPI buckets.
//
// Evicted Pictures are disposed via a post-frame callback so Flutter's raster
// thread is never asked to delete a Picture it may still be reading.
import 'dart:collection';
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
/// Bounded LRU cache of [ui.Picture]s keyed by a string (typically
/// `"$hostId:$revision"`).
///
/// Usage:
/// ```dart
/// final picture = cache.getOrBuild(hostId, store.revision, size, () {
/// final recorder = ui.PictureRecorder();
/// final canvas = ui.Canvas(recorder);
/// // … draw …
/// return recorder.endRecording();
/// });
/// canvas.drawPicture(picture);
/// ```
class InkPictureCache {
InkPictureCache({int maxSize = 12}) : _maxSize = maxSize;
final int _maxSize;
// LinkedHashMap preserves insertion order; we move accessed entries to the
// back so the front is always the least-recently used.
final LinkedHashMap<String, ui.Picture> _cache =
LinkedHashMap<String, ui.Picture>();
/// Returns a cached [ui.Picture] for [key], or calls [build] to create one.
///
/// The [key] should encode all inputs that affect the picture content (host
/// id + revision, at minimum). [size] and [build] are only used on a cache
/// miss.
ui.Picture getOrBuild(
String hostId,
int revision,
ui.Size size,
ui.Picture Function() build,
) {
final key = '$hostId:$revision';
if (_cache.containsKey(key)) {
// Promote to most-recently-used by reinserting at the back.
final pic = _cache.remove(key)!;
_cache[key] = pic;
return pic;
}
final picture = build();
_cache[key] = picture;
// Evict least-recently-used entries beyond the cap.
while (_cache.length > _maxSize) {
final lruKey = _cache.keys.first;
final evicted = _cache.remove(lruKey)!;
_disposeDeferred(evicted);
}
return picture;
}
/// Disposes all cached Pictures, deferring the actual disposal to a
/// post-frame callback so any in-flight raster work can complete.
void dispose() {
final pictures = List<ui.Picture>.from(_cache.values);
_cache.clear();
for (final pic in pictures) {
_disposeDeferred(pic);
}
}
static void _disposeDeferred(ui.Picture picture) {
WidgetsBinding.instance.addPostFrameCallback((_) {
picture.dispose();
});
}
}

View File

@@ -0,0 +1,50 @@
// lib/editor/render/live_ink_painter.dart
//
// CustomPainter for the in-progress stroke (live) layer.
//
// Paints only the single EditorStroke? currently being drawn, with
// isComplete:false so perfect_freehand tapers the trailing end correctly.
// Kept in a separate RepaintBoundary so committed strokes are never
// re-rasterized on pointer-move events.
import 'package:flutter/material.dart';
import '../engine/stroke_geometry.dart';
import '../engine/stroke_model.dart';
/// Paints the single in-progress [EditorStroke] (or nothing when [live] is
/// null / empty). Use alongside [StaticInkPainter] in stacked [CustomPaint]s.
class LiveInkPainter extends CustomPainter {
const LiveInkPainter({
required this.live,
required this.pageSize,
this.thinning = kDefaultPenThinning,
});
/// The stroke currently being drawn, or null when idle.
final EditorStroke? live;
final Size pageSize;
/// perfect_freehand pressure→width response (from `PenConfig.pressureSensitivity`),
/// kept consistent with the static layer so the stroke doesn't change width
/// the instant it commits.
final double thinning;
@override
void paint(Canvas canvas, Size size) {
final stroke = live;
if (stroke == null || stroke.points.isEmpty) return;
final path = buildStrokeOutline(stroke, pageSize,
isComplete: false, thinning: thinning);
if (path.getBounds().isEmpty) return;
canvas.drawPath(path, paintForEditorStroke(stroke));
}
@override
bool shouldRepaint(LiveInkPainter old) =>
!identical(old.live, live) ||
old.pageSize != pageSize ||
old.thinning != thinning;
}

View File

@@ -0,0 +1,77 @@
// lib/editor/render/static_ink_painter.dart
//
// CustomPainter for the committed-stroke (static) layer.
//
// paint() gets-or-builds a ui.Picture of all committed strokes keyed by
// store.revision, then delegates to canvas.drawPicture — so as long as the
// revision is unchanged the raster thread replays the same display list at
// zero CPU cost.
//
// shouldRepaint() is O(1): it compares the revision int and pageSize only.
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import '../engine/stroke_geometry.dart';
import '../engine/stroke_store.dart';
import 'ink_picture_cache.dart';
/// Paints the committed ink layer by recording strokes into a [ui.Picture]
/// once per [StrokeStore.revision] and caching it in [InkPictureCache].
///
/// Place this inside a [RepaintBoundary] / [CustomPaint] pair. The sibling
/// [LiveInkPainter] handles the in-progress stroke in a separate layer.
class StaticInkPainter extends CustomPainter {
StaticInkPainter({
required this.hostId,
required this.store,
required this.pageSize,
required this.cache,
this.thinning = kDefaultPenThinning,
}) : revision = store.revision;
final String hostId;
final StrokeStore store;
final Size pageSize;
final InkPictureCache cache;
/// perfect_freehand pressure→width response (from `PenConfig.pressureSensitivity`).
/// Folded into the cache key + [shouldRepaint] so a sensitivity change can't
/// replay a stale Picture built at the old thinning.
final double thinning;
/// Revision snapshot captured at construction time. Used by [shouldRepaint]
/// so two painters built at different revisions compare correctly even when
/// they share the same [StrokeStore] instance.
final int revision;
@override
void paint(Canvas canvas, Size size) {
// thinning is part of the cache identity (different thinning ⇒ different
// outline) so it MUST be in the key, not just shouldRepaint.
final cacheKey = '$hostId#${thinning.toStringAsFixed(4)}';
final picture = cache.getOrBuild(cacheKey, store.revision, pageSize, () {
final recorder = ui.PictureRecorder();
final rec = Canvas(recorder);
for (final stroke in store.committed) {
final path = buildStrokeOutline(stroke, pageSize,
isComplete: true, thinning: thinning);
if (path.getBounds().isEmpty) continue;
// One drawPath per stroke ⇒ highlighter self-overlap never darkens;
// cross-stroke overlap darkens via BlendMode.multiply (closes
// TODO(brush-opacity); shared resolver with the live + PenCanvas paths).
rec.drawPath(path, paintForEditorStroke(stroke));
}
return recorder.endRecording();
});
canvas.drawPicture(picture);
}
@override
bool shouldRepaint(StaticInkPainter old) =>
old.revision != store.revision ||
old.pageSize != pageSize ||
old.thinning != thinning;
}

View File

@@ -0,0 +1,80 @@
// lib/editor/search/search_ranking.dart
//
// Pure ranking + aggregation for full-text search (F8). Given many sources (PDF
// text pages, typed boxes, OCR'd handwriting) keyed by a ref string, score each
// for a query and return the best hits with display snippets. This is the
// search_indexer's ranking core; the FTS pre-filter/index lives in the DB.
//
// Pure (no storage/widgets); builds on search_text (normalize/match) +
// search_snippet (excerpt). Fully unit-tested.
import 'search_snippet.dart';
import 'search_text.dart';
/// A ranked search result: WHERE it is ([ref]), the display [snippet], and the
/// [score] (higher = better).
class SearchHit {
const SearchHit({required this.ref, required this.snippet, required this.score});
final String ref;
final Snippet snippet;
final double score;
@override
String toString() => 'SearchHit($ref, score=${score.toStringAsFixed(3)})';
}
/// Score [source] for [query]: more (normalized) occurrences rank higher, and an
/// earlier first match breaks ties. 0 when there is no match.
double scoreText(String source, String query) {
final q = normalizeForIndex(query);
if (q.isEmpty) return 0;
final s = normalizeForIndex(source);
if (s.isEmpty) return 0;
var count = 0;
var from = 0;
var firstPos = -1;
while (true) {
final idx = s.indexOf(q, from);
if (idx < 0) break;
if (firstPos < 0) firstPos = idx;
count++;
from = idx + q.length;
}
if (count == 0) return 0;
// Earliness in (0,1]: a match at position 0 scores 1.0.
final earliness = 1.0 - (firstPos / s.length);
return count + earliness;
}
/// Build + rank hits across [sources] (ref → raw text) for [query]: drop
/// non-matches, attach a display snippet of the ORIGINAL text, sort best-first.
/// Ties (equal score) keep input order (stable).
List<SearchHit> rankHits(
Map<String, String> sources,
String query, {
int window = 80,
}) {
final indexed = <({int order, SearchHit hit})>[];
var order = 0;
for (final entry in sources.entries) {
final i = order++;
final score = scoreText(entry.value, query);
if (score <= 0) continue;
final snippet = snippetFor(entry.value, query, window: window);
if (snippet == null) continue; // matched normalized but not raw (rare)
indexed.add((
order: i,
hit: SearchHit(ref: entry.key, snippet: snippet, score: score),
));
}
// Descending score; ties keep input order (Dart's sort isn't stable, so the
// input index is an explicit tiebreaker).
indexed.sort((a, b) {
final byScore = b.hit.score.compareTo(a.hit.score);
return byScore != 0 ? byScore : a.order.compareTo(b.order);
});
return [for (final e in indexed) e.hit];
}

View File

@@ -0,0 +1,73 @@
// lib/editor/search/search_snippet.dart
//
// Pure snippet extraction for library-wide full-text search (F8 — the user's #1
// named differentiator). Given a source string (a PDF text page, a typed text
// box, or OCR'd handwriting) and a query, produce a windowed excerpt centered on
// the first match with the match offset preserved, so the results list can show
// "…context **match** context…" and jump to the hit.
//
// The FTS index / ranking lives in the DB (search_indexer); THIS is the pure,
// storage-free excerpt math, fully unit-tested.
/// A windowed excerpt around a query match.
class Snippet {
const Snippet({
required this.text,
required this.matchStart,
required this.matchLength,
required this.truncatedStart,
required this.truncatedEnd,
});
/// The excerpt (a substring of the source).
final String text;
/// Offset of the match WITHIN [text].
final int matchStart;
/// Length of the matched run.
final int matchLength;
/// True when [text] begins before the source start was reached (show a
/// leading ellipsis).
final bool truncatedStart;
/// True when [text] ends before the source end (show a trailing ellipsis).
final bool truncatedEnd;
/// Convenience: the matched substring.
String get match => text.substring(matchStart, matchStart + matchLength);
@override
String toString() =>
'${truncatedStart ? '' : ''}$text${truncatedEnd ? '' : ''}'
' [match @$matchStart+$matchLength]';
}
/// First case-insensitive match of [query] in [source], as a snippet of up to
/// roughly [window] characters centered on the match. Returns null when [query]
/// is empty or absent. The full match is always included even if longer than
/// [window].
Snippet? snippetFor(String source, String query, {int window = 80}) {
if (query.isEmpty || source.isEmpty) return null;
assert(window >= 0);
final matchIndex = source.toLowerCase().indexOf(query.toLowerCase());
if (matchIndex < 0) return null;
final matchLen = query.length;
final contextEach = ((window - matchLen) ~/ 2).clamp(0, window);
var start = matchIndex - contextEach;
if (start < 0) start = 0;
var end = matchIndex + matchLen + contextEach;
if (end > source.length) end = source.length;
return Snippet(
text: source.substring(start, end),
matchStart: matchIndex - start,
matchLength: matchLen,
truncatedStart: start > 0,
truncatedEnd: end < source.length,
);
}

View File

@@ -0,0 +1,30 @@
// lib/editor/search/search_text.dart
//
// Pure text normalization + matching for full-text search (F8). PDF text layers
// and OCR output are full of hard line breaks and irregular whitespace, so a
// query like "hello world" won't substring-match raw extracted text that reads
// "hello\nworld". Normalizing both sides (lowercase + collapse every whitespace
// run to a single space + trim) fixes that.
//
// CJK NOTE: this user writes Chinese. We deliberately do NOT word-tokenize —
// Chinese has no inter-word spaces, so a whitespace/punctuation tokenizer would
// mangle it. Substring matching over normalized text is correct for both Latin
// and CJK; word/段 segmentation belongs in the DB FTS tokenizer (trigram /
// unicode61), not here.
/// Matches any run of Unicode whitespace (spaces, tabs, newlines, NBSP, …).
final RegExp _whitespaceRun = RegExp(r'\s+');
/// Normalize [text] for indexing/matching: lowercase, collapse whitespace runs
/// (incl. the hard newlines PDF/OCR insert mid-sentence) to single spaces, trim.
String normalizeForIndex(String text) {
return text.toLowerCase().replaceAll(_whitespaceRun, ' ').trim();
}
/// Whether [source] contains [query] after both are normalized — so a match can
/// span the line breaks present in the raw text. Empty query never matches.
bool matchesNormalized(String source, String query) {
final q = normalizeForIndex(query);
if (q.isEmpty) return false;
return normalizeForIndex(source).contains(q);
}

10
lib/editor/stroke.dart Normal file
View File

@@ -0,0 +1,10 @@
// Canonical stroke model surface.
//
// Historical baggage had three parallel types (PenStroke / EditorStroke /
// InkStroke). New code MUST import from this barrel and prefer [EditorStroke]
// for engine/storage. UI adapters convert at the edge.
//
// Do not add a fourth model.
export '../engine/stroke_model.dart' show EditorStroke, EditorPoint, EditorTool;
export '../canvas/pen_stroke.dart' show PenStroke, PenPoint, PenStrokeKind;

View File

@@ -0,0 +1,57 @@
// lib/editor/ui/page_nav_shortcuts.dart
//
// Shared keyboard page navigation for PDF / slide / office editors.
// Arrow keys + PageUp/PageDown (+ Home/End when provided).
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class PreviousPageIntent extends Intent {
const PreviousPageIntent();
}
class NextPageIntent extends Intent {
const NextPageIntent();
}
class FirstPageIntent extends Intent {
const FirstPageIntent();
}
class LastPageIntent extends Intent {
const LastPageIntent();
}
/// Wraps [child] so ←/→/PageUp/PageDown(/Home/End) drive page changes.
Widget pageNavShortcuts({
required Widget child,
required VoidCallback? onPrevious,
required VoidCallback? onNext,
VoidCallback? onFirst,
VoidCallback? onLast,
}) {
return Focus(
autofocus: true,
child: CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
const SingleActivator(LogicalKeyboardKey.arrowLeft): () =>
onPrevious?.call(),
const SingleActivator(LogicalKeyboardKey.arrowUp): () =>
onPrevious?.call(),
const SingleActivator(LogicalKeyboardKey.pageUp): () =>
onPrevious?.call(),
const SingleActivator(LogicalKeyboardKey.arrowRight): () =>
onNext?.call(),
const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
onNext?.call(),
const SingleActivator(LogicalKeyboardKey.pageDown): () =>
onNext?.call(),
if (onFirst != null)
const SingleActivator(LogicalKeyboardKey.home): onFirst,
if (onLast != null)
const SingleActivator(LogicalKeyboardKey.end): onLast,
},
child: child,
),
);
}

View File

@@ -0,0 +1,390 @@
import 'package:flutter/material.dart';
import '../input/pen_config.dart';
import '../input/pressure_curve.dart';
String _shapeNameForGamma(double gamma) {
if ((gamma - 0.6).abs() < 0.05) return 'soft';
if ((gamma - 1.0).abs() < 0.05) return 'linear';
if ((gamma - 2.0).abs() < 0.05) return 'quadratic';
if ((gamma - 3.0).abs() < 0.05) return 'cubic';
if ((gamma - 0.5).abs() < 0.05) return 'sqrt';
return 'soft';
}
PressureCurve _curveForName(String name) => switch (name) {
'linear' => PressureCurve.shaped(PressureCurveShape.linear),
'quadratic' => PressureCurve.shaped(PressureCurveShape.quadratic),
'cubic' => PressureCurve.shaped(PressureCurveShape.cubic),
'sqrt' => PressureCurve.shaped(PressureCurveShape.sqrt),
'logarithmic' => PressureCurve.shaped(PressureCurveShape.soft), // gamma proxy
_ => PressureCurve.shaped(PressureCurveShape.soft),
};
/// Shows a Material You modal bottom sheet for configuring pen input.
///
/// Changes are applied and persisted immediately via [controller] setters,
/// so the sheet reflects the live configuration at all times.
Future<void> showPenSettingsSheet(
BuildContext context,
PenConfigController controller,
) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => _PenSettingsSheet(controller: controller),
);
}
class _PenSettingsSheet extends StatelessWidget {
const _PenSettingsSheet({required this.controller});
final PenConfigController controller;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
final config = controller.value;
final colorScheme = Theme.of(context).colorScheme;
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.7,
minChildSize: 0.4,
maxChildSize: 0.95,
builder: (context, scrollController) {
return ListView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
// Drag handle
Center(
child: Container(
width: 32,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withAlpha(80),
borderRadius: BorderRadius.circular(2),
),
),
),
Text(
'Pen Settings',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// ── Button Actions ────────────────────────────────────────
_SectionHeader(
title: 'Button Actions',
icon: Icons.touch_app,
colorScheme: colorScheme,
),
const SizedBox(height: 8),
_LabeledRow(
label: 'Side Button',
child: _ActionDropdown(
value: config.sideButton,
onChanged: controller.setSideButton,
),
),
const SizedBox(height: 8),
_LabeledRow(
label: 'Eraser End',
child: _ActionDropdown(
value: config.eraserEnd,
onChanged: controller.setEraserEnd,
),
),
const SizedBox(height: 16),
// ── Pressure ──────────────────────────────────────────────
_SectionHeader(
title: 'Pressure',
icon: Icons.compress,
colorScheme: colorScheme,
),
_SliderTile(
label: 'Pressure Sensitivity',
value: config.pressureSensitivity,
min: 0.0,
max: 1.0,
divisions: 20,
formatValue: (v) => v.toStringAsFixed(2),
onChanged: controller.setPressureSensitivity,
),
_SliderTile(
label: 'Pressure Gamma',
value: config.pressureGamma,
min: 0.3,
max: 3.0,
divisions: 27,
formatValue: (v) => v.toStringAsFixed(2),
onChanged: controller.setPressureGamma,
),
_LabeledRow(
label: 'Curve Preset (rnote)',
child: DropdownMenu<String>(
initialSelection: _shapeNameForGamma(config.pressureGamma),
onSelected: (name) {
if (name == null) return;
final shaped = _curveForName(name);
controller.setPressureGamma(shaped.gamma);
},
dropdownMenuEntries: const [
DropdownMenuEntry(value: 'soft', label: 'Soft (γ≈0.6)'),
DropdownMenuEntry(value: 'linear', label: 'Linear'),
DropdownMenuEntry(
value: 'quadratic', label: 'Quadratic / Pow2'),
DropdownMenuEntry(value: 'cubic', label: 'Cubic'),
DropdownMenuEntry(value: 'sqrt', label: 'Sqrt (pencil)'),
],
),
),
const Padding(
padding: EdgeInsets.only(left: 8, bottom: 8),
child: Text(
'笔刷自带曲线优先(钢笔=二次/Pow2铅笔=平方根)。全局 gamma 作后备。',
style: TextStyle(fontSize: 12),
),
),
// ── Input ─────────────────────────────────────────────────
_SectionHeader(
title: 'Input',
icon: Icons.pan_tool_alt,
colorScheme: colorScheme,
),
_SliderTile(
label: 'Palm Rejection',
value: config.palmRejectionMs,
min: 0,
max: 500,
divisions: 50,
formatValue: (v) => '${v.round()} ms',
onChanged: controller.setPalmRejectionMs,
),
SwitchListTile(
title: const Text('Finger Drawing'),
subtitle: const Text(
'Allow touch strokes when no pen is detected',
),
value: config.fingerDrawing,
onChanged: controller.setFingerDrawing,
contentPadding: EdgeInsets.zero,
),
// ── Stroke Widths ─────────────────────────────────────────
_SectionHeader(
title: 'Stroke Widths',
icon: Icons.line_weight,
colorScheme: colorScheme,
),
_SliderTile(
label: 'Pen Width',
value: config.penWidth,
min: 0.001,
max: 0.02,
divisions: 19,
formatValue: (v) => v.toStringAsFixed(4),
onChanged: controller.setPenWidth,
),
_SliderTile(
label: 'Highlighter Width',
value: config.highlighterWidth,
min: 0.005,
max: 0.06,
divisions: 22,
formatValue: (v) => v.toStringAsFixed(4),
onChanged: controller.setHighlighterWidth,
),
// ── Eraser ────────────────────────────────────────────────
_SectionHeader(
title: 'Eraser',
icon: Icons.cleaning_services_outlined,
colorScheme: colorScheme,
),
_SliderTile(
label: 'Eraser Size',
value: config.eraserRadius,
min: 0.005,
max: 0.1,
divisions: 19,
formatValue: (v) => v.toStringAsFixed(3),
onChanged: controller.setEraserRadius,
),
SwitchListTile(
title: const Text('Stroke Eraser'),
subtitle: const Text(
'Erase a whole stroke on contact (off: erase by segment)',
),
value: config.eraserWholeStroke,
onChanged: controller.setEraserWholeStroke,
contentPadding: EdgeInsets.zero,
),
],
);
},
);
},
);
}
}
// ── Shared section header ────────────────────────────────────────────────────
class _SectionHeader extends StatelessWidget {
const _SectionHeader({
required this.title,
required this.icon,
required this.colorScheme,
});
final String title;
final IconData icon;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Row(
children: [
Icon(icon, size: 18, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Expanded(child: Divider(color: colorScheme.outlineVariant)),
],
),
);
}
}
// ── Label + widget row ───────────────────────────────────────────────────────
class _LabeledRow extends StatelessWidget {
const _LabeledRow({required this.label, required this.child});
final String label;
final Widget child;
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 120,
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
Expanded(child: child),
],
);
}
}
// ── Action dropdown ──────────────────────────────────────────────────────────
class _ActionDropdown extends StatelessWidget {
const _ActionDropdown({required this.value, required this.onChanged});
final PenButtonAction value;
final ValueChanged<PenButtonAction> onChanged;
static String _label(PenButtonAction action) => switch (action) {
PenButtonAction.none => '',
PenButtonAction.eraser => '橡皮',
PenButtonAction.undo => '撤销',
PenButtonAction.toggleTool => '切换工具',
PenButtonAction.pan => '平移',
PenButtonAction.select => '选择(笔迹)',
PenButtonAction.selectText => '选择文本',
};
@override
Widget build(BuildContext context) {
return DropdownMenu<PenButtonAction>(
initialSelection: value,
expandedInsets: EdgeInsets.zero,
onSelected: (action) {
if (action != null) onChanged(action);
},
dropdownMenuEntries: PenButtonAction.values
.map(
(a) => DropdownMenuEntry(value: a, label: _label(a)),
)
.toList(),
);
}
}
// ── Slider with live value label ─────────────────────────────────────────────
class _SliderTile extends StatelessWidget {
const _SliderTile({
required this.label,
required this.value,
required this.min,
required this.max,
required this.divisions,
required this.formatValue,
required this.onChanged,
});
final String label;
final double value;
final double min;
final double max;
final int divisions;
final String Function(double) formatValue;
final ValueChanged<double> onChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.w500)),
Text(
formatValue(value),
style: TextStyle(
fontFamily: 'monospace',
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
],
),
Slider(
value: value.clamp(min, max),
min: min,
max: max,
divisions: divisions,
label: formatValue(value),
onChanged: onChanged,
),
],
);
}
}

View File

@@ -0,0 +1,235 @@
// lib/editor/ui/thumbnail_grid.dart
//
// Drawboard-style page thumbnail grid for BadNote.
// Shows lazy GridView of PDF page thumbnails via PdfPageView.
// Used as a modal bottom sheet via showPageThumbnailSheet().
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:pdfrx/pdfrx.dart';
/// A lazy grid of page thumbnails for a PDF document.
///
/// [document] — the open PdfDocument.
/// [currentPage] — 0-based index of the currently active page.
/// [onPageSelected] — called with the 0-based page index when the user taps a thumbnail.
class PageThumbnailGrid extends StatefulWidget {
const PageThumbnailGrid({
super.key,
required this.document,
required this.currentPage,
required this.onPageSelected,
});
final PdfDocument document;
final int currentPage;
final ValueChanged<int> onPageSelected;
@override
State<PageThumbnailGrid> createState() => _PageThumbnailGridState();
}
class _PageThumbnailGridState extends State<PageThumbnailGrid> {
late final ScrollController _scrollController;
/// Approximate tile height used for the initial jump calculation.
/// The grid uses a cross-axis count of 3, so tile width ≈ screenWidth/3.
/// We rely on a fixed thumbnail width of ~120 logical pixels so the
/// aspect ratio (A4 ≈ 1:√2) gives us roughly 170 px tall per tile.
static const double _tileHeightEstimate = 170.0;
static const double _crossAxisCount = 3;
static const double _thumbnailWidth = 120.0;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
SchedulerBinding.instance.addPostFrameCallback((_) => _jumpToCurrentPage());
}
@override
void didUpdateWidget(covariant PageThumbnailGrid oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentPage != widget.currentPage) {
SchedulerBinding.instance.addPostFrameCallback((_) => _jumpToCurrentPage());
}
}
void _jumpToCurrentPage() {
if (!_scrollController.hasClients) return;
final row = widget.currentPage ~/ _crossAxisCount.toInt();
final offset = row * _tileHeightEstimate;
final maxOffset = _scrollController.position.maxScrollExtent;
_scrollController.jumpTo(offset.clamp(0.0, maxOffset));
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pageCount = widget.document.pages.length;
return GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _thumbnailWidth + 24,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: _thumbnailWidth / _tileHeightEstimate,
),
itemCount: pageCount,
itemBuilder: (context, index) {
return _ThumbnailTile(
document: widget.document,
pageIndex: index,
isSelected: index == widget.currentPage,
onTap: () => widget.onPageSelected(index),
);
},
);
}
}
class _ThumbnailTile extends StatelessWidget {
const _ThumbnailTile({
required this.document,
required this.pageIndex,
required this.isSelected,
required this.onTap,
});
final PdfDocument document;
final int pageIndex;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: isSelected ? colorScheme.secondaryContainer : colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? colorScheme.primary : Colors.transparent,
width: 2,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: PdfPageView(
document: document,
pageNumber: pageIndex + 1, // PdfPageView is 1-based
backgroundColor: colorScheme.surface,
decoration: const BoxDecoration(color: Colors.white),
),
),
Container(
padding: const EdgeInsets.symmetric(vertical: 4),
color: isSelected ? colorScheme.secondaryContainer : colorScheme.surfaceContainerHigh,
child: Text(
'${pageIndex + 1}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: isSelected ? colorScheme.onSecondaryContainer : colorScheme.onSurfaceVariant,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
),
],
),
),
),
);
}
}
/// Shows a Material You modal bottom sheet containing a [PageThumbnailGrid].
///
/// [document] — the open PdfDocument.
/// [currentPage] — 0-based index of the currently active page.
/// [onPageSelected] — called with the 0-based page index when the user selects
/// a thumbnail; the sheet is automatically dismissed afterwards.
Future<void> showPageThumbnailSheet(
BuildContext context, {
required PdfDocument document,
required int currentPage,
required ValueChanged<int> onPageSelected,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) {
return DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.4,
maxChildSize: 0.95,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
// Drag handle
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Theme.of(sheetContext).colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
// Header row
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 8, 8),
child: Row(
children: [
Text(
'Pages',
style: Theme.of(sheetContext).textTheme.titleMedium,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Close',
onPressed: () => Navigator.of(sheetContext).pop(),
),
],
),
),
const Divider(height: 1),
// Thumbnail grid — fill remaining height
Expanded(
child: PageThumbnailGrid(
document: document,
currentPage: currentPage,
onPageSelected: (index) {
Navigator.of(sheetContext).pop();
onPageSelected(index);
},
),
),
],
);
},
);
},
);
}

254
lib/l10n/app_en.arb Normal file
View File

@@ -0,0 +1,254 @@
{
"@@locale": "en",
"appTitle": "BadNote",
"settings": "Settings",
"search": "Search",
"importPdf": "Import PDF",
"importPpt": "Import PPT",
"importFile": "Import file",
"createNotebook": "Create notebook",
"newNotebookTitle": "New notebook",
"notebookTitleHint": "Notebook title",
"create": "Create",
"untitledNote": "Untitled",
"noNotesYetHint": "No ink notes yet — tap + to create one",
"noDocumentsYet": "No documents yet — tap Import file",
"processingImport": "Importing…",
"importFailed": "Couldn't import that file: {error}",
"@importFailed": {
"placeholders": { "error": { "type": "String" } }
},
"convertNeedsLibreOffice": "Importing Word documents needs LibreOffice installed. Convert to PDF first, or install LibreOffice.",
"unsupportedFileType": "Unsupported file type: {ext}",
"@unsupportedFileType": {
"placeholders": { "ext": { "type": "String" } }
},
"penCanvasBeta": "Pen Canvas (beta)",
"newNote": "New Note",
"open": "Open",
"cancel": "Cancel",
"delete": "Delete",
"deleteNoteTitle": "Delete note?",
"deleteNote": "Delete note",
"openInSplitView": "Open in Split View",
"splitViewSubtitle": "PDF reference + scratchpad",
"removeDocument": "Remove document",
"ok": "OK",
"pickColor": "Pick a color",
"clearSettingsTitle": "Clear all local settings?",
"clear": "Clear",
"settingsReset": "Settings reset to defaults",
"themeSystem": "System",
"themeLight": "Light",
"themeDark": "Dark",
"seedColorDesc": "Seed color for Material 3 theme",
"searchHint": "Search notes and documents...",
"searchError": "Search error: {error}",
"@searchError": {
"placeholders": { "error": { "type": "String" } }
},
"noResultsFor": "No results for \"{query}\"",
"@noResultsFor": {
"placeholders": { "query": { "type": "String" } }
},
"typeToSearch": "Type to search your notes and documents",
"sectionNotes": "Notes",
"sectionDocuments": "Documents",
"pageLabel": "Page {page}",
"@pageLabel": {
"placeholders": { "page": { "type": "int" } }
},
"processingPptx": "Processing PPTX...",
"processingPresentation": "Processing presentation...",
"couldNotOpenPresentation": "Could not open presentation.",
"toolPen": "Pen",
"toolHighlighter": "Highlighter",
"toolEraser": "Eraser",
"brushPicker": "Brush",
"brushFountainPen": "Fountain pen",
"brushBallpoint": "Ballpoint",
"brushPencil": "Pencil",
"brushHighlighter": "Highlighter",
"toolSelect": "Select",
"toolShape": "Shape",
"shapePicker": "Shape",
"shapeLine": "Line",
"shapeRectangle": "Rectangle",
"shapeEllipse": "Ellipse",
"shapeArrow": "Arrow",
"actionDeleteSelection": "Delete selection",
"actionUndo": "Undo",
"actionRedo": "Redo",
"fingerDrawingOn": "Finger drawing ON",
"fingerDrawingOff": "Finger drawing OFF (pen only)",
"pages": "Pages",
"penSettings": "Pen settings",
"inputDiagnostic": "Input diagnostic (writes a log file)",
"back": "Back",
"previousPage": "Previous page",
"nextPage": "Next page",
"toolSelectText": "Select text",
"actionHighlightSelection": "Highlight selection",
"toolRemoveHighlight": "Remove highlight (tap a highlight)",
"toolPlaceScratchLink": "Place scratch link",
"toolText": "Text (tap or double-click to add)",
"textPlaceholder": "Type…",
"scratchLinkDeleteTitle": "Delete scratch link?",
"scratchLinkDeleteBody": "This removes the anchor and its private scratchpad.",
"toolAddBookmark": "Add bookmark (here or at selection)",
"toolBookmarks": "Bookmarks",
"bookmarksTitle": "Bookmarks",
"bookmarksEmpty": "No bookmarks yet.",
"bookmarkDefaultLabel": "Page {page}",
"@bookmarkDefaultLabel": {
"placeholders": { "page": { "type": "int" } }
},
"bookmarkPageLabel": "Page {page}",
"@bookmarkPageLabel": {
"placeholders": { "page": { "type": "int" } }
},
"bookmarkDeleteTitle": "Delete bookmark?",
"bookmarkDeleteBody": "This removes the saved location.",
"failedToOpenPdf": "Failed to open PDF:\n{error}",
"@failedToOpenPdf": {
"placeholders": { "error": { "type": "String" } }
},
"pdfNoPages": "PDF has no pages.",
"pageOfPages": "{current} / {total}",
"@pageOfPages": {
"placeholders": {
"current": { "type": "int" },
"total": { "type": "int" }
}
},
"libraryTab": "Library",
"boardTab": "Stickies",
"shellTagline": "Ink · Annotate · Know",
"notesSection": "Notes",
"documentsSection": "Documents",
"emptyLibraryTitle": "Nothing here yet",
"emptyLibraryBody": "Create a note, or import PDF / PPT / Word",
"diagnosticsSection": "Diagnostics",
"diagnosticsExport": "Export diagnostic pack",
"diagnosticsExportHint": "Reproduce on Surface, export, and send the zip back",
"diagnosticsToggle": "Input diagnostics overlay",
"penSettingsUnified": "Pen & ink",
"board": "Board",
"boardTitle": "Sticky Board",
"boardOpen": "Sticky note board",
"boardAddCard": "Add card",
"boardNewCardText": "New note",
"boardDeleteCard": "Delete card",
"boardDeleteCardTitle": "Delete this card?",
"boardBacklinks": "Linked from",
"boardNoBacklinks": "Nothing links here yet",
"boardDanglingLink": "No card named \"{target}\"",
"@boardDanglingLink": {
"placeholders": { "target": { "type": "String" } }
},
"close": "Close",
"vaultSetupTitle": "Choose your vault",
"vaultSetupHeadline": "Pick a folder for your notebooks",
"vaultSetupBody": "BadNote stores your notebooks inside one folder you choose — like an Obsidian vault. Pick a folder you control (e.g. a synced folder) so your notes travel with their files.",
"vaultChooseFolder": "Choose folder",
"vaultMissingTitle": "Your vault folder is missing",
"vaultMissingBody": "The folder you picked can't be found (it may have been moved, deleted, or on a drive that's unplugged). Relocate it or pick a new one.",
"vaultPickFailed": "Couldn't open the folder picker: {error}",
"@vaultPickFailed": {
"placeholders": { "error": { "type": "String" } }
},
"vaultNotWritable": "That folder isn't writable. Please choose another.",
"vaultSection": "Vault",
"vaultFolderLabel": "Vault folder",
"vaultNoneSelected": "No folder selected",
"vaultChangeFolder": "Change vault folder",
"vaultUpdated": "Vault folder updated",
"syncSection": "Sync (WebDAV)",
"syncServerUrl": "Server URL",
"syncServerUrlHint": "https://dav.example.com/remote.php/dav/files/me",
"syncUsername": "Username",
"syncPassword": "Password",
"syncRemoteFolder": "Remote folder",
"syncRemoteFolderHint": "BadNote",
"syncSave": "Save",
"syncSaved": "Sync settings saved",
"syncTestConnection": "Test connection",
"syncTestOk": "Connection OK",
"syncTestFailed": "Connection failed: {error}",
"@syncTestFailed": {
"placeholders": { "error": { "type": "String" } }
},
"syncNow": "Sync now",
"syncRunning": "Syncing…",
"syncNeverRun": "Never synced",
"syncLastRun": "Last synced: {when}",
"@syncLastRun": {
"placeholders": { "when": { "type": "String" } }
},
"syncResultSummary": "{uploaded} uploaded · {downloaded} downloaded · {conflicts} conflicts",
"@syncResultSummary": {
"placeholders": {
"uploaded": { "type": "int" },
"downloaded": { "type": "int" },
"conflicts": { "type": "int" }
}
},
"syncFailed": "Sync failed: {error}",
"@syncFailed": {
"placeholders": { "error": { "type": "String" } }
},
"syncAuto": "Sync automatically on launch",
"syncCredentialsNote": "Credentials are stored locally in plain text. Use a dedicated app password.",
"syncNotConfigured": "Enter a server URL to enable sync.",
"settingsDefaults": "Defaults",
"settingsAppearance": "Appearance",
"settingsAbout": "About",
"settingsDefaultTool": "Default tool",
"settingsDefaultColor": "Default color",
"settingsDefaultWidth": "Default stroke width",
"settingsPressureCurve": "Pressure curve",
"settingsClearConfirmBody": "This resets pen defaults and appearance. Notes and documents are not affected.",
"serverSection": "BadNote Server",
"serverUrl": "Server URL",
"serverUrlHint": "http://192.168.1.10:8080",
"serverUsername": "Username",
"serverPassword": "Password",
"serverSave": "Save & sign in",
"serverTest": "Test connection",
"serverTestOk": "Connected · API {version}",
"@serverTestOk": {
"placeholders": { "version": { "type": "String" } }
},
"serverTestFail": "Connection failed: {error}",
"@serverTestFail": {
"placeholders": { "error": { "type": "String" } }
},
"serverLoggedIn": "Signed in",
"serverHint": "Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.",
"boardEmptyTitle": "No sticky notes yet",
"boardEmptyBody": "Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.",
"relativeJustNow": "Just now",
"relativeMinutesAgo": "{n}m ago",
"@relativeMinutesAgo": { "placeholders": { "n": { "type": "int" } } },
"relativeHoursAgo": "{n}h ago",
"@relativeHoursAgo": { "placeholders": { "n": { "type": "int" } } },
"relativeYesterday": "Yesterday",
"diagExported": "Diagnostic pack exported ({bytes} bytes)\nPath copied",
"@diagExported": { "placeholders": { "bytes": { "type": "int" } } },
"diagExportFail": "Export failed: {error}",
"@diagExportFail": { "placeholders": { "error": { "type": "String" } } },
"processingOcr": "Processing OCR…",
"notebooksSection": "Notebooks",
"addBlankPage": "Blank page",
"importIntoNotebook": "Import into notebook",
"notebookMembersEmpty": "No pages yet",
"memberCount": "{count} items",
"@memberCount": {
"placeholders": { "count": { "type": "int" } }
},
"textFontSmall": "S",
"textFontMedium": "M",
"textFontLarge": "L",
"textBold": "Bold",
"textDragHint": "Drag to move"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,623 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appTitle => 'BadNote';
@override
String get settings => 'Settings';
@override
String get search => 'Search';
@override
String get importPdf => 'Import PDF';
@override
String get importPpt => 'Import PPT';
@override
String get importFile => 'Import file';
@override
String get createNotebook => 'Create notebook';
@override
String get newNotebookTitle => 'New notebook';
@override
String get notebookTitleHint => 'Notebook title';
@override
String get create => 'Create';
@override
String get untitledNote => 'Untitled';
@override
String get noNotesYetHint => 'No ink notes yet — tap + to create one';
@override
String get noDocumentsYet => 'No documents yet — tap Import file';
@override
String get processingImport => 'Importing…';
@override
String importFailed(String error) {
return 'Couldn\'t import that file: $error';
}
@override
String get convertNeedsLibreOffice =>
'Importing Word documents needs LibreOffice installed. Convert to PDF first, or install LibreOffice.';
@override
String unsupportedFileType(String ext) {
return 'Unsupported file type: $ext';
}
@override
String get penCanvasBeta => 'Pen Canvas (beta)';
@override
String get newNote => 'New Note';
@override
String get open => 'Open';
@override
String get cancel => 'Cancel';
@override
String get delete => 'Delete';
@override
String get deleteNoteTitle => 'Delete note?';
@override
String get deleteNote => 'Delete note';
@override
String get openInSplitView => 'Open in Split View';
@override
String get splitViewSubtitle => 'PDF reference + scratchpad';
@override
String get removeDocument => 'Remove document';
@override
String get ok => 'OK';
@override
String get pickColor => 'Pick a color';
@override
String get clearSettingsTitle => 'Clear all local settings?';
@override
String get clear => 'Clear';
@override
String get settingsReset => 'Settings reset to defaults';
@override
String get themeSystem => 'System';
@override
String get themeLight => 'Light';
@override
String get themeDark => 'Dark';
@override
String get seedColorDesc => 'Seed color for Material 3 theme';
@override
String get searchHint => 'Search notes and documents...';
@override
String searchError(String error) {
return 'Search error: $error';
}
@override
String noResultsFor(String query) {
return 'No results for \"$query\"';
}
@override
String get typeToSearch => 'Type to search your notes and documents';
@override
String get sectionNotes => 'Notes';
@override
String get sectionDocuments => 'Documents';
@override
String pageLabel(int page) {
return 'Page $page';
}
@override
String get processingPptx => 'Processing PPTX...';
@override
String get processingPresentation => 'Processing presentation...';
@override
String get couldNotOpenPresentation => 'Could not open presentation.';
@override
String get toolPen => 'Pen';
@override
String get toolHighlighter => 'Highlighter';
@override
String get toolEraser => 'Eraser';
@override
String get brushPicker => 'Brush';
@override
String get brushFountainPen => 'Fountain pen';
@override
String get brushBallpoint => 'Ballpoint';
@override
String get brushPencil => 'Pencil';
@override
String get brushHighlighter => 'Highlighter';
@override
String get toolSelect => 'Select';
@override
String get toolShape => 'Shape';
@override
String get shapePicker => 'Shape';
@override
String get shapeLine => 'Line';
@override
String get shapeRectangle => 'Rectangle';
@override
String get shapeEllipse => 'Ellipse';
@override
String get shapeArrow => 'Arrow';
@override
String get actionDeleteSelection => 'Delete selection';
@override
String get actionUndo => 'Undo';
@override
String get actionRedo => 'Redo';
@override
String get fingerDrawingOn => 'Finger drawing ON';
@override
String get fingerDrawingOff => 'Finger drawing OFF (pen only)';
@override
String get pages => 'Pages';
@override
String get penSettings => 'Pen settings';
@override
String get inputDiagnostic => 'Input diagnostic (writes a log file)';
@override
String get back => 'Back';
@override
String get previousPage => 'Previous page';
@override
String get nextPage => 'Next page';
@override
String get toolSelectText => 'Select text';
@override
String get actionHighlightSelection => 'Highlight selection';
@override
String get toolRemoveHighlight => 'Remove highlight (tap a highlight)';
@override
String get toolPlaceScratchLink => 'Place scratch link';
@override
String get toolText => 'Text (tap or double-click to add)';
@override
String get textPlaceholder => 'Type…';
@override
String get scratchLinkDeleteTitle => 'Delete scratch link?';
@override
String get scratchLinkDeleteBody =>
'This removes the anchor and its private scratchpad.';
@override
String get toolAddBookmark => 'Add bookmark (here or at selection)';
@override
String get toolBookmarks => 'Bookmarks';
@override
String get bookmarksTitle => 'Bookmarks';
@override
String get bookmarksEmpty => 'No bookmarks yet.';
@override
String bookmarkDefaultLabel(int page) {
return 'Page $page';
}
@override
String bookmarkPageLabel(int page) {
return 'Page $page';
}
@override
String get bookmarkDeleteTitle => 'Delete bookmark?';
@override
String get bookmarkDeleteBody => 'This removes the saved location.';
@override
String failedToOpenPdf(String error) {
return 'Failed to open PDF:\n$error';
}
@override
String get pdfNoPages => 'PDF has no pages.';
@override
String pageOfPages(int current, int total) {
return '$current / $total';
}
@override
String get libraryTab => 'Library';
@override
String get boardTab => 'Stickies';
@override
String get shellTagline => 'Ink · Annotate · Know';
@override
String get notesSection => 'Notes';
@override
String get documentsSection => 'Documents';
@override
String get emptyLibraryTitle => 'Nothing here yet';
@override
String get emptyLibraryBody => 'Create a note, or import PDF / PPT / Word';
@override
String get diagnosticsSection => 'Diagnostics';
@override
String get diagnosticsExport => 'Export diagnostic pack';
@override
String get diagnosticsExportHint =>
'Reproduce on Surface, export, and send the zip back';
@override
String get diagnosticsToggle => 'Input diagnostics overlay';
@override
String get penSettingsUnified => 'Pen & ink';
@override
String get board => 'Board';
@override
String get boardTitle => 'Sticky Board';
@override
String get boardOpen => 'Sticky note board';
@override
String get boardAddCard => 'Add card';
@override
String get boardNewCardText => 'New note';
@override
String get boardDeleteCard => 'Delete card';
@override
String get boardDeleteCardTitle => 'Delete this card?';
@override
String get boardBacklinks => 'Linked from';
@override
String get boardNoBacklinks => 'Nothing links here yet';
@override
String boardDanglingLink(String target) {
return 'No card named \"$target\"';
}
@override
String get close => 'Close';
@override
String get vaultSetupTitle => 'Choose your vault';
@override
String get vaultSetupHeadline => 'Pick a folder for your notebooks';
@override
String get vaultSetupBody =>
'BadNote stores your notebooks inside one folder you choose — like an Obsidian vault. Pick a folder you control (e.g. a synced folder) so your notes travel with their files.';
@override
String get vaultChooseFolder => 'Choose folder';
@override
String get vaultMissingTitle => 'Your vault folder is missing';
@override
String get vaultMissingBody =>
'The folder you picked can\'t be found (it may have been moved, deleted, or on a drive that\'s unplugged). Relocate it or pick a new one.';
@override
String vaultPickFailed(String error) {
return 'Couldn\'t open the folder picker: $error';
}
@override
String get vaultNotWritable =>
'That folder isn\'t writable. Please choose another.';
@override
String get vaultSection => 'Vault';
@override
String get vaultFolderLabel => 'Vault folder';
@override
String get vaultNoneSelected => 'No folder selected';
@override
String get vaultChangeFolder => 'Change vault folder';
@override
String get vaultUpdated => 'Vault folder updated';
@override
String get syncSection => 'Sync (WebDAV)';
@override
String get syncServerUrl => 'Server URL';
@override
String get syncServerUrlHint =>
'https://dav.example.com/remote.php/dav/files/me';
@override
String get syncUsername => 'Username';
@override
String get syncPassword => 'Password';
@override
String get syncRemoteFolder => 'Remote folder';
@override
String get syncRemoteFolderHint => 'BadNote';
@override
String get syncSave => 'Save';
@override
String get syncSaved => 'Sync settings saved';
@override
String get syncTestConnection => 'Test connection';
@override
String get syncTestOk => 'Connection OK';
@override
String syncTestFailed(String error) {
return 'Connection failed: $error';
}
@override
String get syncNow => 'Sync now';
@override
String get syncRunning => 'Syncing…';
@override
String get syncNeverRun => 'Never synced';
@override
String syncLastRun(String when) {
return 'Last synced: $when';
}
@override
String syncResultSummary(int uploaded, int downloaded, int conflicts) {
return '$uploaded uploaded · $downloaded downloaded · $conflicts conflicts';
}
@override
String syncFailed(String error) {
return 'Sync failed: $error';
}
@override
String get syncAuto => 'Sync automatically on launch';
@override
String get syncCredentialsNote =>
'Credentials are stored locally in plain text. Use a dedicated app password.';
@override
String get syncNotConfigured => 'Enter a server URL to enable sync.';
@override
String get settingsDefaults => 'Defaults';
@override
String get settingsAppearance => 'Appearance';
@override
String get settingsAbout => 'About';
@override
String get settingsDefaultTool => 'Default tool';
@override
String get settingsDefaultColor => 'Default color';
@override
String get settingsDefaultWidth => 'Default stroke width';
@override
String get settingsPressureCurve => 'Pressure curve';
@override
String get settingsClearConfirmBody =>
'This resets pen defaults and appearance. Notes and documents are not affected.';
@override
String get serverSection => 'BadNote Server';
@override
String get serverUrl => 'Server URL';
@override
String get serverUrlHint => 'http://192.168.1.10:8080';
@override
String get serverUsername => 'Username';
@override
String get serverPassword => 'Password';
@override
String get serverSave => 'Save & sign in';
@override
String get serverTest => 'Test connection';
@override
String serverTestOk(String version) {
return 'Connected · API $version';
}
@override
String serverTestFail(String error) {
return 'Connection failed: $error';
}
@override
String get serverLoggedIn => 'Signed in';
@override
String get serverHint =>
'Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.';
@override
String get boardEmptyTitle => 'No sticky notes yet';
@override
String get boardEmptyBody =>
'Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.';
@override
String get relativeJustNow => 'Just now';
@override
String relativeMinutesAgo(int n) {
return '${n}m ago';
}
@override
String relativeHoursAgo(int n) {
return '${n}h ago';
}
@override
String get relativeYesterday => 'Yesterday';
@override
String diagExported(int bytes) {
return 'Diagnostic pack exported ($bytes bytes)\nPath copied';
}
@override
String diagExportFail(String error) {
return 'Export failed: $error';
}
@override
String get processingOcr => 'Processing OCR…';
@override
String get notebooksSection => 'Notebooks';
@override
String get addBlankPage => 'Blank page';
@override
String get importIntoNotebook => 'Import into notebook';
@override
String get notebookMembersEmpty => 'No pages yet';
@override
String memberCount(int count) {
return '$count items';
}
@override
String get textFontSmall => 'S';
@override
String get textFontMedium => 'M';
@override
String get textFontLarge => 'L';
@override
String get textBold => 'Bold';
@override
String get textDragHint => 'Drag to move';
}

View File

@@ -0,0 +1,615 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Chinese (`zh`).
class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
@override
String get appTitle => 'BadNote';
@override
String get settings => '设置';
@override
String get search => '搜索';
@override
String get importPdf => '导入 PDF';
@override
String get importPpt => '导入 PPT';
@override
String get importFile => '导入文件';
@override
String get createNotebook => '新建笔记本';
@override
String get newNotebookTitle => '新建笔记本';
@override
String get notebookTitleHint => '笔记本标题';
@override
String get create => '创建';
@override
String get untitledNote => '未命名';
@override
String get noNotesYetHint => '还没有手写笔记——点按 + 新建';
@override
String get noDocumentsYet => '暂无文档——点按“导入文件”';
@override
String get processingImport => '正在导入…';
@override
String importFailed(String error) {
return '无法导入该文件:$error';
}
@override
String get convertNeedsLibreOffice =>
'导入 Word 文档需要安装 LibreOffice。请先转换为 PDF或安装 LibreOffice。';
@override
String unsupportedFileType(String ext) {
return '不支持的文件类型:$ext';
}
@override
String get penCanvasBeta => '手写画布(测试版)';
@override
String get newNote => '新建笔记';
@override
String get open => '打开';
@override
String get cancel => '取消';
@override
String get delete => '删除';
@override
String get deleteNoteTitle => '删除笔记?';
@override
String get deleteNote => '删除笔记';
@override
String get openInSplitView => '分屏打开';
@override
String get splitViewSubtitle => 'PDF 参考 + 草稿纸';
@override
String get removeDocument => '移除文档';
@override
String get ok => '确定';
@override
String get pickColor => '选择颜色';
@override
String get clearSettingsTitle => '清除所有本地设置?';
@override
String get clear => '清除';
@override
String get settingsReset => '设置已恢复默认';
@override
String get themeSystem => '跟随系统';
@override
String get themeLight => '浅色';
@override
String get themeDark => '深色';
@override
String get seedColorDesc => 'Material 3 主题种子色';
@override
String get searchHint => '搜索笔记和文档…';
@override
String searchError(String error) {
return '搜索出错:$error';
}
@override
String noResultsFor(String query) {
return '没有“$query”的结果';
}
@override
String get typeToSearch => '输入以搜索你的笔记和文档';
@override
String get sectionNotes => '笔记';
@override
String get sectionDocuments => '文档';
@override
String pageLabel(int page) {
return '$page';
}
@override
String get processingPptx => '正在处理 PPTX…';
@override
String get processingPresentation => '正在处理演示文稿…';
@override
String get couldNotOpenPresentation => '无法打开演示文稿。';
@override
String get toolPen => '钢笔';
@override
String get toolHighlighter => '荧光笔';
@override
String get toolEraser => '橡皮擦';
@override
String get brushPicker => '笔刷';
@override
String get brushFountainPen => '钢笔';
@override
String get brushBallpoint => '圆珠笔';
@override
String get brushPencil => '铅笔';
@override
String get brushHighlighter => '荧光笔';
@override
String get toolSelect => '选择';
@override
String get toolShape => '形状';
@override
String get shapePicker => '形状';
@override
String get shapeLine => '直线';
@override
String get shapeRectangle => '矩形';
@override
String get shapeEllipse => '椭圆';
@override
String get shapeArrow => '箭头';
@override
String get actionDeleteSelection => '删除所选';
@override
String get actionUndo => '撤销';
@override
String get actionRedo => '重做';
@override
String get fingerDrawingOn => '手指书写:开';
@override
String get fingerDrawingOff => '手指书写:关(仅手写笔)';
@override
String get pages => '页面';
@override
String get penSettings => '手写笔设置';
@override
String get inputDiagnostic => '输入诊断(写入日志文件)';
@override
String get back => '返回';
@override
String get previousPage => '上一页';
@override
String get nextPage => '下一页';
@override
String get toolSelectText => '选择文字';
@override
String get actionHighlightSelection => '高亮所选';
@override
String get toolRemoveHighlight => '移除高亮(点按高亮处)';
@override
String get toolPlaceScratchLink => '放置便签链接';
@override
String get toolText => '文字(点按或双击添加)';
@override
String get textPlaceholder => '输入文字…';
@override
String get scratchLinkDeleteTitle => '删除便签链接?';
@override
String get scratchLinkDeleteBody => '这会移除锚点及其专属草稿纸。';
@override
String get toolAddBookmark => '添加书签(当前位置或所选段落)';
@override
String get toolBookmarks => '书签';
@override
String get bookmarksTitle => '书签';
@override
String get bookmarksEmpty => '还没有书签。';
@override
String bookmarkDefaultLabel(int page) {
return '$page';
}
@override
String bookmarkPageLabel(int page) {
return '$page';
}
@override
String get bookmarkDeleteTitle => '删除书签?';
@override
String get bookmarkDeleteBody => '这会移除保存的位置。';
@override
String failedToOpenPdf(String error) {
return '打开 PDF 失败:\n$error';
}
@override
String get pdfNoPages => 'PDF 没有任何页面。';
@override
String pageOfPages(int current, int total) {
return '$current / $total';
}
@override
String get libraryTab => '';
@override
String get boardTab => '便利贴';
@override
String get shellTagline => '手写 · 批注 · 知识';
@override
String get notesSection => '笔记';
@override
String get documentsSection => '文档';
@override
String get emptyLibraryTitle => '还没有内容';
@override
String get emptyLibraryBody => '新建笔记,或导入 PDF / PPT / Word';
@override
String get diagnosticsSection => '诊断';
@override
String get diagnosticsExport => '导出诊断包';
@override
String get diagnosticsExportHint => '在 Surface 上复现问题后导出,发回给开发者分析';
@override
String get diagnosticsToggle => '输入诊断叠加层';
@override
String get penSettingsUnified => '笔与墨迹';
@override
String get board => '便利贴板';
@override
String get boardTitle => '便利贴板';
@override
String get boardOpen => '便利贴板';
@override
String get boardAddCard => '添加便利贴';
@override
String get boardNewCardText => '新便利贴';
@override
String get boardDeleteCard => '删除便利贴';
@override
String get boardDeleteCardTitle => '删除这张便利贴?';
@override
String get boardBacklinks => '哪些链接到这里';
@override
String get boardNoBacklinks => '暂无其他便利贴链接到这里';
@override
String boardDanglingLink(String target) {
return '没有名为“$target”的便利贴';
}
@override
String get close => '关闭';
@override
String get vaultSetupTitle => '选择笔记库';
@override
String get vaultSetupHeadline => '为你的笔记本选择一个文件夹';
@override
String get vaultSetupBody =>
'BadNote 会把你的笔记本都存放在你选择的一个文件夹里——就像 Obsidian 的库vault。请选择一个你能掌控的文件夹例如同步盘这样你的笔记会跟着文件一起走。';
@override
String get vaultChooseFolder => '选择文件夹';
@override
String get vaultMissingTitle => '笔记库文件夹不见了';
@override
String get vaultMissingBody => '找不到你选择的文件夹(可能被移动、删除,或所在磁盘已拔出)。请重新定位或另选一个。';
@override
String vaultPickFailed(String error) {
return '无法打开文件夹选择器:$error';
}
@override
String get vaultNotWritable => '该文件夹不可写,请另选一个。';
@override
String get vaultSection => '笔记库';
@override
String get vaultFolderLabel => '笔记库文件夹';
@override
String get vaultNoneSelected => '尚未选择文件夹';
@override
String get vaultChangeFolder => '更改笔记库文件夹';
@override
String get vaultUpdated => '笔记库文件夹已更新';
@override
String get syncSection => '同步WebDAV';
@override
String get syncServerUrl => '服务器地址';
@override
String get syncServerUrlHint =>
'https://dav.example.com/remote.php/dav/files/me';
@override
String get syncUsername => '用户名';
@override
String get syncPassword => '密码';
@override
String get syncRemoteFolder => '远程文件夹';
@override
String get syncRemoteFolderHint => 'BadNote';
@override
String get syncSave => '保存';
@override
String get syncSaved => '同步设置已保存';
@override
String get syncTestConnection => '测试连接';
@override
String get syncTestOk => '连接成功';
@override
String syncTestFailed(String error) {
return '连接失败:$error';
}
@override
String get syncNow => '立即同步';
@override
String get syncRunning => '同步中…';
@override
String get syncNeverRun => '尚未同步';
@override
String syncLastRun(String when) {
return '上次同步:$when';
}
@override
String syncResultSummary(int uploaded, int downloaded, int conflicts) {
return '上传 $uploaded · 下载 $downloaded · 冲突 $conflicts';
}
@override
String syncFailed(String error) {
return '同步失败:$error';
}
@override
String get syncAuto => '启动时自动同步';
@override
String get syncCredentialsNote => '凭据以明文保存在本地,建议使用专用的应用密码。';
@override
String get syncNotConfigured => '请输入服务器地址以启用同步。';
@override
String get settingsDefaults => '默认笔迹';
@override
String get settingsAppearance => '外观';
@override
String get settingsAbout => '关于';
@override
String get settingsDefaultTool => '默认工具';
@override
String get settingsDefaultColor => '默认颜色';
@override
String get settingsDefaultWidth => '默认线宽';
@override
String get settingsPressureCurve => '压感曲线';
@override
String get settingsClearConfirmBody => '将重置笔默认值与外观设置。笔记和文档不会受影响。';
@override
String get serverSection => 'BadNote 服务器';
@override
String get serverUrl => '服务器地址';
@override
String get serverUrlHint => 'http://192.168.1.10:8080';
@override
String get serverUsername => '用户名';
@override
String get serverPassword => '密码';
@override
String get serverSave => '保存并登录';
@override
String get serverTest => '测试连接';
@override
String serverTestOk(String version) {
return '连接成功 · API $version';
}
@override
String serverTestFail(String error) {
return '连接失败:$error';
}
@override
String get serverLoggedIn => '已登录';
@override
String get serverHint => '可选。用于自托管 vault 协助同步与延迟 OCR日常笔记仍完全离线。';
@override
String get boardEmptyTitle => '还没有便利贴';
@override
String get boardEmptyBody => '点按右下角添加卡片。在正文写 [[另一张卡片id]] 可建立双链。';
@override
String get relativeJustNow => '刚刚';
@override
String relativeMinutesAgo(int n) {
return '$n 分钟前';
}
@override
String relativeHoursAgo(int n) {
return '$n 小时前';
}
@override
String get relativeYesterday => '昨天';
@override
String diagExported(int bytes) {
return '诊断包已导出($bytes 字节)\n路径已复制';
}
@override
String diagExportFail(String error) {
return '导出失败:$error';
}
@override
String get processingOcr => '正在识别文字…';
@override
String get notebooksSection => '笔记本';
@override
String get addBlankPage => '空白页';
@override
String get importIntoNotebook => '导入到笔记本';
@override
String get notebookMembersEmpty => '还没有页面';
@override
String memberCount(int count) {
return '$count';
}
@override
String get textFontSmall => '';
@override
String get textFontMedium => '';
@override
String get textFontLarge => '';
@override
String get textBold => '粗体';
@override
String get textDragHint => '拖动移动';
}

224
lib/l10n/app_zh.arb Normal file
View File

@@ -0,0 +1,224 @@
{
"@@locale": "zh",
"appTitle": "BadNote",
"settings": "设置",
"search": "搜索",
"importPdf": "导入 PDF",
"importPpt": "导入 PPT",
"importFile": "导入文件",
"createNotebook": "新建笔记本",
"newNotebookTitle": "新建笔记本",
"notebookTitleHint": "笔记本标题",
"create": "创建",
"untitledNote": "未命名",
"noNotesYetHint": "还没有手写笔记——点按 + 新建",
"noDocumentsYet": "暂无文档——点按“导入文件”",
"processingImport": "正在导入…",
"importFailed": "无法导入该文件:{error}",
"convertNeedsLibreOffice": "导入 Word 文档需要安装 LibreOffice。请先转换为 PDF或安装 LibreOffice。",
"unsupportedFileType": "不支持的文件类型:{ext}",
"penCanvasBeta": "手写画布(测试版)",
"newNote": "新建笔记",
"open": "打开",
"cancel": "取消",
"delete": "删除",
"deleteNoteTitle": "删除笔记?",
"deleteNote": "删除笔记",
"openInSplitView": "分屏打开",
"splitViewSubtitle": "PDF 参考 + 草稿纸",
"removeDocument": "移除文档",
"ok": "确定",
"pickColor": "选择颜色",
"clearSettingsTitle": "清除所有本地设置?",
"clear": "清除",
"settingsReset": "设置已恢复默认",
"themeSystem": "跟随系统",
"themeLight": "浅色",
"themeDark": "深色",
"seedColorDesc": "Material 3 主题种子色",
"searchHint": "搜索笔记和文档…",
"searchError": "搜索出错:{error}",
"noResultsFor": "没有“{query}”的结果",
"typeToSearch": "输入以搜索你的笔记和文档",
"sectionNotes": "笔记",
"sectionDocuments": "文档",
"pageLabel": "第 {page} 页",
"processingPptx": "正在处理 PPTX…",
"processingPresentation": "正在处理演示文稿…",
"couldNotOpenPresentation": "无法打开演示文稿。",
"toolPen": "钢笔",
"toolHighlighter": "荧光笔",
"toolEraser": "橡皮擦",
"brushPicker": "笔刷",
"brushFountainPen": "钢笔",
"brushBallpoint": "圆珠笔",
"brushPencil": "铅笔",
"brushHighlighter": "荧光笔",
"toolSelect": "选择",
"toolShape": "形状",
"shapePicker": "形状",
"shapeLine": "直线",
"shapeRectangle": "矩形",
"shapeEllipse": "椭圆",
"shapeArrow": "箭头",
"actionDeleteSelection": "删除所选",
"actionUndo": "撤销",
"actionRedo": "重做",
"fingerDrawingOn": "手指书写:开",
"fingerDrawingOff": "手指书写:关(仅手写笔)",
"pages": "页面",
"penSettings": "手写笔设置",
"inputDiagnostic": "输入诊断(写入日志文件)",
"back": "返回",
"previousPage": "上一页",
"nextPage": "下一页",
"toolSelectText": "选择文字",
"actionHighlightSelection": "高亮所选",
"toolRemoveHighlight": "移除高亮(点按高亮处)",
"toolPlaceScratchLink": "放置便签链接",
"toolText": "文字(点按或双击添加)",
"textPlaceholder": "输入文字…",
"scratchLinkDeleteTitle": "删除便签链接?",
"scratchLinkDeleteBody": "这会移除锚点及其专属草稿纸。",
"toolAddBookmark": "添加书签(当前位置或所选段落)",
"toolBookmarks": "书签",
"bookmarksTitle": "书签",
"bookmarksEmpty": "还没有书签。",
"bookmarkDefaultLabel": "第 {page} 页",
"@bookmarkDefaultLabel": {
"placeholders": { "page": { "type": "int" } }
},
"bookmarkPageLabel": "第 {page} 页",
"@bookmarkPageLabel": {
"placeholders": { "page": { "type": "int" } }
},
"bookmarkDeleteTitle": "删除书签?",
"bookmarkDeleteBody": "这会移除保存的位置。",
"failedToOpenPdf": "打开 PDF 失败:\n{error}",
"pdfNoPages": "PDF 没有任何页面。",
"pageOfPages": "{current} / {total}",
"libraryTab": "库",
"boardTab": "便利贴",
"shellTagline": "手写 · 批注 · 知识",
"notesSection": "笔记",
"documentsSection": "文档",
"emptyLibraryTitle": "还没有内容",
"emptyLibraryBody": "新建笔记,或导入 PDF / PPT / Word",
"diagnosticsSection": "诊断",
"diagnosticsExport": "导出诊断包",
"diagnosticsExportHint": "在 Surface 上复现问题后导出,发回给开发者分析",
"diagnosticsToggle": "输入诊断叠加层",
"penSettingsUnified": "笔与墨迹",
"board": "便利贴板",
"boardTitle": "便利贴板",
"boardOpen": "便利贴板",
"boardAddCard": "添加便利贴",
"boardNewCardText": "新便利贴",
"boardDeleteCard": "删除便利贴",
"boardDeleteCardTitle": "删除这张便利贴?",
"boardBacklinks": "哪些链接到这里",
"boardNoBacklinks": "暂无其他便利贴链接到这里",
"boardDanglingLink": "没有名为“{target}”的便利贴",
"close": "关闭",
"vaultSetupTitle": "选择笔记库",
"vaultSetupHeadline": "为你的笔记本选择一个文件夹",
"vaultSetupBody": "BadNote 会把你的笔记本都存放在你选择的一个文件夹里——就像 Obsidian 的库vault。请选择一个你能掌控的文件夹例如同步盘这样你的笔记会跟着文件一起走。",
"vaultChooseFolder": "选择文件夹",
"vaultMissingTitle": "笔记库文件夹不见了",
"vaultMissingBody": "找不到你选择的文件夹(可能被移动、删除,或所在磁盘已拔出)。请重新定位或另选一个。",
"vaultPickFailed": "无法打开文件夹选择器:{error}",
"vaultNotWritable": "该文件夹不可写,请另选一个。",
"vaultSection": "笔记库",
"vaultFolderLabel": "笔记库文件夹",
"vaultNoneSelected": "尚未选择文件夹",
"vaultChangeFolder": "更改笔记库文件夹",
"vaultUpdated": "笔记库文件夹已更新",
"syncSection": "同步WebDAV",
"syncServerUrl": "服务器地址",
"syncServerUrlHint": "https://dav.example.com/remote.php/dav/files/me",
"syncUsername": "用户名",
"syncPassword": "密码",
"syncRemoteFolder": "远程文件夹",
"syncRemoteFolderHint": "BadNote",
"syncSave": "保存",
"syncSaved": "同步设置已保存",
"syncTestConnection": "测试连接",
"syncTestOk": "连接成功",
"syncTestFailed": "连接失败:{error}",
"@syncTestFailed": {
"placeholders": { "error": { "type": "String" } }
},
"syncNow": "立即同步",
"syncRunning": "同步中…",
"syncNeverRun": "尚未同步",
"syncLastRun": "上次同步:{when}",
"@syncLastRun": {
"placeholders": { "when": { "type": "String" } }
},
"syncResultSummary": "上传 {uploaded} · 下载 {downloaded} · 冲突 {conflicts}",
"@syncResultSummary": {
"placeholders": {
"uploaded": { "type": "int" },
"downloaded": { "type": "int" },
"conflicts": { "type": "int" }
}
},
"syncFailed": "同步失败:{error}",
"@syncFailed": {
"placeholders": { "error": { "type": "String" } }
},
"syncAuto": "启动时自动同步",
"syncCredentialsNote": "凭据以明文保存在本地,建议使用专用的应用密码。",
"syncNotConfigured": "请输入服务器地址以启用同步。",
"settingsDefaults": "默认笔迹",
"settingsAppearance": "外观",
"settingsAbout": "关于",
"settingsDefaultTool": "默认工具",
"settingsDefaultColor": "默认颜色",
"settingsDefaultWidth": "默认线宽",
"settingsPressureCurve": "压感曲线",
"settingsClearConfirmBody": "将重置笔默认值与外观设置。笔记和文档不会受影响。",
"serverSection": "BadNote 服务器",
"serverUrl": "服务器地址",
"serverUrlHint": "http://192.168.1.10:8080",
"serverUsername": "用户名",
"serverPassword": "密码",
"serverSave": "保存并登录",
"serverTest": "测试连接",
"serverTestOk": "连接成功 · API {version}",
"@serverTestOk": {
"placeholders": { "version": { "type": "String" } }
},
"serverTestFail": "连接失败:{error}",
"@serverTestFail": {
"placeholders": { "error": { "type": "String" } }
},
"serverLoggedIn": "已登录",
"serverHint": "可选。用于自托管 vault 协助同步与延迟 OCR日常笔记仍完全离线。",
"boardEmptyTitle": "还没有便利贴",
"boardEmptyBody": "点按右下角添加卡片。在正文写 [[另一张卡片id]] 可建立双链。",
"relativeJustNow": "刚刚",
"relativeMinutesAgo": "{n} 分钟前",
"@relativeMinutesAgo": { "placeholders": { "n": { "type": "int" } } },
"relativeHoursAgo": "{n} 小时前",
"@relativeHoursAgo": { "placeholders": { "n": { "type": "int" } } },
"relativeYesterday": "昨天",
"diagExported": "诊断包已导出({bytes} 字节)\n路径已复制",
"@diagExported": { "placeholders": { "bytes": { "type": "int" } } },
"diagExportFail": "导出失败:{error}",
"@diagExportFail": { "placeholders": { "error": { "type": "String" } } },
"processingOcr": "正在识别文字…",
"notebooksSection": "笔记本",
"addBlankPage": "空白页",
"importIntoNotebook": "导入到笔记本",
"notebookMembersEmpty": "还没有页面",
"memberCount": "{count} 项",
"@memberCount": {
"placeholders": { "count": { "type": "int" } }
},
"textFontSmall": "小",
"textFontMedium": "中",
"textFontLarge": "大",
"textBold": "粗体",
"textDragHint": "拖动移动"
}

View File

@@ -1,20 +1,26 @@
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:pdfrx/pdfrx.dart'; import 'package:pdfrx/pdfrx.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'editor/pdf/pen_capture_region.dart'; import 'editor/pdf/pen_capture_region.dart';
import 'editor/persistence/sidecar_flush_observer.dart';
import 'theme/app_theme.dart';
import 'diagnostics/badnote_log.dart';
import 'l10n/app_localizations.dart';
import 'providers/settings_provider.dart'; import 'providers/settings_provider.dart';
import 'screens/home_screen.dart'; import 'screens/app_shell.dart';
import 'screens/vault_setup_screen.dart';
import 'services/database_service.dart'; import 'services/database_service.dart';
import 'services/vault_service.dart';
import 'services/webdav_sync_service.dart';
import 'storage/sqlite_to_sidecar_migrator.dart';
Future<void> main() async { Future<void> main() async {
// Kind-aware binding (extends WidgetsFlutterBinding) must be the active // Kind-gated PDF pen capture MUST install before runApp — without it
// binding before runApp so the M1 spike's PenCaptureRegion can gate // PenCaptureRegion.currentPointerKind stays null and stylus ink never hits.
// hit-testing by pointer kind. Safe for the rest of the app: with no pen
// region mounted it behaves exactly like the default binding.
PenCaptureBinding.ensureInitialized(); PenCaptureBinding.ensureInitialized();
// pdfrx native engine init (required before any PdfViewer is built). // pdfrx native engine init (required before any PdfViewer is built).
pdfrxFlutterInitialize(); pdfrxFlutterInitialize();
@@ -25,14 +31,40 @@ Future<void> main() async {
// Initialize SharedPreferences // Initialize SharedPreferences
await SharedPreferences.getInstance(); await SharedPreferences.getInstance();
// Always-on structured diagnostics (Surface remote debugging).
await BadNoteLog.instance.start();
BadNoteLog.instance.info(LogSubsystem.shell, 'app_start');
runApp(const ProviderScope(child: BadNoteApp())); runApp(const ProviderScope(child: BadNoteApp()));
} }
class BadNoteApp extends ConsumerWidget { class BadNoteApp extends ConsumerStatefulWidget {
const BadNoteApp({super.key}); const BadNoteApp({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<BadNoteApp> createState() => _BadNoteAppState();
}
class _BadNoteAppState extends ConsumerState<BadNoteApp> {
// Phase 6 / §F.3: flush any open sidecar repos when the app is suspended or
// closed so the last strokes are never lost to an OS kill. Lives for the whole
// app lifetime (attached here, detached on app teardown).
final SidecarFlushObserver _flushObserver = SidecarFlushObserver();
@override
void initState() {
super.initState();
_flushObserver.attach();
}
@override
void dispose() {
_flushObserver.detach();
super.dispose();
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
// Material You: prefer the OS dynamic color (Windows/Android system accent); // Material You: prefer the OS dynamic color (Windows/Android system accent);
@@ -52,19 +84,138 @@ class BadNoteApp extends ConsumerWidget {
return MaterialApp( return MaterialApp(
title: 'BadNote', title: 'BadNote',
themeMode: settings.themeMode, themeMode: settings.themeMode,
theme: _theme(lightScheme), theme: AppTheme.fromScheme(lightScheme),
darkTheme: _theme(darkScheme), darkTheme: AppTheme.fromScheme(darkScheme),
home: const HomeScreen(), // i18n: follows the OS language (en / zh) via the system locale.
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: const VaultGate(),
); );
}, },
); );
} }
}
ThemeData _theme(ColorScheme scheme) => ThemeData( /// Startup gate: shows [HomeScreen] only once a valid vault root folder has been
colorScheme: scheme, /// chosen. If none is set — or the saved folder no longer exists — it shows
useMaterial3: true, /// [VaultSetupScreen] first (re-prompting on a missing folder rather than
textTheme: GoogleFonts.interTextTheme( /// silently scattering data elsewhere). Phase 0: records the vault path only;
ThemeData(brightness: scheme.brightness).textTheme, /// editors still use SQLite.
class VaultGate extends StatefulWidget {
const VaultGate({super.key});
@override
State<VaultGate> createState() => _VaultGateState();
}
class _VaultGateState extends State<VaultGate> {
VaultService? _vault;
bool _valid = false;
bool _hadStoredPath = false;
bool _loading = true;
bool _migrating = false;
@override
void initState() {
super.initState();
_check();
}
Future<void> _check() async {
final vault = await VaultService.getInstance();
final valid = await vault.vaultRootValid();
if (!mounted) return;
setState(() {
_vault = vault;
_valid = valid;
// A stored-but-invalid path means the chosen folder went missing.
_hadStoredPath = (vault.vaultRoot?.isNotEmpty ?? false);
_loading = false;
});
if (valid) {
await _maybeMigrate(vault);
_maybeAutoSync(vault); // fire-and-forget; never blocks the UI
}
}
/// Optionally kick off a WebDAV sync on launch when the user has enabled
/// auto-sync (default OFF). Deliberately NON-blocking and failure-tolerant: a
/// bad config or offline server must never delay or crash startup. Results
/// are surfaced in Settings (last-synced time) rather than interrupting here.
Future<void> _maybeAutoSync(VaultService vault) async {
try {
final prefs = await SharedPreferences.getInstance();
final sync = WebDavSyncService(prefs);
final config = sync.config;
if (!config.autoSync || !config.isConfigured) return;
final root = vault.vaultRoot;
if (root == null || root.isEmpty) return;
final client = sync.buildClient();
if (client == null) return;
try {
await sync.syncNow(vaultRoot: root, client: client);
} finally {
client.close();
}
} catch (_) {
// Auto-sync is best-effort; swallow everything so launch is unaffected.
}
}
/// Run the one-time SQLite→sidecar migration ONCE per vault (Phase 5, §B).
/// Gated on [VaultService.vaultMigrationDone]; a fresh install (no legacy DB)
/// is a fast no-op. The live DB is first reopened at the vault cache location
/// so post-migration reads hit the new index, never the renamed legacy file.
Future<void> _maybeMigrate(VaultService vault) async {
// Move the live cache DB to the vault location now that the root is valid.
await DatabaseService.reopen();
if (vault.vaultMigrationDone) return;
if (mounted) setState(() => _migrating = true);
try {
await SqliteToSidecarMigrator(vault).run();
await vault.setVaultMigrationDone();
} catch (_) {
// A failed migration leaves the legacy DB intact (it is only renamed to
// `.premigration` after a successful pass) and the flag unset, so the
// next launch retries. Never block the user from reaching the app.
}
if (mounted) setState(() => _migrating = false);
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (_migrating) {
return const Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Migrating your notebooks…'),
],
),
), ),
); );
}
if (_valid) return const AppShell();
return VaultSetupScreen(
vaultService: _vault!,
missing: _hadStoredPath,
onVaultReady: () async {
if (_vault != null) await _maybeMigrate(_vault!);
if (mounted) setState(() => _valid = true);
},
);
}
} }

View File

@@ -3,15 +3,50 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'bookmark.freezed.dart'; part 'bookmark.freezed.dart';
part 'bookmark.g.dart'; part 'bookmark.g.dart';
/// A saved location in a document.
///
/// "Paragraph precision" (user ask: 精确到段落加书签) is expressed by the optional
/// in-page anchor fields below, all normalized to the page in [0,1]:
///
/// * [anchorLeft]/[anchorTop]/[anchorRight]/[anchorBottom] — the bounding rect
/// of the bookmarked text fragment (the FIRST fragment of the current text
/// selection), in NORMALIZED page coords with a top-left origin (the same
/// convention `SidecarHighlight` and the editor's highlight rects use). This
/// is what jump-to scrolls to (via `goToRectInsidePage`), so the bookmark
/// lands on the exact paragraph, not just the page top.
/// * [charIndex] — the character index of the selection start in the page's
/// `fullText` (the true text-position anchor). Stored for fidelity / future
/// reflow-tolerant re-anchoring; not currently used for navigation.
///
/// When no text was selected the anchor falls back to the tapped point: only
/// [anchorTop]/[anchorLeft] are set (a zero-size rect) and [charIndex] is null.
/// All anchor fields are optional and absent from JSON when null, so OLD
/// bookmarks (page-only) still decode and re-encode unchanged (back-compat).
@freezed @freezed
abstract class Bookmark with _$Bookmark { abstract class Bookmark with _$Bookmark {
const factory Bookmark({ const factory Bookmark({
required String id, required String id,
required String documentId, required String documentId,
/// 1-based page number this bookmark lives on.
required int pageNumber, required int pageNumber,
@Default('') String label, @Default('') String label,
@Default(0xFF2196F3) int color, @Default(0xFF2196F3) int color,
required DateTime createdAt, required DateTime createdAt,
/// Normalized in-page anchor rect (top-left origin, [0,1]). Null for legacy
/// page-only bookmarks (and tap-fallback bookmarks set only top/left). Old
/// page-only bookmark JSON omits these keys; they decode to null and the
/// data round-trips (back-compat). A re-encoded legacy bookmark gains
/// explicit null keys, which readers tolerate.
double? anchorLeft,
double? anchorTop,
double? anchorRight,
double? anchorBottom,
/// Character index of the selection start in the page's `fullText`, or null
/// (tap-fallback / legacy bookmarks).
int? charIndex,
}) = _Bookmark; }) = _Bookmark;
factory Bookmark.fromJson(Map<String, dynamic> json) => factory Bookmark.fromJson(Map<String, dynamic> json) =>

View File

@@ -23,11 +23,27 @@ Bookmark _$BookmarkFromJson(Map<String, dynamic> json) {
mixin _$Bookmark { mixin _$Bookmark {
String get id => throw _privateConstructorUsedError; String get id => throw _privateConstructorUsedError;
String get documentId => throw _privateConstructorUsedError; String get documentId => throw _privateConstructorUsedError;
/// 1-based page number this bookmark lives on.
int get pageNumber => throw _privateConstructorUsedError; int get pageNumber => throw _privateConstructorUsedError;
String get label => throw _privateConstructorUsedError; String get label => throw _privateConstructorUsedError;
int get color => throw _privateConstructorUsedError; int get color => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError; DateTime get createdAt => throw _privateConstructorUsedError;
/// Normalized in-page anchor rect (top-left origin, [0,1]). Null for legacy
/// page-only bookmarks (and tap-fallback bookmarks set only top/left). Old
/// page-only bookmark JSON omits these keys; they decode to null and the
/// data round-trips (back-compat). A re-encoded legacy bookmark gains
/// explicit null keys, which readers tolerate.
double? get anchorLeft => throw _privateConstructorUsedError;
double? get anchorTop => throw _privateConstructorUsedError;
double? get anchorRight => throw _privateConstructorUsedError;
double? get anchorBottom => throw _privateConstructorUsedError;
/// Character index of the selection start in the page's `fullText`, or null
/// (tap-fallback / legacy bookmarks).
int? get charIndex => throw _privateConstructorUsedError;
/// Serializes this Bookmark to a JSON map. /// Serializes this Bookmark to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError; Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@@ -50,6 +66,11 @@ abstract class $BookmarkCopyWith<$Res> {
String label, String label,
int color, int color,
DateTime createdAt, DateTime createdAt,
double? anchorLeft,
double? anchorTop,
double? anchorRight,
double? anchorBottom,
int? charIndex,
}); });
} }
@@ -74,6 +95,11 @@ class _$BookmarkCopyWithImpl<$Res, $Val extends Bookmark>
Object? label = null, Object? label = null,
Object? color = null, Object? color = null,
Object? createdAt = null, Object? createdAt = null,
Object? anchorLeft = freezed,
Object? anchorTop = freezed,
Object? anchorRight = freezed,
Object? anchorBottom = freezed,
Object? charIndex = freezed,
}) { }) {
return _then( return _then(
_value.copyWith( _value.copyWith(
@@ -101,6 +127,26 @@ class _$BookmarkCopyWithImpl<$Res, $Val extends Bookmark>
? _value.createdAt ? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime, as DateTime,
anchorLeft: freezed == anchorLeft
? _value.anchorLeft
: anchorLeft // ignore: cast_nullable_to_non_nullable
as double?,
anchorTop: freezed == anchorTop
? _value.anchorTop
: anchorTop // ignore: cast_nullable_to_non_nullable
as double?,
anchorRight: freezed == anchorRight
? _value.anchorRight
: anchorRight // ignore: cast_nullable_to_non_nullable
as double?,
anchorBottom: freezed == anchorBottom
? _value.anchorBottom
: anchorBottom // ignore: cast_nullable_to_non_nullable
as double?,
charIndex: freezed == charIndex
? _value.charIndex
: charIndex // ignore: cast_nullable_to_non_nullable
as int?,
) )
as $Val, as $Val,
); );
@@ -123,6 +169,11 @@ abstract class _$$BookmarkImplCopyWith<$Res>
String label, String label,
int color, int color,
DateTime createdAt, DateTime createdAt,
double? anchorLeft,
double? anchorTop,
double? anchorRight,
double? anchorBottom,
int? charIndex,
}); });
} }
@@ -146,6 +197,11 @@ class __$$BookmarkImplCopyWithImpl<$Res>
Object? label = null, Object? label = null,
Object? color = null, Object? color = null,
Object? createdAt = null, Object? createdAt = null,
Object? anchorLeft = freezed,
Object? anchorTop = freezed,
Object? anchorRight = freezed,
Object? anchorBottom = freezed,
Object? charIndex = freezed,
}) { }) {
return _then( return _then(
_$BookmarkImpl( _$BookmarkImpl(
@@ -173,6 +229,26 @@ class __$$BookmarkImplCopyWithImpl<$Res>
? _value.createdAt ? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime, as DateTime,
anchorLeft: freezed == anchorLeft
? _value.anchorLeft
: anchorLeft // ignore: cast_nullable_to_non_nullable
as double?,
anchorTop: freezed == anchorTop
? _value.anchorTop
: anchorTop // ignore: cast_nullable_to_non_nullable
as double?,
anchorRight: freezed == anchorRight
? _value.anchorRight
: anchorRight // ignore: cast_nullable_to_non_nullable
as double?,
anchorBottom: freezed == anchorBottom
? _value.anchorBottom
: anchorBottom // ignore: cast_nullable_to_non_nullable
as double?,
charIndex: freezed == charIndex
? _value.charIndex
: charIndex // ignore: cast_nullable_to_non_nullable
as int?,
), ),
); );
} }
@@ -188,6 +264,11 @@ class _$BookmarkImpl implements _Bookmark {
this.label = '', this.label = '',
this.color = 0xFF2196F3, this.color = 0xFF2196F3,
required this.createdAt, required this.createdAt,
this.anchorLeft,
this.anchorTop,
this.anchorRight,
this.anchorBottom,
this.charIndex,
}); });
factory _$BookmarkImpl.fromJson(Map<String, dynamic> json) => factory _$BookmarkImpl.fromJson(Map<String, dynamic> json) =>
@@ -197,6 +278,8 @@ class _$BookmarkImpl implements _Bookmark {
final String id; final String id;
@override @override
final String documentId; final String documentId;
/// 1-based page number this bookmark lives on.
@override @override
final int pageNumber; final int pageNumber;
@override @override
@@ -208,9 +291,28 @@ class _$BookmarkImpl implements _Bookmark {
@override @override
final DateTime createdAt; final DateTime createdAt;
/// Normalized in-page anchor rect (top-left origin, [0,1]). Null for legacy
/// page-only bookmarks (and tap-fallback bookmarks set only top/left). Old
/// page-only bookmark JSON omits these keys; they decode to null and the
/// data round-trips (back-compat). A re-encoded legacy bookmark gains
/// explicit null keys, which readers tolerate.
@override
final double? anchorLeft;
@override
final double? anchorTop;
@override
final double? anchorRight;
@override
final double? anchorBottom;
/// Character index of the selection start in the page's `fullText`, or null
/// (tap-fallback / legacy bookmarks).
@override
final int? charIndex;
@override @override
String toString() { String toString() {
return 'Bookmark(id: $id, documentId: $documentId, pageNumber: $pageNumber, label: $label, color: $color, createdAt: $createdAt)'; return 'Bookmark(id: $id, documentId: $documentId, pageNumber: $pageNumber, label: $label, color: $color, createdAt: $createdAt, anchorLeft: $anchorLeft, anchorTop: $anchorTop, anchorRight: $anchorRight, anchorBottom: $anchorBottom, charIndex: $charIndex)';
} }
@override @override
@@ -226,7 +328,17 @@ class _$BookmarkImpl implements _Bookmark {
(identical(other.label, label) || other.label == label) && (identical(other.label, label) || other.label == label) &&
(identical(other.color, color) || other.color == color) && (identical(other.color, color) || other.color == color) &&
(identical(other.createdAt, createdAt) || (identical(other.createdAt, createdAt) ||
other.createdAt == createdAt)); other.createdAt == createdAt) &&
(identical(other.anchorLeft, anchorLeft) ||
other.anchorLeft == anchorLeft) &&
(identical(other.anchorTop, anchorTop) ||
other.anchorTop == anchorTop) &&
(identical(other.anchorRight, anchorRight) ||
other.anchorRight == anchorRight) &&
(identical(other.anchorBottom, anchorBottom) ||
other.anchorBottom == anchorBottom) &&
(identical(other.charIndex, charIndex) ||
other.charIndex == charIndex));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -239,6 +351,11 @@ class _$BookmarkImpl implements _Bookmark {
label, label,
color, color,
createdAt, createdAt,
anchorLeft,
anchorTop,
anchorRight,
anchorBottom,
charIndex,
); );
/// Create a copy of Bookmark /// Create a copy of Bookmark
@@ -263,6 +380,11 @@ abstract class _Bookmark implements Bookmark {
final String label, final String label,
final int color, final int color,
required final DateTime createdAt, required final DateTime createdAt,
final double? anchorLeft,
final double? anchorTop,
final double? anchorRight,
final double? anchorBottom,
final int? charIndex,
}) = _$BookmarkImpl; }) = _$BookmarkImpl;
factory _Bookmark.fromJson(Map<String, dynamic> json) = factory _Bookmark.fromJson(Map<String, dynamic> json) =
@@ -272,6 +394,8 @@ abstract class _Bookmark implements Bookmark {
String get id; String get id;
@override @override
String get documentId; String get documentId;
/// 1-based page number this bookmark lives on.
@override @override
int get pageNumber; int get pageNumber;
@override @override
@@ -281,6 +405,25 @@ abstract class _Bookmark implements Bookmark {
@override @override
DateTime get createdAt; DateTime get createdAt;
/// Normalized in-page anchor rect (top-left origin, [0,1]). Null for legacy
/// page-only bookmarks (and tap-fallback bookmarks set only top/left). Old
/// page-only bookmark JSON omits these keys; they decode to null and the
/// data round-trips (back-compat). A re-encoded legacy bookmark gains
/// explicit null keys, which readers tolerate.
@override
double? get anchorLeft;
@override
double? get anchorTop;
@override
double? get anchorRight;
@override
double? get anchorBottom;
/// Character index of the selection start in the page's `fullText`, or null
/// (tap-fallback / legacy bookmarks).
@override
int? get charIndex;
/// Create a copy of Bookmark /// Create a copy of Bookmark
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @override

View File

@@ -14,6 +14,11 @@ _$BookmarkImpl _$$BookmarkImplFromJson(Map<String, dynamic> json) =>
label: json['label'] as String? ?? '', label: json['label'] as String? ?? '',
color: (json['color'] as num?)?.toInt() ?? 0xFF2196F3, color: (json['color'] as num?)?.toInt() ?? 0xFF2196F3,
createdAt: DateTime.parse(json['createdAt'] as String), createdAt: DateTime.parse(json['createdAt'] as String),
anchorLeft: (json['anchorLeft'] as num?)?.toDouble(),
anchorTop: (json['anchorTop'] as num?)?.toDouble(),
anchorRight: (json['anchorRight'] as num?)?.toDouble(),
anchorBottom: (json['anchorBottom'] as num?)?.toDouble(),
charIndex: (json['charIndex'] as num?)?.toInt(),
); );
Map<String, dynamic> _$$BookmarkImplToJson(_$BookmarkImpl instance) => Map<String, dynamic> _$$BookmarkImplToJson(_$BookmarkImpl instance) =>
@@ -24,4 +29,9 @@ Map<String, dynamic> _$$BookmarkImplToJson(_$BookmarkImpl instance) =>
'label': instance.label, 'label': instance.label,
'color': instance.color, 'color': instance.color,
'createdAt': instance.createdAt.toIso8601String(), 'createdAt': instance.createdAt.toIso8601String(),
'anchorLeft': instance.anchorLeft,
'anchorTop': instance.anchorTop,
'anchorRight': instance.anchorRight,
'anchorBottom': instance.anchorBottom,
'charIndex': instance.charIndex,
}; };

View File

@@ -0,0 +1,103 @@
// lib/models/scratch_link.dart
//
// A PDF-anchored scratch link: a sticky-note "tab" placed at a normalized
// position (nx, ny in [0,1]) on a specific page of a document. Tapping the
// anchor opens an on-page sticky card that BELONGS TO THIS ANCHOR (keyed by
// [id]). Optional [nw]/[nh] size the expanded card as fractions of the page.
import 'package:flutter/foundation.dart';
@immutable
class ScratchLink {
const ScratchLink({
required this.id,
required this.documentId,
required this.pageIndex,
required this.nx,
required this.ny,
this.nw = 0.42,
this.nh = 0.36,
});
/// Stable anchor id (uuid). Doubles as the scratchpad storage key so each
/// anchor gets its own private infinite scratchpad.
final String id;
/// The owning document (the editor's stable document-id for the PDF path).
final String documentId;
/// 0-based page the anchor sits on.
final int pageIndex;
/// Normalized horizontal position on the page, in [0, 1] (top-left of card).
final double nx;
/// Normalized vertical position on the page, in [0, 1] (top-left of card).
final double ny;
/// Expanded card width as a fraction of page width (clamped on write).
final double nw;
/// Expanded card height as a fraction of page height.
final double nh;
ScratchLink copyWith({
String? id,
String? documentId,
int? pageIndex,
double? nx,
double? ny,
double? nw,
double? nh,
}) =>
ScratchLink(
id: id ?? this.id,
documentId: documentId ?? this.documentId,
pageIndex: pageIndex ?? this.pageIndex,
nx: nx ?? this.nx,
ny: ny ?? this.ny,
nw: nw ?? this.nw,
nh: nh ?? this.nh,
);
Map<String, dynamic> toJson() => {
'id': id,
'documentId': documentId,
'pageIndex': pageIndex,
'nx': nx,
'ny': ny,
'nw': nw,
'nh': nh,
};
factory ScratchLink.fromJson(Map<String, dynamic> json) => ScratchLink(
id: json['id'] as String,
documentId: json['documentId'] as String,
pageIndex: (json['pageIndex'] as num).toInt(),
nx: (json['nx'] as num).toDouble(),
ny: (json['ny'] as num).toDouble(),
nw: (json['nw'] as num?)?.toDouble() ?? 0.42,
nh: (json['nh'] as num?)?.toDouble() ?? 0.36,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ScratchLink &&
runtimeType == other.runtimeType &&
id == other.id &&
documentId == other.documentId &&
pageIndex == other.pageIndex &&
nx == other.nx &&
ny == other.ny &&
nw == other.nw &&
nh == other.nh;
@override
int get hashCode => Object.hash(id, documentId, pageIndex, nx, ny, nw, nh);
@override
String toString() =>
'ScratchLink(id: $id, documentId: $documentId, pageIndex: $pageIndex, '
'nx: $nx, ny: $ny, nw: $nw, nh: $nh)';
}

View File

@@ -1,61 +1,68 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../models/document.dart'; import '../models/document.dart';
import '../services/database_service.dart'; import '../services/vault_service.dart';
import 'note_provider.dart';
const _uuid = Uuid(); final vaultServiceProvider = FutureProvider<VaultService>((ref) async {
return VaultService.getInstance();
});
final documentListProvider = final documentListProvider =
AsyncNotifierProvider<DocumentListNotifier, List<Document>>( AsyncNotifierProvider<DocumentListNotifier, List<Document>>(
DocumentListNotifier.new, DocumentListNotifier.new,
); );
/// The home-screen document list is now sourced from a VAULT SCAN (folders
/// under the vault root containing a source file + optional sidecar), NOT the
/// SQLite `documents` table. The sidecar that travels with the file is the
/// source of truth; there is no SQLite cache for this list (the scan is cheap —
/// one directory listing — and always correct).
class DocumentListNotifier extends AsyncNotifier<List<Document>> { class DocumentListNotifier extends AsyncNotifier<List<Document>> {
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future); Future<VaultService> get _vault =>
ref.read(vaultServiceProvider.future);
@override @override
Future<List<Document>> build() async { Future<List<Document>> build() async {
final db = await _db; return _scan();
return db.getAllDocuments();
} }
/// Reloads documents from the database and publishes the result to [state] Future<List<Document>> _scan() async {
/// so the UI rebuilds. Used by pull-to-refresh. final vault = await _vault;
final notebooks = await vault.scanNotebooks();
return notebooks.map(_toDocument).toList();
}
/// Adapt a scanned [VaultNotebook] into the [Document] shape the home-screen
/// tiles already render. The notebook folder path doubles as a stable id.
Document _toDocument(VaultNotebook nb) {
return Document(
id: nb.folderPath,
filename: nb.filename,
docType: nb.docType,
filePath: nb.sourceFilePath,
pageCount: 0,
createdAt: nb.modified,
updatedAt: nb.modified,
);
}
/// Re-scan the vault and publish the result. Used by pull-to-refresh and
/// after an import.
Future<void> loadDocuments() async { Future<void> loadDocuments() async {
state = const AsyncLoading(); state = const AsyncLoading();
state = await AsyncValue.guard(() async { state = await AsyncValue.guard(_scan);
final db = await _db;
return db.getAllDocuments();
});
}
Future<Document> addDocument({
required String filename,
required String docType,
required String filePath,
int pageCount = 0,
}) async {
final db = await _db;
final now = DateTime.now();
final document = Document(
id: _uuid.v4(),
filename: filename,
docType: docType,
filePath: filePath,
pageCount: pageCount,
createdAt: now,
updatedAt: now,
);
await db.insertDocument(document);
state = AsyncData([document, ...state.value ?? []]);
return document;
} }
/// Remove a notebook by deleting its folder (source file + sidecar travel
/// together, so removing the folder removes the whole notebook). [id] is the
/// notebook folder path produced by [_toDocument].
Future<void> removeDocument(String id) async { Future<void> removeDocument(String id) async {
final db = await _db; final dir = Directory(id);
await db.deleteDocument(id); if (await dir.exists()) {
await dir.delete(recursive: true);
}
final current = state.value ?? []; final current = state.value ?? [];
state = AsyncData(current.where((d) => d.id != id).toList()); state = AsyncData(current.where((d) => d.id != id).toList());
} }

View File

@@ -1,68 +1,81 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../models/note.dart'; import '../models/note.dart';
import '../services/database_service.dart'; import '../services/vault_service.dart';
import 'document_provider.dart' show vaultServiceProvider;
const _uuid = Uuid();
final databaseServiceProvider = FutureProvider<DatabaseService>((ref) async {
return DatabaseService.getInstance();
});
/// The home-screen note list is now sourced from a VAULT SCAN of standalone
/// (free-ink) notebook folders — each a folder holding a `notebook.badnote.json`
/// and NO importable source file — NOT the SQLite `notes` table. The sidecar
/// that lives in the folder is the source of truth ("跟着文件走").
///
/// Each scanned note is adapted into the existing [Note] model the home screen
/// already renders: `id` = the synthetic note path (`<folder>/notebook`, also a
/// stable id), `title`, `updatedAt` = the sidecar mtime. Strokes are NOT loaded
/// here — they are hydrated lazily by the editor from the sidecar, so the list
/// stays cheap (one directory listing). Home tiles that show a stroke count will
/// therefore read 0 until the note is opened; the count is no longer cached.
final noteListProvider = AsyncNotifierProvider<NoteListNotifier, List<Note>>( final noteListProvider = AsyncNotifierProvider<NoteListNotifier, List<Note>>(
NoteListNotifier.new, NoteListNotifier.new,
); );
class NoteListNotifier extends AsyncNotifier<List<Note>> { class NoteListNotifier extends AsyncNotifier<List<Note>> {
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future); Future<VaultService> get _vault => ref.read(vaultServiceProvider.future);
@override @override
Future<List<Note>> build() async { Future<List<Note>> build() async {
final db = await _db; return _scan();
return db.getAllNotes();
} }
/// Reloads notes from the database and publishes the result to [state] so Future<List<Note>> _scan() async {
/// the UI rebuilds. Used by pull-to-refresh. final vault = await _vault;
final notes = await vault.scanNotes();
return notes.map(_toNote).toList();
}
/// Adapt a scanned [VaultNote] into the [Note] shape the home tiles render.
/// `id` is the synthetic note path so opening it re-keys the right sidecar.
Note _toNote(VaultNote n) => Note(
id: n.notePath,
title: n.title,
createdAt: n.modified,
updatedAt: n.modified,
);
/// Re-scan the vault and publish the result. Used by pull-to-refresh and
/// after a note is created or edited.
Future<void> loadNotes() async { Future<void> loadNotes() async {
state = const AsyncLoading(); state = const AsyncLoading();
state = await AsyncValue.guard(() async { state = await AsyncValue.guard(_scan);
final db = await _db;
return db.getAllNotes();
});
} }
/// Create an empty standalone notebook folder with [title] and return the
/// adapted [Note] (whose `id` is the synthetic note path). The home screen
/// opens the editor on it; persistence flows through the sidecar.
Future<Note> createNote({String title = 'Untitled'}) async { Future<Note> createNote({String title = 'Untitled'}) async {
final db = await _db; final vault = await _vault;
final notePath = await vault.createEmptyNotebook(title);
final now = DateTime.now(); final now = DateTime.now();
final note = Note( final note = Note(
id: _uuid.v4(), id: notePath,
title: title, title: title,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
); );
await db.insertNote(note);
state = AsyncData([note, ...state.value ?? []]); state = AsyncData([note, ...state.value ?? []]);
return note; return note;
} }
Future<void> updateNote(Note note) async { /// Delete a note by removing its notebook folder (the sidecar travels with
final db = await _db; /// it). [id] is the synthetic note path `<folder>/notebook`.
await db.updateNote(note);
final current = state.value ?? [];
state = AsyncData(current.map((n) => n.id == note.id ? note : n).toList());
}
Future<void> deleteNote(String id) async { Future<void> deleteNote(String id) async {
final db = await _db; final folder = Directory(File(id).parent.path);
await db.deleteNote(id); if (await folder.exists()) {
await folder.delete(recursive: true);
}
final current = state.value ?? []; final current = state.value ?? [];
state = AsyncData(current.where((n) => n.id != id).toList()); state = AsyncData(current.where((n) => n.id != id).toList());
} }
} }
final noteProvider = FutureProvider.family<Note?, String>((ref, id) async {
final db = await ref.watch(databaseServiceProvider.future);
return db.getNoteById(id);
});

View File

@@ -0,0 +1,46 @@
// lib/providers/notebook_container_provider.dart
//
// Home-screen list of OneNote-style notebook containers: vault folders that
// hold a `notebook.json` manifest (see `storage/notebook_manifest.dart`). This
// mirrors `note_provider.dart` / `document_provider.dart`'s vault-scan pattern
// — the manifest on disk is the source of truth, there is no SQLite cache.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/vault_service.dart';
import 'document_provider.dart' show vaultServiceProvider;
final notebookContainerListProvider = AsyncNotifierProvider<
NotebookContainerListNotifier, List<VaultContainer>>(
NotebookContainerListNotifier.new,
);
class NotebookContainerListNotifier
extends AsyncNotifier<List<VaultContainer>> {
Future<VaultService> get _vault => ref.read(vaultServiceProvider.future);
@override
Future<List<VaultContainer>> build() => _scan();
Future<List<VaultContainer>> _scan() async {
final vault = await _vault;
return vault.scanContainers();
}
/// Re-scan the vault and publish the result. Used by pull-to-refresh and
/// after a container is created elsewhere.
Future<void> loadContainers() async {
state = const AsyncLoading();
state = await AsyncValue.guard(_scan);
}
/// Create a new notebook container (folder + `notebook.json` + one blank ink
/// page) titled [title], prepend it to the list, and return it so the caller
/// can navigate straight into it.
Future<VaultContainer> createContainer(String title) async {
final vault = await _vault;
final container = await vault.createNotebookContainer(title);
state = AsyncData([container, ...state.value ?? []]);
return container;
}
}

View File

@@ -1,11 +1,24 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/ocr_service.dart'; import '../services/ocr_service.dart';
import '../services/pdf_text_indexer.dart';
import '../services/pdfrx_page_text_source.dart';
enum OcrStatus { none, processing, done, failed } enum OcrStatus { none, processing, done, failed }
final ocrServiceProvider = Provider<OcrService>((ref) => OcrService()); final ocrServiceProvider = Provider<OcrService>((ref) => OcrService());
/// The import-time PDF document-body indexer, wired to the pdfrx-backed embedded
/// text + page-render OCR sources (see [PdfrxPageTextSource]). The import flow
/// fires [PdfTextIndexer.indexPdf] (fire-and-forget) so a scanned PDF's text
/// becomes searchable in the background without blocking the editor opening.
final pdfTextIndexerProvider = Provider<PdfTextIndexer>(
(ref) => PdfTextIndexer(
loadEmbeddedText: PdfrxPageTextSource.loadEmbeddedText,
ocrPages: PdfrxPageTextSource.ocrPages,
),
);
/// Tracks local OCR processing status per note ID. /// Tracks local OCR processing status per note ID.
/// ///
/// This map only ever holds an entry per note that has had OCR triggered in /// This map only ever holds an entry per note that has had OCR triggered in

View File

@@ -1,8 +1,23 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/document.dart';
import '../models/note.dart'; import '../models/note.dart';
import 'note_provider.dart'; import '../services/vault_search_index.dart';
import 'document_provider.dart' show vaultServiceProvider;
/// The search index, rebuilt by SCANNING the vault sidecars (the source of
/// truth) — NOT the demoted SQLite cache (Phase 6, §B/§F). Bumping
/// [searchIndexEpochProvider] (e.g. after an import or note edit) invalidates
/// this provider so the next read re-scans the vault from disk.
final vaultSearchIndexProvider = FutureProvider<VaultSearchIndex>((ref) async {
ref.watch(searchIndexEpochProvider);
final vault = await ref.watch(vaultServiceProvider.future);
final index = VaultSearchIndex(vault);
await index.rebuild();
return index;
});
/// Bump to force the search index to rebuild from disk (e.g. after an import).
final searchIndexEpochProvider = StateProvider<int>((ref) => 0);
final searchQueryProvider = StateProvider<String>((ref) => ''); final searchQueryProvider = StateProvider<String>((ref) => '');
@@ -34,59 +49,45 @@ class DocumentSearchHit extends SearchResult {
final searchResultsProvider = FutureProvider<List<SearchResult>>((ref) async { final searchResultsProvider = FutureProvider<List<SearchResult>>((ref) async {
final query = ref.watch(searchQueryProvider); final query = ref.watch(searchQueryProvider);
if (query.isEmpty) return []; if (query.trim().isEmpty) return [];
// Obtain the DB through the provider graph so this participates in final index = await ref.watch(vaultSearchIndexProvider.future);
// initialization and disposal like every other consumer.
final db = await ref.watch(databaseServiceProvider.future);
// Run the note and document searches concurrently. final hits = await index.search(query);
final searches = await Future.wait([
db.searchNotes(query),
db.searchDocuments(query),
]);
final noteHits = searches[0] as List<Note>;
final docHits = searches[1] as List<Map<String, dynamic>>;
final results = <SearchResult>[]; final results = <SearchResult>[];
for (final hit in hits) {
// Add note results. final entry = hit.entry;
for (final note in noteHits) { final snippet = hit.snippet.text;
results.add(NoteSearchHit(note: note, snippet: note.title)); if (entry.isNote) {
} // Construct a lightweight Note whose id is the synthetic note path so
// PenNoteScreen re-keys the right sidecar on open. Strokes are hydrated
// Resolve document metadata without an N+1 loop: collect the distinct // lazily by the editor; the search list only needs id/title.
// document ids referenced by the hits, look each up exactly once, then final now = DateTime.now();
// build the result list from the cached lookups.
final docIds = <String>{
for (final hit in docHits)
if (hit['document_id'] is String) hit['document_id'] as String,
};
final docEntries = await Future.wait(
docIds.map((id) async => MapEntry(id, await db.getDocument(id))),
);
final docsById = <String, Document>{
for (final entry in docEntries)
if (entry.value != null) entry.key: entry.value!,
};
for (final hit in docHits) {
final documentId = hit['document_id'];
if (documentId is! String) continue;
final doc = docsById[documentId];
if (doc == null) continue;
final pageNumber = hit['page_number'];
final content = hit['content'];
results.add( results.add(
DocumentSearchHit( NoteSearchHit(
documentId: documentId, note: Note(
filename: doc.filename, id: entry.openPath,
filePath: doc.filePath, title: entry.title,
pageNumber: pageNumber is int ? pageNumber : 0, createdAt: now,
snippet: content is String ? content : '', updatedAt: now,
),
snippet: snippet,
), ),
); );
} else {
results.add(
DocumentSearchHit(
documentId: entry.id,
filename: entry.title,
filePath: entry.openPath,
// The scan-based index matches whole-notebook text, not per-page, so
// the document opens at its first page.
pageNumber: 0,
snippet: snippet,
),
);
}
} }
return results; return results;

Some files were not shown because too many files have changed in this diff Show More