Compare commits
94 Commits
ee1b3a39f1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 307161f465 | |||
| ad9b1b46db | |||
| f31dd0fb52 | |||
| 85af037b7d | |||
| 4f6fb69dee | |||
| 2b1c6ba7e0 | |||
| 4a6fe7d05e | |||
| 198da00ecd | |||
| d346cc2670 | |||
| 3cabc7e074 | |||
| e939759458 | |||
| 20add27a30 | |||
| 1d5ba05bb8 | |||
| c800295c12 | |||
| 46589a4c87 | |||
| 6c2dd71b82 | |||
| 24d13642fd | |||
| 4886f1b2df | |||
| f4f0853eae | |||
| 2f0fda5f95 | |||
| 978111eeff | |||
| 953c7b700f | |||
| 9fcac47ef2 | |||
| 875dabcd89 | |||
| fd102b5703 | |||
| 9bb5c483d6 | |||
| f757701391 | |||
| 0feca74278 | |||
| 45a8931b64 | |||
| db6e3842c7 | |||
| f41df2033f | |||
| df389177e6 | |||
| 8908f42f76 | |||
| eae4493954 | |||
| 0ae2671f9b | |||
| ffb9e35755 | |||
| dfe5f2a477 | |||
| 3507e929b1 | |||
| 96594fbe1b | |||
| a7d71e7cfd | |||
| d1b265dc70 | |||
| 2c9f6037f7 | |||
| 4fb431727e | |||
| 299b9546a8 | |||
| 1d70c029b3 | |||
| f48e43f13d | |||
| 5db364d1fc | |||
| 22f27641ca | |||
| 8a0c850f51 | |||
| a2df8ae68f | |||
| 3cfb793803 | |||
| 84d7dc8d24 | |||
| c8579d3e86 | |||
| 458d6448d3 | |||
| eda0ee6006 | |||
| 2edb897000 | |||
| c1a35b3290 | |||
| 4c8cc73106 | |||
| 77b6ee415d | |||
| b1f131814e | |||
| 1e836d6e7d | |||
| b359000991 | |||
| 0cf58fb67a | |||
| 1aa2865d6f | |||
| 3e0b81be92 | |||
| 18ebce6d73 | |||
| eedb52d2f5 | |||
| 852eb389ee | |||
| c31bfd3445 | |||
| c03513da4f | |||
| 6b7cc14836 | |||
| 1a3d1065f9 | |||
| 07b543f3e1 | |||
| eca5141372 | |||
| f64e6561a0 | |||
| d50087247c | |||
| a48c0e7e56 | |||
| 682907614d | |||
| f9ec04fe86 | |||
| ae9e070b46 | |||
| 45d89b7790 | |||
| 7e405453e0 | |||
| 25400c8b82 | |||
| c045fdd3ca | |||
| 3295018ee3 | |||
| e4a94d00c0 | |||
| 28b7af7e72 | |||
| 914951afb7 | |||
| 1e2a83b0b9 | |||
| 0c40a456e8 | |||
| 3febbd1431 | |||
| 9db688d948 | |||
| c506a4dc7f | |||
| 1e5b8a996d |
33
.claude/pua-loop-context.md
Normal file
33
.claude/pua-loop-context.md
Normal 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.
|
||||
3
.claude/pua-loop-history.jsonl
Normal file
3
.claude/pua-loop-history.jsonl
Normal 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"}
|
||||
@@ -35,39 +35,25 @@ jobs:
|
||||
# Flutter on PATH, so: use it if present (PATH / FLUTTER_ROOT / common
|
||||
# dirs), otherwise install the pinned SDK from the flutter-io.cn mirror
|
||||
# into a persistent dir (C:\flutter-ci) that is reused across runs.
|
||||
- name: Set up Flutter
|
||||
# Pin Flutter 3.44.2 DETERMINISTICALLY. pdfrx needs Windows stylus +
|
||||
# multitouch, which only landed in Flutter 3.44.0 (WM_POINTER migration).
|
||||
# We install into a version-pinned dir and PREPEND it to PATH so this
|
||||
# version shadows any older Flutter already on the runner (a stale
|
||||
# C:\flutter-ci from prior runs must NOT win, or pen input breaks again).
|
||||
- name: Set up Flutter (pinned 3.44.2)
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
if (Get-Command flutter -ErrorAction SilentlyContinue) {
|
||||
Write-Host "flutter already on PATH"; exit 0
|
||||
}
|
||||
$candidates = @()
|
||||
if ($env:FLUTTER_ROOT) { $candidates += (Join-Path $env:FLUTTER_ROOT 'bin') }
|
||||
$candidates += @(
|
||||
'C:\flutter-ci\flutter\bin','C:\flutter\bin','C:\src\flutter\bin',
|
||||
'C:\tools\flutter\bin','C:\dev\flutter\bin',
|
||||
"$env:USERPROFILE\flutter\bin","$env:LOCALAPPDATA\flutter\bin"
|
||||
)
|
||||
foreach ($c in $candidates) {
|
||||
if ($c -and (Test-Path (Join-Path $c 'flutter.bat'))) {
|
||||
Write-Host "Found Flutter at $c"
|
||||
Add-Content -Path $env:GITHUB_PATH -Value $c
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
# Install the pinned SDK from the China mirror into a persistent dir.
|
||||
$root = 'C:\flutter-ci'
|
||||
$ver = '3.44.2'
|
||||
$root = "C:\flutter-$ver"
|
||||
$bin = Join-Path $root 'flutter\bin'
|
||||
if (-not (Test-Path (Join-Path $bin 'flutter.bat'))) {
|
||||
$url = 'https://storage.flutter-io.cn/flutter_infra_release/releases/stable/windows/flutter_windows_3.41.4-stable.zip'
|
||||
$zip = Join-Path $env:TEMP 'flutter_sdk.zip'
|
||||
$url = "https://storage.flutter-io.cn/flutter_infra_release/releases/stable/windows/flutter_windows_$ver-stable.zip"
|
||||
$zip = Join-Path $env:TEMP "flutter_$ver.zip"
|
||||
New-Item -ItemType Directory -Force -Path $root | Out-Null
|
||||
Write-Host "Downloading Flutter SDK from $url"
|
||||
Write-Host "Downloading Flutter $ver from $url"
|
||||
$ok = $false
|
||||
# curl.exe (bundled on Windows 10+) streams to disk and is far
|
||||
# faster than Invoke-WebRequest for a ~1GB file. Fall back to IWR.
|
||||
$curl = "$env:SystemRoot\System32\curl.exe"
|
||||
if (Test-Path $curl) {
|
||||
& $curl -L --fail --retry 3 --retry-delay 5 -o $zip $url
|
||||
@@ -80,20 +66,38 @@ jobs:
|
||||
catch { Write-Host "IWR attempt $i failed: $_"; Start-Sleep 5 }
|
||||
}
|
||||
}
|
||||
if (-not $ok) { Write-Error "Failed to download Flutter SDK"; exit 1 }
|
||||
Write-Host "Extracting to $root (this is slow on first run)"
|
||||
if (-not $ok) { Write-Error "Failed to download Flutter $ver"; exit 1 }
|
||||
Write-Host "Extracting to $root (slow on first run)"
|
||||
Expand-Archive -Path $zip -DestinationPath $root -Force
|
||||
Remove-Item $zip -Force
|
||||
}
|
||||
if (-not (Test-Path (Join-Path $bin 'flutter.bat'))) {
|
||||
Write-Error "Flutter install failed"; exit 1
|
||||
Write-Error "Flutter $ver install failed"; exit 1
|
||||
}
|
||||
Write-Host "Flutter ready at $bin"
|
||||
# Prepend (first line wins in GITHUB_PATH) so 3.44.2 shadows any
|
||||
# system/cached Flutter on the runner.
|
||||
Add-Content -Path $env:GITHUB_PATH -Value $bin
|
||||
Write-Host "Flutter $ver pinned at $bin"
|
||||
|
||||
- name: Flutter version
|
||||
run: flutter --version
|
||||
|
||||
# pdfrx REQUIRES Windows Developer Mode (its build creates symbolic links;
|
||||
# without Dev Mode `flutter build windows` fails). Enable it on the runner
|
||||
# by setting the AppModelUnlock registry key (idempotent — a no-op if it is
|
||||
# already on). Requires the runner to be elevated; if it is not, this fails
|
||||
# loudly here, which is correct since the build cannot proceed regardless.
|
||||
- name: Enable Windows Developer Mode (pdfrx symlinks)
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock'
|
||||
if (-not (Test-Path $key)) { New-Item -Path $key -Force | Out-Null }
|
||||
New-ItemProperty -Path $key -Name 'AllowDevelopmentWithoutDevLicense' `
|
||||
-Value 1 -PropertyType DWORD -Force | Out-Null
|
||||
$v = (Get-ItemProperty -Path $key -Name 'AllowDevelopmentWithoutDevLicense').AllowDevelopmentWithoutDevLicense
|
||||
Write-Host "AllowDevelopmentWithoutDevLicense = $v (1 = Developer Mode on)"
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
@@ -114,6 +118,69 @@ jobs:
|
||||
- name: Enable Windows desktop
|
||||
run: flutter config --enable-windows-desktop
|
||||
|
||||
# pdfrx -> pdfium_dart pulls pdfium.dll from github.com at build time via a
|
||||
# native-assets build hook (hook/build.dart) that uses package:http, which
|
||||
# IGNORES HTTP_PROXY. Behind the GFW that direct download fails. The hook
|
||||
# skips downloading when its output file already exists, so we (1) pre-fetch
|
||||
# pdfium.dll through a CN-accessible GitHub mirror and pre-place it at the
|
||||
# hook's deterministic shared-output path, and (2) also rewrite the hook's
|
||||
# hardcoded GitHub URL to the mirror as a fallback.
|
||||
- name: Pre-fetch PDFium (pdfrx native asset)
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
$rel = 'chromium/7811'
|
||||
# Deterministic native-assets shared output path (verified from a real
|
||||
# build: .dart_tool/hooks_runner/shared/pdfium_dart/build/<rel>/<plat>).
|
||||
$dst = '.dart_tool\hooks_runner\shared\pdfium_dart\build\chromium_7811\win-x64'
|
||||
$dll = Join-Path $dst 'pdfium.dll'
|
||||
if (-not (Test-Path $dll)) {
|
||||
New-Item -ItemType Directory -Force -Path $dst | Out-Null
|
||||
$tgz = Join-Path $env:TEMP 'pdfium-win-x64.tgz'
|
||||
$gh = "github.com/bblanchon/pdfium-binaries/releases/download/$rel/pdfium-win-x64.tgz"
|
||||
$urls = @("https://gh-proxy.com/https://$gh", "https://ghfast.top/https://$gh", "https://$gh")
|
||||
$curl = "$env:SystemRoot\System32\curl.exe"
|
||||
$ok = $false
|
||||
foreach ($u in $urls) {
|
||||
Write-Host "Fetching PDFium: $u"
|
||||
& $curl -L --fail --retry 2 --retry-delay 5 -o $tgz $u
|
||||
if ($LASTEXITCODE -eq 0 -and (Test-Path $tgz)) { $ok = $true; break }
|
||||
Write-Host " failed (exit $LASTEXITCODE)"
|
||||
}
|
||||
if (-not $ok) { Write-Error "Failed to fetch PDFium from all mirrors"; exit 1 }
|
||||
$ex = Join-Path $env:TEMP 'pdfium_extract'
|
||||
if (Test-Path $ex) { Remove-Item -Recurse -Force $ex }
|
||||
New-Item -ItemType Directory -Force -Path $ex | Out-Null
|
||||
& tar -xzf $tgz -C $ex
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "tar extract failed"; exit 1 }
|
||||
Copy-Item (Join-Path $ex 'bin\pdfium.dll') $dll -Force
|
||||
Remove-Item $tgz -Force
|
||||
if (-not (Test-Path $dll)) { Write-Error "pdfium.dll not placed"; exit 1 }
|
||||
Write-Host "PDFium pre-placed at $dll"
|
||||
} else {
|
||||
Write-Host "pdfium.dll already present at $dll"
|
||||
}
|
||||
# Fallback: rewrite the hook's hardcoded GitHub URL to the mirror, in
|
||||
# case the pre-placed path is not consulted for some build config.
|
||||
$roots = @($env:PUB_CACHE, "$env:LOCALAPPDATA\Pub\Cache", "$env:APPDATA\Pub\Cache") |
|
||||
Where-Object { $_ -and (Test-Path $_) }
|
||||
foreach ($r in $roots) {
|
||||
$hook = Get-ChildItem -Path $r -Recurse -Filter build.dart -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -match 'pdfium_dart-[\d.]+[\\/]hook[\\/]build\.dart' } |
|
||||
Select-Object -First 1
|
||||
if ($hook) {
|
||||
$orig = 'https://github.com/bblanchon/pdfium-binaries/releases/download/'
|
||||
$mirror = 'https://gh-proxy.com/https://github.com/bblanchon/pdfium-binaries/releases/download/'
|
||||
$c = Get-Content -Raw -LiteralPath $hook.FullName
|
||||
if ($c -notmatch [regex]::Escape($mirror)) {
|
||||
Set-Content -LiteralPath $hook.FullName -Value ($c.Replace($orig, $mirror)) -NoNewline
|
||||
Write-Host "Patched hook URL to mirror: $($hook.FullName)"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# The flutter_onnxruntime plugin downloads the ONNX Runtime native lib
|
||||
# from github.com during the build, which is blocked here. Pre-place the
|
||||
# extracted SDK where the plugin expects it (via a China-accessible GitHub
|
||||
@@ -149,11 +216,17 @@ jobs:
|
||||
# (CMake's file(DOWNLOAD) honours them).
|
||||
- name: Build Windows release
|
||||
env:
|
||||
HTTP_PROXY: http://127.0.0.1:7890
|
||||
HTTPS_PROXY: http://127.0.0.1:7890
|
||||
http_proxy: http://127.0.0.1:7890
|
||||
https_proxy: http://127.0.0.1:7890
|
||||
run: flutter build windows --release
|
||||
HTTP_PROXY: http://192.168.31.189:7890
|
||||
HTTPS_PROXY: http://192.168.31.189:7890
|
||||
http_proxy: http://192.168.31.189:7890
|
||||
https_proxy: http://192.168.31.189:7890
|
||||
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
|
||||
shell: powershell
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -60,3 +60,6 @@ server/.env
|
||||
# M1 spike generated bench assets (regenerate via tool/gen_*.dart)
|
||||
/test/assets/large_300p.pdf
|
||||
/test/assets/dense_strokes.json
|
||||
|
||||
# On-device input diagnostic capture (local only)
|
||||
badnote_input_log.txt
|
||||
|
||||
15
README.md
15
README.md
@@ -1,17 +1,20 @@
|
||||
# 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.
|
||||
|
||||
## Features
|
||||
|
||||
- Ink notes with Surface Pen (pressure, stabilizer, undo/redo)
|
||||
- PDF and PPT import with page-level annotation
|
||||
- Unified shell: Library · Sticky board · Search · Settings
|
||||
- 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
|
||||
- **Local OCR** — pluggable, fully on-device. An embedded ONNX recognition
|
||||
backend (cross-platform, CPU/iGPU) with a graceful fallback to the platform's
|
||||
built-in OCR (Windows). See [Local OCR](#local-ocr).
|
||||
- Always-on diagnostics + one-click diagnostic pack export (Settings)
|
||||
- Optional self-hosted **BadNote Server** (`/api/v1`: vault assist + OCR jobs) — see [server/README.md](server/README.md)
|
||||
- **Local OCR** — ONNX when bundled, else Windows WinRT
|
||||
- WebDAV vault sync (NAS)
|
||||
|
||||
## Build (Windows)
|
||||
|
||||
|
||||
7888
badnote_input_log-2.txt
Normal file
7888
badnote_input_log-2.txt
Normal file
File diff suppressed because it is too large
Load Diff
446
docs/plans/2026-06-21-badnote-full-refactor.md
Normal file
446
docs/plans/2026-06-21-badnote-full-refactor.md
Normal 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: MF1–MF3 + P0.5 slice + SF1–SF5 + 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** | Low–Med |
|
||||
| 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** | Med–High |
|
||||
| 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 P0–P3** | 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:346–362) 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:281–306, 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:281–306: "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>`, ~8–12 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 ~**10–30 MB** (≈ 1785×2526 px × 4 bytes ≈ 18 MB at 3× of a 595×842 pt page @ ~2 dppt). So **8–12 tiles at 3× would be ~150–350 MB — the old "≤64 MB / 8–12 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 ~8–12 window (cheap). R10's ≤64 MB now applies to **ink + downgraded-tier tiles**; the small high-DPI tile set is budgeted separately (~64–128 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 P0–P3)
|
||||
- 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 C1–C11.
|
||||
|
||||
---
|
||||
|
||||
## 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): ~8–12 mounted pages, a few MB. **`page_tile_cache`** (raster `ui.Image`, DPI-bucketed): the heavy store — a 3×-DPI A4 tile is ~10–30 MB, so 8–12 high-DPI tiles would be ~150–350 MB. **The old "≤64MB / 8–12 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 (~64–128MB, 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:281–306.)
|
||||
- `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 10–13) 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 (3–5)
|
||||
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; SF1–SF5. 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.*
|
||||
134
docs/plans/2026-06-22-badnote-pen-polish.md
Normal file
134
docs/plans/2026-06-22-badnote-pen-polish.md
Normal 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 M1–M4 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:456–459); **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 W1–W3. |
|
||||
|
||||
**Non-goals here:** no new layout modes, board, search, server, or CAS (those stay in the parent roadmap's P1–P5). 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.002–0.02`), a **highlighter size** slider, and a **pressure sensitivity** slider (drives `pressureSensitivity` `0–1`). 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:466–474`), 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:441–448`) 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:56–64`). 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:73–118`) — 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:134–136`, `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.
|
||||
602
docs/plans/2026-06-24-file-based-storage.md
Normal file
602
docs/plans/2026-06-24-file-based-storage.md
Normal 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 0–1 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 2–4, 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).
|
||||
45
docs/plans/surface-diagnostic-checklist.md
Normal file
45
docs/plans/surface-diagnostic-checklist.md
Normal 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` 发回即可;无需录屏(可选)。
|
||||
323
docs/research/pen-brush-spec.md
Normal file
323
docs/research/pen-brush-spec.md
Normal 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 − Pk−1)/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 4–8
|
||||
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
|
||||
@@ -1,166 +1,13 @@
|
||||
// integration_test/coordinate_assertion_test.dart
|
||||
//
|
||||
// M1 MUST #2 (plan §2.1 / §10): a marker painted at normalized (0.5, 0.5) on a
|
||||
// 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.
|
||||
// Coordinate gate previously used spike_editor_pane (retired).
|
||||
// Re-run on Surface via docs/plans/surface-diagnostic-checklist.md
|
||||
// once a PenEditorScreen-based harness is restored.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_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() {
|
||||
// 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();
|
||||
pdfrxFlutterInitialize();
|
||||
|
||||
late File pdfFile;
|
||||
|
||||
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;
|
||||
testWidgets('coordinate_assertion retired with spike pane', (tester) async {
|
||||
}, skip: true);
|
||||
}
|
||||
|
||||
@@ -1,245 +1,16 @@
|
||||
// integration_test/perf_scroll_bench.dart
|
||||
//
|
||||
// M1 MUST #4 / MUST #5 harness (plan §7.1 / §10).
|
||||
//
|
||||
// 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).
|
||||
// Spike-based bench retired with own-canvas architecture.
|
||||
// See docs/plans/surface-diagnostic-checklist.md for device gates.
|
||||
// Replacement bench will target PenEditorScreen + PageTileCache.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_test/flutter_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() {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
// Report raw frame timings to the device lab / driver too.
|
||||
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
||||
pdfrxFlutterInitialize();
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('MUST #4/#5 fling-scroll frame-timing bench', (tester) async {
|
||||
final pdf = File(_kPdfPath);
|
||||
if (!pdf.existsSync()) {
|
||||
stdout.writeln('SKIP: $_kPdfPath not found — run tool/gen_bench_pdf.dart.');
|
||||
return;
|
||||
testWidgets('perf_scroll_bench retired — use Surface diagnostic checklist',
|
||||
(tester) async {
|
||||
// ignore: avoid_print
|
||||
print('SKIP: spike_editor_pane deleted; run Surface checklist instead.');
|
||||
}, 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
7
l10n.yaml
Normal 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
|
||||
164
lib/diagnostics/badnote_log.dart
Normal file
164
lib/diagnostics/badnote_log.dart
Normal 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);
|
||||
}
|
||||
177
lib/diagnostics/diagnostic_chrome.dart
Normal file
177
lib/diagnostics/diagnostic_chrome.dart
Normal 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')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
154
lib/diagnostics/diagnostic_export.dart
Normal file
154
lib/diagnostics/diagnostic_export.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
99
lib/diagnostics/frame_sampler.dart
Normal file
99
lib/diagnostics/frame_sampler.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
121
lib/diagnostics/pen_event_ring.dart
Normal file
121
lib/diagnostics/pen_event_ring.dart
Normal 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
115
lib/editor/board/board.dart
Normal 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);
|
||||
}
|
||||
61
lib/editor/canvas/editor_tool.dart
Normal file
61
lib/editor/canvas/editor_tool.dart
Normal 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,
|
||||
}
|
||||
303
lib/editor/canvas/ink_painters.dart
Normal file
303
lib/editor/canvas/ink_painters.dart
Normal file
@@ -0,0 +1,303 @@
|
||||
// lib/editor/canvas/ink_painters.dart
|
||||
//
|
||||
// CustomPainters for the ink layers. Strokes are stored in normalized page
|
||||
// coordinates; both painters receive the on-screen page [Size] and scale
|
||||
// points into pixels at paint time. perfect_freehand produces the outline.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
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';
|
||||
|
||||
/// Builds a filled outline [Path] for one stroke (already scaled to pixels).
|
||||
///
|
||||
/// [pageSize] maps normalized coords to pixels. [isComplete] should be false
|
||||
/// for the in-progress live stroke so freehand tapers correctly. [thinning] is
|
||||
/// 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 hasRealPressure = stroke.points.any((p) => p.pressure != null);
|
||||
final isHighlighter = stroke.kind == PenStrokeKind.highlighter;
|
||||
|
||||
final pfPoints = stroke.points
|
||||
.map(
|
||||
(p) => pf.PointVector(
|
||||
p.x * pageSize.width,
|
||||
p.y * pageSize.height,
|
||||
p.pressure ?? 0.5,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// Route through THE shared recipe (stroke_geometry.freehandOutlinePoints) so
|
||||
// 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,
|
||||
isHighlighter: isHighlighter,
|
||||
hasRealPressure: hasRealPressure,
|
||||
isComplete: isComplete,
|
||||
thinning: thinning,
|
||||
brush: brushProfileFor(stroke.brush),
|
||||
);
|
||||
|
||||
final path = Path();
|
||||
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 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
|
||||
/// list identity or page size changes (kept behind a RepaintBoundary).
|
||||
class StaticInkPainter extends CustomPainter {
|
||||
StaticInkPainter({
|
||||
required this.strokes,
|
||||
required this.pageSize,
|
||||
this.thinning = kDefaultPenThinning,
|
||||
});
|
||||
|
||||
final List<PenStroke> strokes;
|
||||
final Size pageSize;
|
||||
|
||||
/// Pressure→width response shared with the live/export paths.
|
||||
final double thinning;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
for (final stroke in strokes) {
|
||||
final path =
|
||||
buildStrokePath(stroke, pageSize, isComplete: true, thinning: thinning);
|
||||
if (path.getBounds().isEmpty) continue;
|
||||
// Single drawPath per stroke ⇒ a highlighter's own self-overlap never
|
||||
// darkens; cross-stroke overlap darkens via BlendMode.multiply (marker).
|
||||
canvas.drawPath(path, paintForStroke(stroke));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(StaticInkPainter old) =>
|
||||
!identical(old.strokes, strokes) ||
|
||||
old.strokes.length != strokes.length ||
|
||||
old.pageSize != pageSize ||
|
||||
old.thinning != thinning;
|
||||
}
|
||||
|
||||
/// Eraser preview: shows the eraser circle and faintly highlights the committed
|
||||
/// strokes the eraser would delete, so the user can see what is about to go.
|
||||
/// Mounted only while the eraser is the active mode and the pen is near the
|
||||
/// 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;
|
||||
|
||||
final Size pageSize;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final c = cursor.value;
|
||||
if (c == null) return;
|
||||
|
||||
// CHEAP, ACCURATE highlight: trace ONLY the point-runs inside the eraser
|
||||
// radius — i.e. exactly what splitStrokeByCircle will remove — as a plain
|
||||
// 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()
|
||||
..color = const Color(0xFF757575).withValues(alpha: 0.7)
|
||||
..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,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(SelectionOverlayPainter old) =>
|
||||
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;
|
||||
}
|
||||
107
lib/editor/canvas/input_diagnostics.dart
Normal file
107
lib/editor/canvas/input_diagnostics.dart
Normal 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)}';
|
||||
}
|
||||
}
|
||||
160
lib/editor/canvas/note_background.dart
Normal file
160
lib/editor/canvas/note_background.dart
Normal 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;
|
||||
}
|
||||
337
lib/editor/canvas/office_document_screen.dart
Normal file
337
lib/editor/canvas/office_document_screen.dart
Normal 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;
|
||||
}
|
||||
913
lib/editor/canvas/pen_canvas.dart
Normal file
913
lib/editor/canvas/pen_canvas.dart
Normal file
@@ -0,0 +1,913 @@
|
||||
// lib/editor/canvas/pen_canvas.dart
|
||||
//
|
||||
// Pen-first canvas: ONE shared transform (an InteractiveViewer driven by a
|
||||
// TransformationController we own) zooms/pans BOTH the PDF page bitmap and the
|
||||
// ink layer together. A Listener wrapped around the InteractiveViewer reads raw
|
||||
// pointer kind + pressure and tracks the active pointer COUNT to arbitrate
|
||||
// draw vs pan/zoom — we own the gesture pipeline, pdfrx never sees gestures.
|
||||
//
|
||||
// Gesture arbitration (reimplemented clean-room from Saber's documented model):
|
||||
// - A draw gesture is exactly ONE active pointer that is a stylus / inverted
|
||||
// stylus, OR (when the user's finger-drawing toggle is on) a single finger.
|
||||
// - >= 2 active pointers ALWAYS means pan/zoom (pinch); never draw. If a 2nd
|
||||
// pointer lands while a stroke is in progress, that stroke is discarded
|
||||
// (accidental palm/finger).
|
||||
// - Palm rejection: once any stylus event is seen in a session, finger-drawing
|
||||
// is forced OFF so a resting palm/finger pans instead of marking.
|
||||
// - While a stroke is active, the InteractiveViewer's pan is disabled so it
|
||||
// can't fight the stroke; pinch-zoom still works because a 2nd pointer
|
||||
// cancels the stroke first, re-enabling pan/zoom.
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.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';
|
||||
|
||||
/// The active tool on the pen canvas. Pen/highlighter/eraser are the legacy
|
||||
/// 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 {
|
||||
const PenCanvas({
|
||||
super.key,
|
||||
required this.pageWidget,
|
||||
required this.pageSize,
|
||||
required this.strokes,
|
||||
required this.transformationController,
|
||||
required this.tool,
|
||||
this.brush = BrushKind.fountainPen,
|
||||
this.shapeKind = ShapeKind.line,
|
||||
required this.color,
|
||||
required this.strokeWidth,
|
||||
required this.onStrokeComplete,
|
||||
required this.onEraseStroke,
|
||||
this.selectedStrokeIndex,
|
||||
this.onSelectStroke,
|
||||
this.onMoveStroke,
|
||||
this.allowFingerDrawing = false,
|
||||
this.minScale = 0.5,
|
||||
this.maxScale = 8.0,
|
||||
this.scaleEnabled = true,
|
||||
this.panEnabled = true,
|
||||
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
|
||||
/// (kind / pressure / pressureMin / pressureMax) so we can see what Windows
|
||||
/// actually delivers. Null in release UI.
|
||||
final void Function(String readout)? onPenDebug;
|
||||
|
||||
/// The rendered PDF page bitmap, already sized to [pageSize].
|
||||
final Widget pageWidget;
|
||||
|
||||
/// On-screen size (at scale 1.0) of the page rectangle in logical pixels.
|
||||
/// Ink normalized coords map onto this rectangle.
|
||||
final Size pageSize;
|
||||
|
||||
/// Committed strokes for the CURRENT page (normalized coords).
|
||||
final List<PenStroke> strokes;
|
||||
|
||||
/// Shared transform driving both page and ink.
|
||||
final TransformationController transformationController;
|
||||
|
||||
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;
|
||||
|
||||
/// Pen width as a fraction of page width (so it zooms with the page).
|
||||
final double strokeWidth;
|
||||
|
||||
/// Called with a finished stroke (normalized coords) to commit it.
|
||||
final void Function(PenStroke stroke) onStrokeComplete;
|
||||
|
||||
/// Called to replace committed stroke [strokeIndex] with its surviving pieces
|
||||
/// 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
|
||||
/// seen (palm rejection).
|
||||
final bool allowFingerDrawing;
|
||||
|
||||
final double minScale;
|
||||
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
|
||||
State<PenCanvas> createState() => _PenCanvasState();
|
||||
}
|
||||
|
||||
class _PenCanvasState extends State<PenCanvas> {
|
||||
/// Active (down) pointers by id → their device kind. Size == pointer count.
|
||||
final Map<int, PointerDeviceKind> _activePointers = {};
|
||||
|
||||
/// The pointer id currently driving a stroke, or null.
|
||||
int? _drawPointer;
|
||||
|
||||
/// In-progress stroke points (normalized).
|
||||
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.
|
||||
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
|
||||
/// inverted stylus), detected on hover/down.
|
||||
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
|
||||
// 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
|
||||
// cancels an in-progress stroke regardless.)
|
||||
bool get _fingerDrawingEnabled => widget.allowFingerDrawing;
|
||||
|
||||
bool _isStylus(PointerDeviceKind kind) => arbiter.isStylusKind(kind);
|
||||
|
||||
/// 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
|
||||
/// 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) {
|
||||
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;
|
||||
if (range > 0.0001) {
|
||||
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
|
||||
}
|
||||
// No advertised range (some Windows pen stacks): use the raw normalized
|
||||
// pressure directly if it's a usable non-degenerate value, so we still get
|
||||
// real force instead of falling back to velocity simulation.
|
||||
if (event.pressure > 0.0 && event.pressure < 1.0) {
|
||||
return event.pressure;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The eraser signal. Two sources, ORed:
|
||||
/// 1. Flutter-native: secondary button held or an inverted stylus (works on
|
||||
/// desktop / platforms that surface these).
|
||||
/// 2. Windows native pen plugin: barrel / inverted / eraser flags that
|
||||
/// Flutter 3.44 drops, mapped through the configured side-button /
|
||||
/// eraser-end actions (W3). Level-triggered, so holding the button keeps
|
||||
/// erasing — correct for an eraser.
|
||||
bool _isEraserSignal(PointerEvent event) {
|
||||
// BITMASK test, not equality: Flutter defines kPrimaryStylusButton == 0x02
|
||||
// == kSecondaryButton, and kStylusContact == 0x01. While the pen TIP is
|
||||
// down with the barrel pressed, event.buttons == 0x03, so `== kSecondaryButton`
|
||||
// (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;
|
||||
}
|
||||
|
||||
/// 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 ---------------------------------------------------
|
||||
|
||||
/// Map a global pointer position into normalized page coords using the
|
||||
/// shared transform (inverse) and this widget's geometry.
|
||||
PenPoint? _toNormalized(Offset globalPosition, double? pressure,
|
||||
{double? tilt, Duration? timeStamp}) {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box == null) return null;
|
||||
final local = box.globalToLocal(globalPosition);
|
||||
|
||||
// Undo the InteractiveViewer transform to get scene (untransformed) coords.
|
||||
final scene = widget.transformationController.toScene(local);
|
||||
|
||||
final nx = scene.dx / widget.pageSize.width;
|
||||
final ny = scene.dy / widget.pageSize.height;
|
||||
|
||||
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 -----------------------------------------------------
|
||||
|
||||
void _startStroke(PointerDownEvent event) {
|
||||
_drawPointer = event.pointer;
|
||||
_livePoints.clear();
|
||||
_realPointCount = 0;
|
||||
_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) {
|
||||
_eraserCursor.value = p;
|
||||
_eraseAt(p);
|
||||
// No setState here: the preview repaints via the notifier, and any erased
|
||||
// stroke repaints via the editor's onEraseStroke setState. (_liveStroke is
|
||||
// already null in eraser mode.)
|
||||
if (_liveStroke != null) setState(() => _liveStroke = null);
|
||||
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();
|
||||
}
|
||||
|
||||
void _extendStroke(PointerMoveEvent event) {
|
||||
final p = _toNormalized(event.position, _normalizedPressure(event),
|
||||
tilt: _tiltFor(event), timeStamp: event.timeStamp);
|
||||
if (p == null) return;
|
||||
|
||||
if (_eraserActive || widget.tool == CanvasTool.eraser) {
|
||||
_eraserCursor.value = p;
|
||||
_eraseAt(p);
|
||||
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);
|
||||
_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();
|
||||
}
|
||||
|
||||
void _endStroke() {
|
||||
if (_drawPointer == null) return;
|
||||
final tool = widget.tool;
|
||||
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(
|
||||
PenStroke(
|
||||
points: List.of(_livePoints),
|
||||
color: _currentColor().toARGB32(),
|
||||
width: widget.strokeWidth,
|
||||
kind: _currentKind(),
|
||||
brush: _currentBrush,
|
||||
),
|
||||
);
|
||||
}
|
||||
_drawPointer = null;
|
||||
_shapeStart = null;
|
||||
_selectLast = null;
|
||||
_selectDragging = false;
|
||||
_livePoints.clear();
|
||||
_realPointCount = 0;
|
||||
_predictor.reset();
|
||||
_eraserCursor.value = null; // hide the preview when the pen lifts
|
||||
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).
|
||||
void _cancelStroke() {
|
||||
_drawPointer = null;
|
||||
_livePoints.clear();
|
||||
_eraserCursor.value = null;
|
||||
setState(() => _liveStroke = null);
|
||||
}
|
||||
|
||||
void _updateLiveStroke() {
|
||||
setState(() {
|
||||
_liveStroke = PenStroke(
|
||||
points: List.of(_livePoints),
|
||||
color: _currentColor().toARGB32(),
|
||||
width: widget.strokeWidth,
|
||||
kind: _currentKind(),
|
||||
brush: _currentBrush,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
PenStrokeKind _currentKind() =>
|
||||
widget.tool == CanvasTool.highlighter
|
||||
? PenStrokeKind.highlighter
|
||||
: PenStrokeKind.pen;
|
||||
|
||||
Color _currentColor() => widget.tool == CanvasTool.highlighter
|
||||
? widget.color.withAlpha(0x80)
|
||||
: widget.color;
|
||||
|
||||
/// 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) {
|
||||
if (p == null) return;
|
||||
final radius = widget.eraserRadius; // normalized (page-width fraction)
|
||||
final aspect = _pageAspect;
|
||||
for (var i = widget.strokes.length - 1; i >= 0; i--) {
|
||||
final stroke = widget.strokes[i];
|
||||
if (!strokeHit(stroke, p.x, p.y, radius, aspect: aspect)) continue;
|
||||
// Stroke-eraser mode: a hit removes the entire stroke (empty replacement).
|
||||
// Point-eraser mode (default): cut out the touched span, keep the rest.
|
||||
final pieces = widget.eraserWholeStroke
|
||||
? 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;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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) {
|
||||
final cb = widget.onPenDebug;
|
||||
if (cb == null) return;
|
||||
final norm = _normalizedPressure(event);
|
||||
if (norm != null && norm > _peakNorm) _peakNorm = norm;
|
||||
cb('${event.kind.name} raw=${event.pressure.toStringAsFixed(1)}'
|
||||
'/${event.pressureMax.toStringAsFixed(0)} '
|
||||
'norm=${norm?.toStringAsFixed(3) ?? "null"} '
|
||||
'peak=${_peakNorm.toStringAsFixed(3)} '
|
||||
'btn=${event.buttons} tilt=${event.tilt.toStringAsFixed(2)}'
|
||||
'\n${PenInputService.instance.debugSummary}');
|
||||
}
|
||||
|
||||
void _onPointerHover(PointerHoverEvent event) {
|
||||
if (_isStylus(event.kind)) {
|
||||
_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.
|
||||
_eraserActive = _isEraserSignal(event);
|
||||
}
|
||||
}
|
||||
|
||||
void _onPointerDown(PointerDownEvent event) {
|
||||
if (event.kind == PointerDeviceKind.trackpad) return;
|
||||
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;
|
||||
|
||||
// A 2nd pointer arriving during a stroke = pinch/palm → cancel the stroke
|
||||
// and let the InteractiveViewer take over pan/zoom.
|
||||
if (_activePointers.length >= 2) {
|
||||
if (_drawPointer != null) _cancelStroke();
|
||||
return;
|
||||
}
|
||||
|
||||
// Single pointer: decide draw vs pan. Eraser is on if this stylus down
|
||||
// signals it (barrel button / inverted), or hover already flagged it.
|
||||
if (_isStylus(event.kind)) {
|
||||
_eraserActive = _eraserActive || _isEraserSignal(event);
|
||||
} else {
|
||||
_eraserActive = false;
|
||||
}
|
||||
|
||||
if (_shouldDraw(event.kind)) {
|
||||
_startStroke(event);
|
||||
}
|
||||
}
|
||||
|
||||
void _onPointerMove(PointerMoveEvent event) {
|
||||
if (_isStylus(event.kind)) _emitPenDebug(event);
|
||||
if (event.pointer != _drawPointer) return;
|
||||
if (_activePointers.length >= 2) return; // pinch owns it
|
||||
_extendStroke(event);
|
||||
}
|
||||
|
||||
void _onPointerUp(PointerUpEvent event) {
|
||||
final wasDrawer = event.pointer == _drawPointer;
|
||||
_activePointers.remove(event.pointer);
|
||||
if (wasDrawer) _endStroke();
|
||||
}
|
||||
|
||||
void _onPointerCancel(PointerCancelEvent event) {
|
||||
final wasDrawer = event.pointer == _drawPointer;
|
||||
_activePointers.remove(event.pointer);
|
||||
if (wasDrawer) _cancelStroke();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eraserCursor.dispose();
|
||||
_inkCache.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// The PEN never reaches PenInteractiveViewer's recognizer (it excludes
|
||||
// stylus), so a stylus stroke can never be stolen as a pan. panEnabled only
|
||||
// governs touch/mouse: suppress pan while a single-finger / mouse stroke is
|
||||
// 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(
|
||||
onPointerHover: _onPointerHover,
|
||||
onPointerDown: _onPointerDown,
|
||||
onPointerMove: _onPointerMove,
|
||||
onPointerUp: _onPointerUp,
|
||||
onPointerCancel: _onPointerCancel,
|
||||
child: PenInteractiveViewer(
|
||||
transformationController: widget.transformationController,
|
||||
minScale: widget.minScale,
|
||||
maxScale: widget.maxScale,
|
||||
panEnabled: panEnabled,
|
||||
scaleEnabled: widget.scaleEnabled,
|
||||
child: SizedBox(
|
||||
width: widget.pageSize.width,
|
||||
height: widget.pageSize.height,
|
||||
child: Stack(
|
||||
children: [
|
||||
// PDF page bitmap. Wrapped in its own RepaintBoundary (W2) so the
|
||||
// per-move live-ink repaints and the static-ink repaints never
|
||||
// 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(
|
||||
child: RepaintBoundary(
|
||||
child: CustomPaint(
|
||||
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,
|
||||
cursor: _eraserCursor,
|
||||
radius: widget.eraserRadius,
|
||||
aspect: _pageAspect,
|
||||
pageSize: widget.pageSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Live ink (current stroke only, isolated repaint). Also carries
|
||||
// the SHAPE tool's preview (built as a live PenStroke).
|
||||
Positioned.fill(
|
||||
child: RepaintBoundary(
|
||||
child: CustomPaint(
|
||||
painter: render.LiveInkPainter(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
3166
lib/editor/canvas/pen_editor_screen.dart
Normal file
3166
lib/editor/canvas/pen_editor_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
526
lib/editor/canvas/pen_interactive_viewer.dart
Normal file
526
lib/editor/canvas/pen_interactive_viewer.dart
Normal 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);
|
||||
}
|
||||
921
lib/editor/canvas/pen_note_screen.dart
Normal file
921
lib/editor/canvas/pen_note_screen.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
547
lib/editor/canvas/pen_palette_widgets.dart
Normal file
547
lib/editor/canvas/pen_palette_widgets.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
681
lib/editor/canvas/pen_slide_screen.dart
Normal file
681
lib/editor/canvas/pen_slide_screen.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
62
lib/editor/canvas/pen_stroke.dart
Normal file
62
lib/editor/canvas/pen_stroke.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
// lib/editor/canvas/pen_stroke.dart
|
||||
//
|
||||
// Pen-first canvas stroke model. Points are stored in NORMALIZED page
|
||||
// coordinates ([0,1] x [0,1] relative to the page rectangle) so strokes stay
|
||||
// pinned to the page regardless of zoom/pan or the on-screen page size.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../engine/brush.dart';
|
||||
|
||||
/// A single captured sample of a stroke.
|
||||
///
|
||||
/// [x]/[y] are normalized to the page rectangle ([0,1]).
|
||||
/// [pressure] is the normalized stylus pressure ([0,1]) or null when the
|
||||
/// 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
|
||||
class PenPoint {
|
||||
const PenPoint(this.x, this.y, this.pressure, {this.tilt});
|
||||
|
||||
final double x;
|
||||
final double y;
|
||||
final double? pressure;
|
||||
final double? tilt;
|
||||
}
|
||||
|
||||
/// Which kind of mark a stroke is.
|
||||
enum PenStrokeKind { pen, highlighter }
|
||||
|
||||
/// A committed stroke for a single page, in normalized page coordinates.
|
||||
@immutable
|
||||
class PenStroke {
|
||||
const PenStroke({
|
||||
required this.points,
|
||||
required this.color,
|
||||
required this.width,
|
||||
required this.kind,
|
||||
this.brush = BrushKind.fountainPen,
|
||||
});
|
||||
|
||||
/// Normalized points (see [PenPoint]).
|
||||
final List<PenPoint> points;
|
||||
|
||||
/// ARGB color value.
|
||||
final int color;
|
||||
|
||||
/// Stroke width expressed as a fraction of the page width, so it scales with
|
||||
/// the page when zoomed. Multiply by the on-screen page width to get pixels.
|
||||
final double width;
|
||||
|
||||
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;
|
||||
}
|
||||
103
lib/editor/canvas/pinch_scale_solver.dart
Normal file
103
lib/editor/canvas/pinch_scale_solver.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
291
lib/editor/canvas/sticky_note_overlay.dart
Normal file
291
lib/editor/canvas/sticky_note_overlay.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
292
lib/editor/engine/brush.dart
Normal file
292
lib/editor/engine/brush.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
32
lib/editor/engine/pen_physics.dart
Normal file
32
lib/editor/engine/pen_physics.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
143
lib/editor/engine/shape_geometry.dart
Normal file
143
lib/editor/engine/shape_geometry.dart
Normal 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);
|
||||
}
|
||||
50
lib/editor/engine/stroke_bounds.dart
Normal file
50
lib/editor/engine/stroke_bounds.dart
Normal 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;
|
||||
}
|
||||
106
lib/editor/engine/stroke_eraser.dart
Normal file
106
lib/editor/engine/stroke_eraser.dart
Normal 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;
|
||||
}
|
||||
204
lib/editor/engine/stroke_geometry.dart
Normal file
204
lib/editor/engine/stroke_geometry.dart
Normal 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;
|
||||
}
|
||||
52
lib/editor/engine/stroke_host.dart
Normal file
52
lib/editor/engine/stroke_host.dart
Normal 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)';
|
||||
}
|
||||
212
lib/editor/engine/stroke_model.dart
Normal file
212
lib/editor/engine/stroke_model.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
687
lib/editor/engine/stroke_model.freezed.dart
Normal file
687
lib/editor/engine/stroke_model.freezed.dart
Normal 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;
|
||||
}
|
||||
88
lib/editor/engine/stroke_model.g.dart
Normal file
88
lib/editor/engine/stroke_model.g.dart
Normal 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',
|
||||
};
|
||||
56
lib/editor/engine/stroke_predictor.dart
Normal file
56
lib/editor/engine/stroke_predictor.dart
Normal 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;
|
||||
}
|
||||
80
lib/editor/engine/stroke_simplify.dart
Normal file
80
lib/editor/engine/stroke_simplify.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
// lib/editor/engine/stroke_simplify.dart
|
||||
//
|
||||
// Ramer–Douglas–Peucker 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;
|
||||
}
|
||||
56
lib/editor/engine/stroke_store.dart
Normal file
56
lib/editor/engine/stroke_store.dart
Normal 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++;
|
||||
}
|
||||
}
|
||||
75
lib/editor/engine/undo_stack.dart
Normal file
75
lib/editor/engine/undo_stack.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
38
lib/editor/input/diagnostic_logger.dart
Normal file
38
lib/editor/input/diagnostic_logger.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
46
lib/editor/input/input_arbiter.dart
Normal file
46
lib/editor/input/input_arbiter.dart
Normal 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;
|
||||
}
|
||||
310
lib/editor/input/pen_config.dart
Normal file
310
lib/editor/input/pen_config.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
201
lib/editor/input/pen_input_service.dart
Normal file
201
lib/editor/input/pen_input_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
213
lib/editor/input/pen_slots.dart
Normal file
213
lib/editor/input/pen_slots.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
112
lib/editor/input/pressure_curve.dart
Normal file
112
lib/editor/input/pressure_curve.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
88
lib/editor/input/tool_settings.dart
Normal file
88
lib/editor/input/tool_settings.dart
Normal 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));
|
||||
}
|
||||
75
lib/editor/layout/double_page_layout.dart
Normal file
75
lib/editor/layout/double_page_layout.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
196
lib/editor/layout/page_viewport.dart
Normal file
196
lib/editor/layout/page_viewport.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
39
lib/editor/layout/viewport_fit.dart
Normal file
39
lib/editor/layout/viewport_fit.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
85
lib/editor/link/link_graph.dart
Normal file
85
lib/editor/link/link_graph.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
103
lib/editor/notebook/ink_stroke_adapter.dart
Normal file
103
lib/editor/notebook/ink_stroke_adapter.dart
Normal 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();
|
||||
121
lib/editor/notebook/page_map.dart
Normal file
121
lib/editor/notebook/page_map.dart
Normal 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)';
|
||||
}
|
||||
137
lib/editor/pdf/page_tile_cache.dart
Normal file
137
lib/editor/pdf/page_tile_cache.dart
Normal 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;
|
||||
}
|
||||
61
lib/editor/pdf/page_tile_layer.dart
Normal file
61
lib/editor/pdf/page_tile_layer.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
45
lib/editor/pdf/pdf_document_source.dart
Normal file
45
lib/editor/pdf/pdf_document_source.dart
Normal 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);
|
||||
}
|
||||
42
lib/editor/pdf/pdfrx_page_document_source.dart
Normal file
42
lib/editor/pdf/pdfrx_page_document_source.dart
Normal 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];
|
||||
}
|
||||
36
lib/editor/pdf/slide_export.dart
Normal file
36
lib/editor/pdf/slide_export.dart
Normal 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;
|
||||
@@ -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'),
|
||||
])),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,34 +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 'spike_editor_pane.dart';
|
||||
|
||||
/// Opens a file picker for a PDF, then pushes the spike pane on it.
|
||||
/// Pass the user's own large PDF to get a realistic 60fps / pen test.
|
||||
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: (_) => Scaffold(
|
||||
appBar: AppBar(title: const Text('M1 Spike — pen / scroll / zoom')),
|
||||
// denseStrokesAsset is null here: the bundled synthetic-stroke load is
|
||||
// only for the perf bench. For manual on-device testing, draw real ink
|
||||
// and scroll a real large PDF while watching the frame-time HUD.
|
||||
body: SpikeEditorPane(pdfPath: path),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
149
lib/editor/persistence/editor_repository.dart
Normal file
149
lib/editor/persistence/editor_repository.dart
Normal 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';
|
||||
}
|
||||
98
lib/editor/persistence/save_scheduler.dart
Normal file
98
lib/editor/persistence/save_scheduler.dart
Normal 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;
|
||||
}
|
||||
58
lib/editor/persistence/sidecar_flush_observer.dart
Normal file
58
lib/editor/persistence/sidecar_flush_observer.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
440
lib/editor/persistence/sidecar_repository.dart
Normal file
440
lib/editor/persistence/sidecar_repository.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
71
lib/editor/render/annotation_layer.dart
Normal file
71
lib/editor/render/annotation_layer.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
87
lib/editor/render/ink_picture_cache.dart
Normal file
87
lib/editor/render/ink_picture_cache.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
50
lib/editor/render/live_ink_painter.dart
Normal file
50
lib/editor/render/live_ink_painter.dart
Normal 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;
|
||||
}
|
||||
77
lib/editor/render/static_ink_painter.dart
Normal file
77
lib/editor/render/static_ink_painter.dart
Normal 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;
|
||||
}
|
||||
80
lib/editor/search/search_ranking.dart
Normal file
80
lib/editor/search/search_ranking.dart
Normal 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];
|
||||
}
|
||||
73
lib/editor/search/search_snippet.dart
Normal file
73
lib/editor/search/search_snippet.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
30
lib/editor/search/search_text.dart
Normal file
30
lib/editor/search/search_text.dart
Normal 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
10
lib/editor/stroke.dart
Normal 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;
|
||||
57
lib/editor/ui/page_nav_shortcuts.dart
Normal file
57
lib/editor/ui/page_nav_shortcuts.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
390
lib/editor/ui/pen_settings_page.dart
Normal file
390
lib/editor/ui/pen_settings_page.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
235
lib/editor/ui/thumbnail_grid.dart
Normal file
235
lib/editor/ui/thumbnail_grid.dart
Normal 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
254
lib/l10n/app_en.arb
Normal 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"
|
||||
}
|
||||
1250
lib/l10n/app_localizations.dart
Normal file
1250
lib/l10n/app_localizations.dart
Normal file
File diff suppressed because it is too large
Load Diff
623
lib/l10n/app_localizations_en.dart
Normal file
623
lib/l10n/app_localizations_en.dart
Normal 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';
|
||||
}
|
||||
615
lib/l10n/app_localizations_zh.dart
Normal file
615
lib/l10n/app_localizations_zh.dart
Normal 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
224
lib/l10n/app_zh.arb
Normal 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": "拖动移动"
|
||||
}
|
||||
216
lib/main.dart
216
lib/main.dart
@@ -1,19 +1,26 @@
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
import 'package:shared_preferences/shared_preferences.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 'screens/home_screen.dart';
|
||||
import 'screens/app_shell.dart';
|
||||
import 'screens/vault_setup_screen.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 {
|
||||
// Kind-aware binding (extends WidgetsFlutterBinding) must be the active
|
||||
// binding before runApp so the M1 spike's PenCaptureRegion can gate
|
||||
// hit-testing by pointer kind. Safe for the rest of the app: with no pen
|
||||
// region mounted it behaves exactly like the default binding.
|
||||
// Kind-gated PDF pen capture MUST install before runApp — without it
|
||||
// PenCaptureRegion.currentPointerKind stays null and stylus ink never hits.
|
||||
PenCaptureBinding.ensureInitialized();
|
||||
// pdfrx native engine init (required before any PdfViewer is built).
|
||||
pdfrxFlutterInitialize();
|
||||
@@ -24,40 +31,191 @@ Future<void> main() async {
|
||||
// Initialize SharedPreferences
|
||||
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()));
|
||||
}
|
||||
|
||||
class BadNoteApp extends ConsumerWidget {
|
||||
class BadNoteApp extends ConsumerStatefulWidget {
|
||||
const BadNoteApp({super.key});
|
||||
|
||||
@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);
|
||||
|
||||
// Material You: prefer the OS dynamic color (Windows/Android system accent);
|
||||
// fall back to the user's seed color when the platform provides none.
|
||||
return DynamicColorBuilder(
|
||||
builder: (lightDynamic, darkDynamic) {
|
||||
final lightScheme = lightDynamic?.harmonized() ??
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: settings.colorSchemeSeed,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
final darkScheme = darkDynamic?.harmonized() ??
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: settings.colorSchemeSeed,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
return MaterialApp(
|
||||
title: 'BadNote',
|
||||
themeMode: settings.themeMode,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: settings.colorSchemeSeed,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
textTheme: GoogleFonts.interTextTheme(
|
||||
ThemeData(brightness: Brightness.light).textTheme,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: settings.colorSchemeSeed,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
textTheme: GoogleFonts.interTextTheme(
|
||||
ThemeData(brightness: Brightness.dark).textTheme,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const HomeScreen(),
|
||||
theme: AppTheme.fromScheme(lightScheme),
|
||||
darkTheme: AppTheme.fromScheme(darkScheme),
|
||||
// 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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Startup gate: shows [HomeScreen] only once a valid vault root folder has been
|
||||
/// chosen. If none is set — or the saved folder no longer exists — it shows
|
||||
/// [VaultSetupScreen] first (re-prompting on a missing folder rather than
|
||||
/// silently scattering data elsewhere). Phase 0: records the vault path only;
|
||||
/// 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,50 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'bookmark.freezed.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
|
||||
abstract class Bookmark with _$Bookmark {
|
||||
const factory Bookmark({
|
||||
required String id,
|
||||
required String documentId,
|
||||
|
||||
/// 1-based page number this bookmark lives on.
|
||||
required int pageNumber,
|
||||
@Default('') String label,
|
||||
@Default(0xFF2196F3) int color,
|
||||
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;
|
||||
|
||||
factory Bookmark.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -23,11 +23,27 @@ Bookmark _$BookmarkFromJson(Map<String, dynamic> json) {
|
||||
mixin _$Bookmark {
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
String get documentId => throw _privateConstructorUsedError;
|
||||
|
||||
/// 1-based page number this bookmark lives on.
|
||||
int get pageNumber => throw _privateConstructorUsedError;
|
||||
String get label => throw _privateConstructorUsedError;
|
||||
int get color => 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.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
|
||||
@@ -50,6 +66,11 @@ abstract class $BookmarkCopyWith<$Res> {
|
||||
String label,
|
||||
int color,
|
||||
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? color = null,
|
||||
Object? createdAt = null,
|
||||
Object? anchorLeft = freezed,
|
||||
Object? anchorTop = freezed,
|
||||
Object? anchorRight = freezed,
|
||||
Object? anchorBottom = freezed,
|
||||
Object? charIndex = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
@@ -101,6 +127,26 @@ class _$BookmarkCopyWithImpl<$Res, $Val extends Bookmark>
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
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,
|
||||
);
|
||||
@@ -123,6 +169,11 @@ abstract class _$$BookmarkImplCopyWith<$Res>
|
||||
String label,
|
||||
int color,
|
||||
DateTime createdAt,
|
||||
double? anchorLeft,
|
||||
double? anchorTop,
|
||||
double? anchorRight,
|
||||
double? anchorBottom,
|
||||
int? charIndex,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,6 +197,11 @@ class __$$BookmarkImplCopyWithImpl<$Res>
|
||||
Object? label = null,
|
||||
Object? color = null,
|
||||
Object? createdAt = null,
|
||||
Object? anchorLeft = freezed,
|
||||
Object? anchorTop = freezed,
|
||||
Object? anchorRight = freezed,
|
||||
Object? anchorBottom = freezed,
|
||||
Object? charIndex = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_$BookmarkImpl(
|
||||
@@ -173,6 +229,26 @@ class __$$BookmarkImplCopyWithImpl<$Res>
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
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.color = 0xFF2196F3,
|
||||
required this.createdAt,
|
||||
this.anchorLeft,
|
||||
this.anchorTop,
|
||||
this.anchorRight,
|
||||
this.anchorBottom,
|
||||
this.charIndex,
|
||||
});
|
||||
|
||||
factory _$BookmarkImpl.fromJson(Map<String, dynamic> json) =>
|
||||
@@ -197,6 +278,8 @@ class _$BookmarkImpl implements _Bookmark {
|
||||
final String id;
|
||||
@override
|
||||
final String documentId;
|
||||
|
||||
/// 1-based page number this bookmark lives on.
|
||||
@override
|
||||
final int pageNumber;
|
||||
@override
|
||||
@@ -208,9 +291,28 @@ class _$BookmarkImpl implements _Bookmark {
|
||||
@override
|
||||
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
|
||||
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
|
||||
@@ -226,7 +328,17 @@ class _$BookmarkImpl implements _Bookmark {
|
||||
(identical(other.label, label) || other.label == label) &&
|
||||
(identical(other.color, color) || other.color == color) &&
|
||||
(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)
|
||||
@@ -239,6 +351,11 @@ class _$BookmarkImpl implements _Bookmark {
|
||||
label,
|
||||
color,
|
||||
createdAt,
|
||||
anchorLeft,
|
||||
anchorTop,
|
||||
anchorRight,
|
||||
anchorBottom,
|
||||
charIndex,
|
||||
);
|
||||
|
||||
/// Create a copy of Bookmark
|
||||
@@ -263,6 +380,11 @@ abstract class _Bookmark implements Bookmark {
|
||||
final String label,
|
||||
final int color,
|
||||
required final DateTime createdAt,
|
||||
final double? anchorLeft,
|
||||
final double? anchorTop,
|
||||
final double? anchorRight,
|
||||
final double? anchorBottom,
|
||||
final int? charIndex,
|
||||
}) = _$BookmarkImpl;
|
||||
|
||||
factory _Bookmark.fromJson(Map<String, dynamic> json) =
|
||||
@@ -272,6 +394,8 @@ abstract class _Bookmark implements Bookmark {
|
||||
String get id;
|
||||
@override
|
||||
String get documentId;
|
||||
|
||||
/// 1-based page number this bookmark lives on.
|
||||
@override
|
||||
int get pageNumber;
|
||||
@override
|
||||
@@ -281,6 +405,25 @@ abstract class _Bookmark implements Bookmark {
|
||||
@override
|
||||
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
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
|
||||
@@ -14,6 +14,11 @@ _$BookmarkImpl _$$BookmarkImplFromJson(Map<String, dynamic> json) =>
|
||||
label: json['label'] as String? ?? '',
|
||||
color: (json['color'] as num?)?.toInt() ?? 0xFF2196F3,
|
||||
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) =>
|
||||
@@ -24,4 +29,9 @@ Map<String, dynamic> _$$BookmarkImplToJson(_$BookmarkImpl instance) =>
|
||||
'label': instance.label,
|
||||
'color': instance.color,
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
'anchorLeft': instance.anchorLeft,
|
||||
'anchorTop': instance.anchorTop,
|
||||
'anchorRight': instance.anchorRight,
|
||||
'anchorBottom': instance.anchorBottom,
|
||||
'charIndex': instance.charIndex,
|
||||
};
|
||||
|
||||
103
lib/models/scratch_link.dart
Normal file
103
lib/models/scratch_link.dart
Normal 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)';
|
||||
}
|
||||
@@ -1,61 +1,68 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/document.dart';
|
||||
import '../services/database_service.dart';
|
||||
import 'note_provider.dart';
|
||||
import '../services/vault_service.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
final vaultServiceProvider = FutureProvider<VaultService>((ref) async {
|
||||
return VaultService.getInstance();
|
||||
});
|
||||
|
||||
final documentListProvider =
|
||||
AsyncNotifierProvider<DocumentListNotifier, List<Document>>(
|
||||
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>> {
|
||||
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future);
|
||||
Future<VaultService> get _vault =>
|
||||
ref.read(vaultServiceProvider.future);
|
||||
|
||||
@override
|
||||
Future<List<Document>> build() async {
|
||||
final db = await _db;
|
||||
return db.getAllDocuments();
|
||||
return _scan();
|
||||
}
|
||||
|
||||
/// Reloads documents from the database and publishes the result to [state]
|
||||
/// so the UI rebuilds. Used by pull-to-refresh.
|
||||
Future<List<Document>> _scan() async {
|
||||
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 {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
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;
|
||||
state = await AsyncValue.guard(_scan);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final db = await _db;
|
||||
await db.deleteDocument(id);
|
||||
final dir = Directory(id);
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
final current = state.value ?? [];
|
||||
state = AsyncData(current.where((d) => d.id != id).toList());
|
||||
}
|
||||
|
||||
@@ -1,68 +1,81 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/note.dart';
|
||||
import '../services/database_service.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
|
||||
final databaseServiceProvider = FutureProvider<DatabaseService>((ref) async {
|
||||
return DatabaseService.getInstance();
|
||||
});
|
||||
import '../services/vault_service.dart';
|
||||
import 'document_provider.dart' show vaultServiceProvider;
|
||||
|
||||
/// 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>>(
|
||||
NoteListNotifier.new,
|
||||
);
|
||||
|
||||
class NoteListNotifier extends AsyncNotifier<List<Note>> {
|
||||
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future);
|
||||
Future<VaultService> get _vault => ref.read(vaultServiceProvider.future);
|
||||
|
||||
@override
|
||||
Future<List<Note>> build() async {
|
||||
final db = await _db;
|
||||
return db.getAllNotes();
|
||||
return _scan();
|
||||
}
|
||||
|
||||
/// Reloads notes from the database and publishes the result to [state] so
|
||||
/// the UI rebuilds. Used by pull-to-refresh.
|
||||
Future<List<Note>> _scan() async {
|
||||
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 {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final db = await _db;
|
||||
return db.getAllNotes();
|
||||
});
|
||||
state = await AsyncValue.guard(_scan);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final db = await _db;
|
||||
final vault = await _vault;
|
||||
final notePath = await vault.createEmptyNotebook(title);
|
||||
final now = DateTime.now();
|
||||
final note = Note(
|
||||
id: _uuid.v4(),
|
||||
id: notePath,
|
||||
title: title,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await db.insertNote(note);
|
||||
state = AsyncData([note, ...state.value ?? []]);
|
||||
return note;
|
||||
}
|
||||
|
||||
Future<void> updateNote(Note note) async {
|
||||
final db = await _db;
|
||||
await db.updateNote(note);
|
||||
final current = state.value ?? [];
|
||||
state = AsyncData(current.map((n) => n.id == note.id ? note : n).toList());
|
||||
}
|
||||
|
||||
/// Delete a note by removing its notebook folder (the sidecar travels with
|
||||
/// it). [id] is the synthetic note path `<folder>/notebook`.
|
||||
Future<void> deleteNote(String id) async {
|
||||
final db = await _db;
|
||||
await db.deleteNote(id);
|
||||
final folder = Directory(File(id).parent.path);
|
||||
if (await folder.exists()) {
|
||||
await folder.delete(recursive: true);
|
||||
}
|
||||
final current = state.value ?? [];
|
||||
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);
|
||||
});
|
||||
|
||||
46
lib/providers/notebook_container_provider.dart
Normal file
46
lib/providers/notebook_container_provider.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,24 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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 }
|
||||
|
||||
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.
|
||||
///
|
||||
/// This map only ever holds an entry per note that has had OCR triggered in
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/document.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) => '');
|
||||
|
||||
@@ -34,59 +49,45 @@ class DocumentSearchHit extends SearchResult {
|
||||
|
||||
final searchResultsProvider = FutureProvider<List<SearchResult>>((ref) async {
|
||||
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
|
||||
// initialization and disposal like every other consumer.
|
||||
final db = await ref.watch(databaseServiceProvider.future);
|
||||
final index = await ref.watch(vaultSearchIndexProvider.future);
|
||||
|
||||
// Run the note and document searches concurrently.
|
||||
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 hits = await index.search(query);
|
||||
|
||||
final results = <SearchResult>[];
|
||||
|
||||
// Add note results.
|
||||
for (final note in noteHits) {
|
||||
results.add(NoteSearchHit(note: note, snippet: note.title));
|
||||
}
|
||||
|
||||
// Resolve document metadata without an N+1 loop: collect the distinct
|
||||
// document ids referenced by the hits, look each up exactly once, then
|
||||
// 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'];
|
||||
for (final hit in hits) {
|
||||
final entry = hit.entry;
|
||||
final snippet = hit.snippet.text;
|
||||
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
|
||||
// lazily by the editor; the search list only needs id/title.
|
||||
final now = DateTime.now();
|
||||
results.add(
|
||||
DocumentSearchHit(
|
||||
documentId: documentId,
|
||||
filename: doc.filename,
|
||||
filePath: doc.filePath,
|
||||
pageNumber: pageNumber is int ? pageNumber : 0,
|
||||
snippet: content is String ? content : '',
|
||||
NoteSearchHit(
|
||||
note: Note(
|
||||
id: entry.openPath,
|
||||
title: entry.title,
|
||||
createdAt: now,
|
||||
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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user