feat(tools): rnote-style toolbar core writing batch
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Replace the ad-hoc tool palette with a shared tool system (EditorToolKind) across the PDF, note and slide editors, and add the core writing tools. - Multiple brushes, each remembering its OWN color (rnote-style): selecting a brush restores its color, changing color updates only that brush, and each brush button shows its current color. - Select tool: tap-select a committed stroke, drag to move it, delete it — persisted and undoable. - Shape tool: line / rectangle / ellipse / arrow, drawn with a live preview and committed as generated PenStrokes (shape_geometry.dart) so they reuse stroke rendering, erase, persistence and undo. - Highlighter + eraser fold into the same tool system. Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches (TODO). Brush opacity still deferred. analyze clean, 302 tests.
This commit is contained in:
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).
|
||||
53
lib/editor/canvas/editor_tool.dart
Normal file
53
lib/editor/canvas/editor_tool.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
// 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): text/typing tool, bookmark-to-paragraph, search+OCR,
|
||||
// templates, Windows Ink — later batches add kinds here.
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
@@ -199,6 +199,52 @@ class EraserPreviewPainter extends CustomPainter {
|
||||
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 {
|
||||
|
||||
@@ -30,15 +30,31 @@ import '../input/input_arbiter.dart' as arbiter;
|
||||
import '../input/pen_config.dart';
|
||||
import '../input/pressure_curve.dart';
|
||||
import '../input/pen_input_service.dart';
|
||||
import '../engine/shape_geometry.dart';
|
||||
import '../render/ink_picture_cache.dart';
|
||||
import '../render/live_ink_painter.dart' as render;
|
||||
import '../render/static_ink_painter.dart' as render;
|
||||
import 'ink_painters.dart' show EraserPreviewPainter;
|
||||
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.
|
||||
enum CanvasTool { pen, highlighter, eraser }
|
||||
/// 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,
|
||||
};
|
||||
|
||||
class PenCanvas extends StatefulWidget {
|
||||
const PenCanvas({
|
||||
@@ -49,10 +65,14 @@ class PenCanvas extends StatefulWidget {
|
||||
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,
|
||||
@@ -93,6 +113,10 @@ class PenCanvas extends StatefulWidget {
|
||||
/// 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).
|
||||
@@ -107,6 +131,22 @@ class PenCanvas extends StatefulWidget {
|
||||
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;
|
||||
@@ -169,6 +209,17 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
/// 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;
|
||||
|
||||
/// True when the active stylus reports the eraser signal (barrel button or
|
||||
/// inverted stylus), detected on hover/down.
|
||||
bool _eraserActive = false;
|
||||
@@ -211,6 +262,16 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
? 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
|
||||
@@ -371,9 +432,11 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
void _startStroke(PointerDownEvent event) {
|
||||
_drawPointer = event.pointer;
|
||||
_livePoints.clear();
|
||||
_shapeStart = null;
|
||||
_selectLast = null;
|
||||
_selectDragging = false;
|
||||
final p = _toNormalized(event.position, _normalizedPressure(event),
|
||||
tilt: _tiltFor(event));
|
||||
if (p != null) _livePoints.add(p);
|
||||
|
||||
if (_eraserActive || widget.tool == CanvasTool.eraser) {
|
||||
_eraserCursor.value = p;
|
||||
@@ -384,6 +447,24 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
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);
|
||||
_updateLiveStroke();
|
||||
}
|
||||
|
||||
@@ -397,14 +478,56 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
_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;
|
||||
}
|
||||
|
||||
_livePoints.add(p);
|
||||
_updateLiveStroke();
|
||||
}
|
||||
|
||||
void _endStroke() {
|
||||
if (_drawPointer == null) return;
|
||||
final wasEraser = _eraserActive || widget.tool == CanvasTool.eraser;
|
||||
if (!wasEraser && _livePoints.isNotEmpty) {
|
||||
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: _currentBrush,
|
||||
));
|
||||
}
|
||||
} else if (tool == CanvasTool.select) {
|
||||
// Nothing to commit on release: selection + moves were applied live.
|
||||
} else if (!wasEraser && _livePoints.isNotEmpty) {
|
||||
widget.onStrokeComplete(
|
||||
PenStroke(
|
||||
points: List.of(_livePoints),
|
||||
@@ -416,11 +539,47 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
);
|
||||
}
|
||||
_drawPointer = null;
|
||||
_shapeStart = null;
|
||||
_selectLast = null;
|
||||
_selectDragging = false;
|
||||
_livePoints.clear();
|
||||
_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: _currentBrush,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Discard the in-progress stroke without committing (palm/2nd-finger).
|
||||
void _cancelStroke() {
|
||||
_drawPointer = null;
|
||||
@@ -634,7 +793,8 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Live ink (current stroke only, isolated repaint).
|
||||
// Live ink (current stroke only, isolated repaint). Also carries
|
||||
// the SHAPE tool's preview (built as a live PenStroke).
|
||||
Positioned.fill(
|
||||
child: RepaintBoundary(
|
||||
child: CustomPaint(
|
||||
@@ -646,6 +806,18 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -32,6 +32,7 @@ import '../../models/scratch_link.dart';
|
||||
import '../../screens/split_view_screen.dart';
|
||||
import '../../services/database_service.dart';
|
||||
import '../engine/brush.dart';
|
||||
import '../engine/shape_geometry.dart';
|
||||
import '../engine/stroke_eraser.dart';
|
||||
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
||||
import '../engine/stroke_model.dart';
|
||||
@@ -47,9 +48,9 @@ import '../persistence/editor_repository.dart';
|
||||
import '../persistence/save_scheduler.dart';
|
||||
import '../ui/pen_settings_page.dart';
|
||||
import '../ui/thumbnail_grid.dart';
|
||||
import 'editor_tool.dart';
|
||||
import 'ink_painters.dart' show buildStrokePath;
|
||||
import 'input_diagnostics.dart';
|
||||
import 'pen_canvas.dart' show CanvasTool;
|
||||
import 'pen_palette_widgets.dart';
|
||||
import 'pen_stroke.dart';
|
||||
import 'pinch_scale_solver.dart';
|
||||
@@ -198,14 +199,48 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
bool _showPenDebug = false;
|
||||
double _peakNorm = 0;
|
||||
|
||||
// Tool state.
|
||||
CanvasTool _tool = CanvasTool.pen;
|
||||
// Tool state. The single shared active-tool enum; the page-anchored
|
||||
// select-text / place-link tools (below) are PDF-only and ride a different
|
||||
// path (they disable pen capture), so they stay as their own booleans.
|
||||
EditorToolKind _tool = EditorToolKind.brush;
|
||||
|
||||
/// Selected brush for the PEN tool (fountain/ballpoint/pencil). The
|
||||
/// Selected brush for the BRUSH tool (fountain/ballpoint/pencil). The
|
||||
/// highlighter tool always uses [BrushKind.highlighter]. Local state only for
|
||||
/// this increment (not persisted — TODO(brush-persist-selection)).
|
||||
BrushKind _penBrush = BrushKind.fountainPen;
|
||||
|
||||
/// Selected shape for the SHAPE tool.
|
||||
ShapeKind _shapeKind = ShapeKind.line;
|
||||
|
||||
/// SELECT tool: ([page], strokeIndex) of the selected committed stroke, or
|
||||
/// null. Selection is per-page (each page has its own stroke list).
|
||||
({int page, int index})? _selected;
|
||||
|
||||
/// SHAPE tool: normalized start point + its page, while a shape drag is live.
|
||||
({int page, Offset start})? _shapeDrag;
|
||||
|
||||
/// SELECT tool: last normalized drag position + page, to compute the
|
||||
/// incremental translation; and whether the drag's undo snapshot was taken.
|
||||
Offset? _selectLast;
|
||||
bool _selectDragging = false;
|
||||
|
||||
/// rnote-style per-brush color memory: each brush (and the highlighter)
|
||||
/// remembers its own color. Selecting a brush restores its color; picking a
|
||||
/// color updates ONLY the active brush's entry. In-memory only for this
|
||||
/// increment (TODO(brush-color-persist)).
|
||||
final Map<BrushKind, Color> _brushColors = {
|
||||
BrushKind.fountainPen: Colors.black,
|
||||
BrushKind.ballpoint: Colors.blue,
|
||||
BrushKind.pencil: Colors.green,
|
||||
BrushKind.highlighter: Colors.orange,
|
||||
};
|
||||
|
||||
/// The brush whose color the color-dots edit (highlighter tool ⇒ highlighter,
|
||||
/// else the selected pen brush).
|
||||
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
|
||||
? BrushKind.highlighter
|
||||
: _penBrush;
|
||||
|
||||
/// When true the "select text" tool is active: pen capture is disabled so the
|
||||
/// pen falls through to pdfrx for native text selection.
|
||||
bool _selectTextMode = false;
|
||||
@@ -221,7 +256,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
Color _color = Colors.black;
|
||||
/// The active drawing color = the active brush's remembered color.
|
||||
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
|
||||
bool _allowFingerDrawing = false;
|
||||
|
||||
/// Whether the viewer currently has a non-empty text selection (drives the
|
||||
@@ -240,13 +276,20 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
Colors.orange,
|
||||
];
|
||||
|
||||
/// True when a PEN tool (pen/highlighter/eraser) is active — pen capture is on.
|
||||
/// False in select-text mode (pen reaches pdfrx text selection) and in
|
||||
/// place-link mode (a tap drops an anchor via the page overlay).
|
||||
/// True when an ink tool (brush/highlighter/eraser/select/shape) is active —
|
||||
/// pen capture is on. False in select-text mode (pen reaches pdfrx text
|
||||
/// selection) and in place-link mode (a tap drops an anchor via the overlay).
|
||||
bool get _penCaptureEnabled => !_selectTextMode && !_placeLinkMode;
|
||||
|
||||
/// True when the eraser tool is active.
|
||||
bool get _isEraser => _tool == CanvasTool.eraser && !_selectTextMode;
|
||||
bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode;
|
||||
|
||||
/// True when the SELECT tool is active (and not in a page-anchored mode).
|
||||
bool get _isSelect =>
|
||||
_tool == EditorToolKind.select && _penCaptureEnabled;
|
||||
|
||||
/// True when the SHAPE tool is active.
|
||||
bool get _isShape => _tool == EditorToolKind.shape && _penCaptureEnabled;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -500,6 +543,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
_eraseAt(hit.page, hit.normalized);
|
||||
return;
|
||||
}
|
||||
if (_isSelect) {
|
||||
_liveStrokePage = hit.page;
|
||||
_selectLast = hit.normalized;
|
||||
_selectDragging = false;
|
||||
_selectAt(hit.page, hit.normalized);
|
||||
return;
|
||||
}
|
||||
if (_isShape) {
|
||||
_liveStrokePage = hit.page;
|
||||
_shapeDrag = (page: hit.page, start: hit.normalized);
|
||||
return;
|
||||
}
|
||||
_liveStrokePage = hit.page;
|
||||
_livePoints
|
||||
..clear()
|
||||
@@ -516,6 +571,16 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_isSelect) {
|
||||
if (hit == null || hit.page != page) return;
|
||||
_dragSelected(page, hit.normalized);
|
||||
return;
|
||||
}
|
||||
if (_isShape) {
|
||||
if (hit == null || hit.page != page) return;
|
||||
_updateShapePreview(page, hit.normalized);
|
||||
return;
|
||||
}
|
||||
// A stroke belongs to ONE page: ignore samples on a different page.
|
||||
if (hit == null || hit.page != page) return;
|
||||
_livePoints.add(PenPoint(
|
||||
@@ -526,6 +591,100 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// SELECT down: hit-test the page's committed strokes (topmost first) and set
|
||||
/// the selection (or clear it on empty space).
|
||||
void _selectAt(int page, Offset normalized) {
|
||||
final strokes = _strokesByPage[page];
|
||||
final radius = _penConfig?.value.eraserRadius ?? kDefaultEraserRadius;
|
||||
final aspect = _pageAspect(page);
|
||||
int? hitIndex;
|
||||
if (strokes != null) {
|
||||
for (var i = strokes.length - 1; i >= 0; i--) {
|
||||
if (strokeHit(strokes[i], normalized.dx, normalized.dy, radius,
|
||||
aspect: aspect)) {
|
||||
hitIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_selected = hitIndex == null ? null : (page: page, index: hitIndex);
|
||||
});
|
||||
_bumpOverlay();
|
||||
}
|
||||
|
||||
/// SELECT drag: translate the selected stroke by the incremental delta,
|
||||
/// recording ONE undo snapshot on the first delta of the drag.
|
||||
void _dragSelected(int page, Offset normalized) {
|
||||
final sel = _selected;
|
||||
final last = _selectLast;
|
||||
if (sel == null || last == null || sel.page != page) {
|
||||
_selectLast = normalized;
|
||||
return;
|
||||
}
|
||||
final dx = normalized.dx - last.dx;
|
||||
final dy = normalized.dy - last.dy;
|
||||
_selectLast = normalized;
|
||||
if (dx == 0 && dy == 0) return;
|
||||
_moveSelected(dx, dy, isDragStart: !_selectDragging);
|
||||
_selectDragging = true;
|
||||
}
|
||||
|
||||
/// Translate the selected stroke by ([dx],[dy]); persists + (on [isDragStart])
|
||||
/// records one undo snapshot via the existing per-page undo stack.
|
||||
void _moveSelected(double dx, double dy, {required bool isDragStart}) {
|
||||
final sel = _selected;
|
||||
if (sel == null) return;
|
||||
final list = _strokesByPage[sel.page];
|
||||
if (list == null || sel.index < 0 || sel.index >= list.length) return;
|
||||
if (isDragStart) _undoFor(sel.page).record(List<PenStroke>.of(list));
|
||||
setState(() {
|
||||
final next = List<PenStroke>.of(list);
|
||||
next[sel.index] = translateStroke(next[sel.index], dx, dy);
|
||||
_strokesByPage[sel.page] = next;
|
||||
});
|
||||
_schedulePageSave(sel.page, List<PenStroke>.of(_strokesByPage[sel.page]!));
|
||||
_bumpOverlay();
|
||||
}
|
||||
|
||||
/// Delete the selected stroke (button or long-press) as one undo step.
|
||||
void _deleteSelected() {
|
||||
final sel = _selected;
|
||||
if (sel == null) return;
|
||||
final list = _strokesByPage[sel.page];
|
||||
if (list == null || sel.index < 0 || sel.index >= list.length) return;
|
||||
_undoFor(sel.page).record(List<PenStroke>.of(list));
|
||||
setState(() {
|
||||
final next = List<PenStroke>.of(list)..removeAt(sel.index);
|
||||
_strokesByPage[sel.page] = next;
|
||||
_selected = null;
|
||||
});
|
||||
_schedulePageSave(sel.page, List<PenStroke>.of(_strokesByPage[sel.page]!));
|
||||
_bumpOverlay();
|
||||
}
|
||||
|
||||
/// SHAPE preview: rebuild the generated shape stroke from start→current and
|
||||
/// publish it as the live stroke (drawn by the page overlay painter).
|
||||
void _updateShapePreview(int page, Offset current) {
|
||||
final drag = _shapeDrag;
|
||||
if (drag == null || drag.page != page) return;
|
||||
final pts = generateShapePoints(
|
||||
_shapeKind,
|
||||
PenPoint(drag.start.dx, drag.start.dy, 1.0),
|
||||
PenPoint(current.dx, current.dy, 1.0),
|
||||
);
|
||||
_liveStrokeVN.value = _LiveStrokeData(
|
||||
page,
|
||||
PenStroke(
|
||||
points: pts,
|
||||
color: _currentColor().toARGB32(),
|
||||
width: _currentStrokeWidth(),
|
||||
kind: PenStrokeKind.pen,
|
||||
brush: _currentBrush(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateLiveStroke() {
|
||||
final page = _liveStrokePage;
|
||||
if (page == null || _livePoints.isEmpty) return;
|
||||
@@ -543,7 +702,19 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
|
||||
void _endStroke({required bool commit}) {
|
||||
final page = _liveStrokePage;
|
||||
if (page != null && commit && !_isEraser && _livePoints.isNotEmpty) {
|
||||
// SHAPE: on release, commit the generated shape stroke.
|
||||
final drag = _shapeDrag;
|
||||
if (_isShape && drag != null && commit) {
|
||||
final end = _liveStrokeVN.value;
|
||||
if (end != null && end.page == drag.page) {
|
||||
_commitStroke(drag.page, end.stroke);
|
||||
}
|
||||
} else if (page != null &&
|
||||
commit &&
|
||||
!_isEraser &&
|
||||
!_isSelect &&
|
||||
!_isShape &&
|
||||
_livePoints.isNotEmpty) {
|
||||
// A single tap → tiny dot is allowed (perfect_freehand renders a dot for
|
||||
// a 1-point stroke).
|
||||
_commitStroke(
|
||||
@@ -558,6 +729,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
);
|
||||
}
|
||||
_liveStrokePage = null;
|
||||
_shapeDrag = null;
|
||||
_selectLast = null;
|
||||
_selectDragging = false;
|
||||
_livePoints.clear();
|
||||
_liveStrokeVN.value = null;
|
||||
_bumpOverlay();
|
||||
@@ -600,21 +774,21 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
kind == PointerDeviceKind.stylus ||
|
||||
kind == PointerDeviceKind.invertedStylus;
|
||||
|
||||
double _currentStrokeWidth() => _tool == CanvasTool.highlighter
|
||||
double _currentStrokeWidth() => _tool == EditorToolKind.highlighter
|
||||
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
|
||||
: (_penConfig?.value.penWidth ?? _penWidthFraction);
|
||||
|
||||
PenStrokeKind _currentKind() => _tool == CanvasTool.highlighter
|
||||
PenStrokeKind _currentKind() => _tool == EditorToolKind.highlighter
|
||||
? PenStrokeKind.highlighter
|
||||
: PenStrokeKind.pen;
|
||||
|
||||
/// Brush in effect: highlighter tool ⇒ highlighter brush, else the selected
|
||||
/// pen brush. Drives both the capture-time pressure warp and render geometry.
|
||||
BrushKind _currentBrush() => _tool == CanvasTool.highlighter
|
||||
BrushKind _currentBrush() => _tool == EditorToolKind.highlighter
|
||||
? BrushKind.highlighter
|
||||
: _penBrush;
|
||||
|
||||
Color _currentColor() => _tool == CanvasTool.highlighter
|
||||
Color _currentColor() => _tool == EditorToolKind.highlighter
|
||||
? _color.withAlpha(0x80)
|
||||
: _color;
|
||||
|
||||
@@ -757,11 +931,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
_controller.goToPage(pageNumber: clamped + 1);
|
||||
}
|
||||
|
||||
void _setTool(CanvasTool tool) {
|
||||
void _setTool(EditorToolKind tool) {
|
||||
setState(() {
|
||||
_tool = tool;
|
||||
_selectTextMode = false;
|
||||
_placeLinkMode = false;
|
||||
if (tool != EditorToolKind.select) _selected = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -769,6 +944,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
setState(() {
|
||||
_selectTextMode = true;
|
||||
_placeLinkMode = false;
|
||||
_selected = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -966,6 +1142,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
pageIndex: pageIndex,
|
||||
strokes: _strokesByPage[pageIndex] ?? const [],
|
||||
highlights: _highlightsByPage[pageIndex] ?? const [],
|
||||
selectedIndex:
|
||||
(_selected != null && _selected!.page == pageIndex)
|
||||
? _selected!.index
|
||||
: null,
|
||||
pageSize: pageRectInViewer.size,
|
||||
thinning: _penConfig?.value.pressureSensitivity ??
|
||||
kDefaultPenThinning,
|
||||
@@ -1058,26 +1238,51 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
children: [
|
||||
BrushPickerButton(
|
||||
selected: _penBrush,
|
||||
active: _tool == CanvasTool.pen && !_selectTextMode,
|
||||
active: _tool == EditorToolKind.brush && _penCaptureEnabled,
|
||||
tooltip: l.brushPicker,
|
||||
labelFor: (b) => brushLabel(b, l),
|
||||
colorFor: (b) => _brushColors[b] ?? Colors.black,
|
||||
onSelected: (b) {
|
||||
setState(() => _penBrush = b);
|
||||
_setTool(CanvasTool.pen);
|
||||
_setTool(EditorToolKind.brush);
|
||||
},
|
||||
),
|
||||
ToolButton(
|
||||
icon: Icons.brush_outlined,
|
||||
selected: _tool == CanvasTool.highlighter && !_selectTextMode,
|
||||
selected: _tool == EditorToolKind.highlighter && _penCaptureEnabled,
|
||||
tooltip: l.toolHighlighter,
|
||||
onPressed: () => _setTool(CanvasTool.highlighter),
|
||||
onPressed: () => _setTool(EditorToolKind.highlighter),
|
||||
),
|
||||
ToolButton(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
selected: _isEraser,
|
||||
tooltip: l.toolEraser,
|
||||
onPressed: () => _setTool(CanvasTool.eraser),
|
||||
onPressed: () => _setTool(EditorToolKind.eraser),
|
||||
),
|
||||
ToolButton(
|
||||
icon: Icons.ads_click,
|
||||
selected: _isSelect,
|
||||
tooltip: l.toolSelect,
|
||||
onPressed: () => _setTool(EditorToolKind.select),
|
||||
),
|
||||
ShapePickerButton(
|
||||
selected: _shapeKind,
|
||||
active: _isShape,
|
||||
tooltip: l.shapePicker,
|
||||
labelFor: (s) => shapeLabel(s, l),
|
||||
onActivate: () => _setTool(EditorToolKind.shape),
|
||||
onSelected: (s) {
|
||||
setState(() => _shapeKind = s);
|
||||
_setTool(EditorToolKind.shape);
|
||||
},
|
||||
),
|
||||
if (_isSelect && _selected != null)
|
||||
ToolButton(
|
||||
icon: Icons.delete_outline,
|
||||
selected: false,
|
||||
tooltip: l.actionDeleteSelection,
|
||||
onPressed: _deleteSelected,
|
||||
),
|
||||
PaletteDivider(cs: cs),
|
||||
// Text selection + highlight (real vector text).
|
||||
ToolButton(
|
||||
@@ -1158,9 +1363,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
}
|
||||
|
||||
Widget _colorDot(Color c, ColorScheme cs) {
|
||||
final selected = _color == c;
|
||||
// Selected against the ACTIVE brush's remembered color; a tap updates only
|
||||
// that brush's entry (rnote per-brush color memory). Inert in select mode.
|
||||
final selected = _color == c && !_isSelect;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _color = c),
|
||||
onTap: () => setState(() => _brushColors[_activeColorBrush] = c),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
width: 28,
|
||||
@@ -1367,6 +1574,7 @@ class _PageOverlayPainter extends CustomPainter {
|
||||
required this.highlights,
|
||||
required this.pageSize,
|
||||
required this.thinning,
|
||||
this.selectedIndex,
|
||||
}) : super(repaint: Listenable.merge([overlayRepaint, liveStrokeVN]));
|
||||
|
||||
/// Live stroke source, read at paint time. Only painted when its page matches
|
||||
@@ -1378,6 +1586,10 @@ class _PageOverlayPainter extends CustomPainter {
|
||||
final Size pageSize;
|
||||
final double thinning;
|
||||
|
||||
/// SELECT tool: index into [strokes] of the selected stroke on this page, or
|
||||
/// null. Drives the selection bounding-box overlay.
|
||||
final int? selectedIndex;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// 1. Text highlights (semi-transparent yellow), normalized → pixels.
|
||||
@@ -1427,6 +1639,36 @@ class _PageOverlayPainter extends CustomPainter {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. SELECT bounding box around the selected stroke (over everything).
|
||||
final si = selectedIndex;
|
||||
if (si != null && si >= 0 && si < strokes.length) {
|
||||
final b = penStrokeBounds(strokes[si]);
|
||||
if (b != null) {
|
||||
const padPx = 6.0;
|
||||
final rect = Rect.fromLTRB(
|
||||
b.left * size.width - padPx,
|
||||
b.top * size.height - padPx,
|
||||
b.right * size.width + padPx,
|
||||
b.bottom * size.height + padPx,
|
||||
);
|
||||
final rr = RRect.fromRectAndRadius(rect, const Radius.circular(4));
|
||||
canvas.drawRRect(
|
||||
rr,
|
||||
Paint()
|
||||
..color = const Color(0xFF2962FF).withValues(alpha: 0.12)
|
||||
..style = PaintingStyle.fill,
|
||||
);
|
||||
canvas.drawRRect(
|
||||
rr,
|
||||
Paint()
|
||||
..color = const Color(0xFF2962FF)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5
|
||||
..isAntiAlias = true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1437,6 +1679,7 @@ class _PageOverlayPainter extends CustomPainter {
|
||||
old.strokes.length != strokes.length ||
|
||||
!identical(old.highlights, highlights) ||
|
||||
old.highlights.length != highlights.length ||
|
||||
old.selectedIndex != selectedIndex ||
|
||||
old.pageSize != pageSize ||
|
||||
old.thinning != thinning;
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ import '../../models/note.dart';
|
||||
import '../../providers/note_provider.dart';
|
||||
import '../../providers/ocr_provider.dart';
|
||||
import '../engine/brush.dart';
|
||||
import '../engine/shape_geometry.dart';
|
||||
import '../input/pen_config.dart';
|
||||
import '../input/pen_input_service.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 'pen_canvas.dart';
|
||||
import 'pen_palette_widgets.dart';
|
||||
import 'pen_stroke.dart';
|
||||
@@ -45,14 +47,40 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
final List<List<PenStroke>> _undo = [];
|
||||
final List<List<PenStroke>> _redo = [];
|
||||
|
||||
CanvasTool _tool = CanvasTool.pen;
|
||||
/// The single active-tool state (shared model across the 3 editors).
|
||||
EditorToolKind _tool = EditorToolKind.brush;
|
||||
|
||||
/// Selected brush for the PEN tool (fountain/ballpoint/pencil). The
|
||||
/// Selected brush for the BRUSH tool (fountain/ballpoint/pencil). The
|
||||
/// highlighter tool always uses [BrushKind.highlighter]; local state only for
|
||||
/// this increment (not persisted — see TODO(brush-persist-selection)).
|
||||
BrushKind _penBrush = BrushKind.fountainPen;
|
||||
|
||||
Color _color = Colors.black;
|
||||
/// Selected shape for the SHAPE tool.
|
||||
ShapeKind _shapeKind = ShapeKind.line;
|
||||
|
||||
/// Index of the currently selected committed stroke (SELECT tool), or null.
|
||||
int? _selectedStroke;
|
||||
|
||||
/// rnote-style per-brush color memory: each brush (and the highlighter)
|
||||
/// remembers its own color. Selecting a brush restores its color; picking a
|
||||
/// color updates ONLY the active brush's entry. In-memory only for this
|
||||
/// increment (TODO(brush-color-persist)).
|
||||
final Map<BrushKind, Color> _brushColors = {
|
||||
BrushKind.fountainPen: Colors.black,
|
||||
BrushKind.ballpoint: Colors.blue,
|
||||
BrushKind.pencil: Colors.green,
|
||||
BrushKind.highlighter: Colors.orange,
|
||||
};
|
||||
|
||||
/// The brush whose color the color-dots edit: the highlighter when the
|
||||
/// highlighter tool is active, otherwise the selected pen brush.
|
||||
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
|
||||
? BrushKind.highlighter
|
||||
: _penBrush;
|
||||
|
||||
/// The active drawing color (the active brush's remembered color).
|
||||
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
|
||||
|
||||
bool _allowFingerDrawing = false;
|
||||
bool _dirty = false;
|
||||
bool _needsCenter = true;
|
||||
@@ -242,10 +270,46 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
_transform.value = Matrix4.identity()..translateByDouble(o.dx, o.dy, 0, 1);
|
||||
}
|
||||
|
||||
double get _strokeWidth => _tool == CanvasTool.highlighter
|
||||
double get _strokeWidth => _tool == EditorToolKind.highlighter
|
||||
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
|
||||
: (_penConfig?.value.penWidth ?? _penWidthFraction);
|
||||
|
||||
// ── 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;
|
||||
@@ -324,10 +388,14 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
pageSize: pageSize,
|
||||
strokes: _strokes,
|
||||
transformationController: _transform,
|
||||
tool: _tool,
|
||||
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,
|
||||
@@ -367,30 +435,57 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Pen tool with brush picker (fountain / ballpoint / pencil).
|
||||
// Pen tool with brush picker (fountain / ballpoint / pencil), each
|
||||
// brush showing its own remembered color.
|
||||
BrushPickerButton(
|
||||
selected: _penBrush,
|
||||
active: _tool == CanvasTool.pen,
|
||||
active: _tool == EditorToolKind.brush,
|
||||
tooltip: 'Brush',
|
||||
labelFor: brushLabelEn,
|
||||
colorFor: (b) => _brushColors[b] ?? Colors.black,
|
||||
onSelected: (b) => setState(() {
|
||||
_penBrush = b;
|
||||
_tool = CanvasTool.pen;
|
||||
_tool = EditorToolKind.brush;
|
||||
}),
|
||||
),
|
||||
ToolButton(
|
||||
icon: Icons.brush_outlined,
|
||||
selected: _tool == CanvasTool.highlighter,
|
||||
selected: _tool == EditorToolKind.highlighter,
|
||||
tooltip: 'Highlighter',
|
||||
onPressed: () =>
|
||||
setState(() => _tool = CanvasTool.highlighter),
|
||||
setState(() => _tool = EditorToolKind.highlighter),
|
||||
),
|
||||
ToolButton(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
selected: _tool == CanvasTool.eraser,
|
||||
selected: _tool == EditorToolKind.eraser,
|
||||
tooltip: 'Eraser',
|
||||
onPressed: () => setState(() => _tool = CanvasTool.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,
|
||||
@@ -430,12 +525,18 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
}
|
||||
|
||||
Widget _colorDot(Color c, ColorScheme cs) {
|
||||
// Selected against the ACTIVE brush's remembered color. A color tap updates
|
||||
// only that brush's entry (rnote per-brush color memory).
|
||||
final selected = _color.toARGB32() == c.toARGB32() &&
|
||||
_tool != CanvasTool.eraser;
|
||||
_tool != EditorToolKind.eraser &&
|
||||
_tool != EditorToolKind.select;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
_color = c;
|
||||
if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen;
|
||||
if (_tool == EditorToolKind.eraser ||
|
||||
_tool == EditorToolKind.select) {
|
||||
_tool = EditorToolKind.brush;
|
||||
}
|
||||
_brushColors[_activeColorBrush] = c;
|
||||
}),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../engine/brush.dart';
|
||||
import 'editor_tool.dart';
|
||||
|
||||
/// Localized display name for a brush (single source so all three editors agree).
|
||||
String brushLabel(BrushKind kind, AppLocalizations l) => switch (kind) {
|
||||
@@ -28,6 +29,24 @@ String brushLabelEn(BrushKind kind) => switch (kind) {
|
||||
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 = [
|
||||
@@ -44,11 +63,23 @@ IconData brushIcon(BrushKind kind) => switch (kind) {
|
||||
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,
|
||||
};
|
||||
|
||||
/// 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,
|
||||
@@ -56,6 +87,7 @@ class BrushPickerButton extends StatelessWidget {
|
||||
required this.active,
|
||||
required this.onSelected,
|
||||
required this.labelFor,
|
||||
required this.colorFor,
|
||||
required this.tooltip,
|
||||
});
|
||||
|
||||
@@ -70,6 +102,9 @@ class BrushPickerButton extends StatelessWidget {
|
||||
/// 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
|
||||
@@ -91,6 +126,17 @@ class BrushPickerButton extends StatelessWidget {
|
||||
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),
|
||||
@@ -107,11 +153,26 @@ class BrushPickerButton extends StatelessWidget {
|
||||
color: active ? cs.secondaryContainer : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(brushIcon(selected), size: 22, color: iconColor),
|
||||
Icon(Icons.arrow_drop_down, size: 18, color: iconColor),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -119,6 +180,87 @@ class BrushPickerButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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({
|
||||
|
||||
@@ -16,12 +16,14 @@ 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/pressure_curve.dart' show kNaturalPressureGamma;
|
||||
import '../layout/viewport_fit.dart';
|
||||
import '../pdf/slide_export.dart';
|
||||
import '../ui/pen_settings_page.dart';
|
||||
import 'editor_tool.dart';
|
||||
import 'pen_canvas.dart';
|
||||
import 'pen_palette_widgets.dart';
|
||||
import 'pen_stroke.dart';
|
||||
@@ -52,13 +54,33 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
/// keeps the slide's aspect (no distortion). Null until loaded.
|
||||
Map<int, Size>? _slideSizes;
|
||||
|
||||
CanvasTool _tool = CanvasTool.pen;
|
||||
/// The single active-tool state (shared model across the 3 editors).
|
||||
EditorToolKind _tool = EditorToolKind.brush;
|
||||
|
||||
/// Selected brush for the PEN tool. Highlighter tool uses the highlighter
|
||||
/// Selected brush for the BRUSH tool. Highlighter tool uses the highlighter
|
||||
/// brush; local state only (not persisted — TODO(brush-persist-selection)).
|
||||
BrushKind _penBrush = BrushKind.fountainPen;
|
||||
|
||||
Color _color = Colors.black;
|
||||
/// Selected shape for the SHAPE tool.
|
||||
ShapeKind _shapeKind = ShapeKind.line;
|
||||
|
||||
/// Index of the currently selected committed stroke (SELECT tool), or null.
|
||||
int? _selectedStroke;
|
||||
|
||||
/// rnote-style per-brush color memory (see PenNoteScreen). In-memory only.
|
||||
final Map<BrushKind, Color> _brushColors = {
|
||||
BrushKind.fountainPen: Colors.black,
|
||||
BrushKind.ballpoint: Colors.blue,
|
||||
BrushKind.pencil: Colors.green,
|
||||
BrushKind.highlighter: Colors.orange,
|
||||
};
|
||||
|
||||
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
|
||||
? BrushKind.highlighter
|
||||
: _penBrush;
|
||||
|
||||
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
|
||||
|
||||
bool _allowFingerDrawing = false;
|
||||
bool _needsCenter = true;
|
||||
bool _showSlider = false;
|
||||
@@ -189,6 +211,7 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
setState(() {
|
||||
_slideIndex = clamped;
|
||||
_needsCenter = true;
|
||||
_selectedStroke = null; // selection is per-slide
|
||||
});
|
||||
}
|
||||
|
||||
@@ -280,10 +303,41 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
_transform.value = Matrix4.identity()..translateByDouble(o.dx, o.dy, 0, 1);
|
||||
}
|
||||
|
||||
double get _strokeWidth => _tool == CanvasTool.highlighter
|
||||
double get _strokeWidth => _tool == EditorToolKind.highlighter
|
||||
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
|
||||
: (_penConfig?.value.penWidth ?? _penWidthFraction);
|
||||
|
||||
// ── 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;
|
||||
@@ -368,10 +422,14 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
pageSize: pageSize,
|
||||
strokes: _currentStrokes,
|
||||
transformationController: _transform,
|
||||
tool: _tool,
|
||||
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,
|
||||
@@ -405,26 +463,51 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
children: [
|
||||
BrushPickerButton(
|
||||
selected: _penBrush,
|
||||
active: _tool == CanvasTool.pen,
|
||||
active: _tool == EditorToolKind.brush,
|
||||
tooltip: 'Brush',
|
||||
labelFor: brushLabelEn,
|
||||
colorFor: (b) => _brushColors[b] ?? Colors.black,
|
||||
onSelected: (b) => setState(() {
|
||||
_penBrush = b;
|
||||
_tool = CanvasTool.pen;
|
||||
_tool = EditorToolKind.brush;
|
||||
}),
|
||||
),
|
||||
ToolButton(
|
||||
icon: Icons.brush_outlined,
|
||||
selected: _tool == CanvasTool.highlighter,
|
||||
selected: _tool == EditorToolKind.highlighter,
|
||||
tooltip: 'Highlighter',
|
||||
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
|
||||
onPressed: () => setState(() => _tool = EditorToolKind.highlighter),
|
||||
),
|
||||
ToolButton(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
selected: _tool == CanvasTool.eraser,
|
||||
selected: _tool == EditorToolKind.eraser,
|
||||
tooltip: 'Eraser',
|
||||
onPressed: () => setState(() => _tool = CanvasTool.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,
|
||||
@@ -466,12 +549,16 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
}
|
||||
|
||||
Widget _colorDot(Color c, ColorScheme cs) {
|
||||
final selected =
|
||||
_color.toARGB32() == c.toARGB32() && _tool != CanvasTool.eraser;
|
||||
final selected = _color.toARGB32() == c.toARGB32() &&
|
||||
_tool != EditorToolKind.eraser &&
|
||||
_tool != EditorToolKind.select;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
_color = c;
|
||||
if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen;
|
||||
if (_tool == EditorToolKind.eraser ||
|
||||
_tool == EditorToolKind.select) {
|
||||
_tool = EditorToolKind.brush;
|
||||
}
|
||||
_brushColors[_activeColorBrush] = c;
|
||||
}),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
|
||||
138
lib/editor/engine/shape_geometry.dart
Normal file
138
lib/editor/engine/shape_geometry.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
// 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';
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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);
|
||||
}
|
||||
@@ -51,6 +51,14 @@
|
||||
"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",
|
||||
|
||||
@@ -350,6 +350,54 @@ abstract class AppLocalizations {
|
||||
/// **'Highlighter'**
|
||||
String get brushHighlighter;
|
||||
|
||||
/// No description provided for @toolSelect.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select'**
|
||||
String get toolSelect;
|
||||
|
||||
/// No description provided for @toolShape.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Shape'**
|
||||
String get toolShape;
|
||||
|
||||
/// No description provided for @shapePicker.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Shape'**
|
||||
String get shapePicker;
|
||||
|
||||
/// No description provided for @shapeLine.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Line'**
|
||||
String get shapeLine;
|
||||
|
||||
/// No description provided for @shapeRectangle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Rectangle'**
|
||||
String get shapeRectangle;
|
||||
|
||||
/// No description provided for @shapeEllipse.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Ellipse'**
|
||||
String get shapeEllipse;
|
||||
|
||||
/// No description provided for @shapeArrow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Arrow'**
|
||||
String get shapeArrow;
|
||||
|
||||
/// No description provided for @actionDeleteSelection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete selection'**
|
||||
String get actionDeleteSelection;
|
||||
|
||||
/// No description provided for @actionUndo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -140,6 +140,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@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';
|
||||
|
||||
|
||||
@@ -140,6 +140,30 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@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 => '撤销';
|
||||
|
||||
|
||||
@@ -42,6 +42,14 @@
|
||||
"brushBallpoint": "圆珠笔",
|
||||
"brushPencil": "铅笔",
|
||||
"brushHighlighter": "荧光笔",
|
||||
"toolSelect": "选择",
|
||||
"toolShape": "形状",
|
||||
"shapePicker": "形状",
|
||||
"shapeLine": "直线",
|
||||
"shapeRectangle": "矩形",
|
||||
"shapeEllipse": "椭圆",
|
||||
"shapeArrow": "箭头",
|
||||
"actionDeleteSelection": "删除所选",
|
||||
"actionUndo": "撤销",
|
||||
"actionRedo": "重做",
|
||||
"fingerDrawingOn": "手指书写:开",
|
||||
|
||||
@@ -383,6 +383,9 @@ class _SplitViewState extends State<SplitViewScreen> {
|
||||
active: _tool == CanvasTool.pen,
|
||||
tooltip: 'Brush',
|
||||
labelFor: brushLabelEn,
|
||||
// The scratchpad keeps a single shared color (no per-brush color
|
||||
// memory in this surface); show it for every brush.
|
||||
colorFor: (_) => _color,
|
||||
onSelected: (b) => setState(() {
|
||||
_penBrush = b;
|
||||
_tool = CanvasTool.pen;
|
||||
|
||||
80
test/pen_brush_color_memory_test.dart
Normal file
80
test/pen_brush_color_memory_test.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
// test/pen_brush_color_memory_test.dart
|
||||
//
|
||||
// Pins rnote-style per-brush color memory in the note editor: each brush
|
||||
// remembers its OWN color, selecting a brush restores that brush's color (the
|
||||
// PenCanvas receives it), and picking a color updates ONLY the active brush's
|
||||
// entry — switching back to a different brush restores the other color.
|
||||
//
|
||||
// Drives the real PenNoteScreen toolbar (BrushPickerButton popup + color dots)
|
||||
// and reads PenCanvas.color to assert the active drawing color.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:badnote/editor/canvas/pen_canvas.dart';
|
||||
import 'package:badnote/editor/canvas/pen_note_screen.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Color canvasColor(WidgetTester tester) =>
|
||||
tester.widget<PenCanvas>(find.byType(PenCanvas)).color;
|
||||
|
||||
/// Pick the brush named [label] from the BrushPickerButton popup menu.
|
||||
Future<void> selectBrush(WidgetTester tester, String label) async {
|
||||
// The brush picker carries the 'Brush' tooltip.
|
||||
await tester.tap(find.byTooltip('Brush'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(label).last);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Tap the toolbar color dot whose swatch is exactly [c]. The dot is an
|
||||
/// AnimatedContainer (the swatch) inside a GestureDetector; tap the gesture
|
||||
/// detector ancestor so the onTap fires.
|
||||
Future<void> tapColorDot(WidgetTester tester, Color c) async {
|
||||
final swatch = find.byWidgetPredicate((w) =>
|
||||
w is AnimatedContainer &&
|
||||
w.decoration is BoxDecoration &&
|
||||
(w.decoration as BoxDecoration).color == c &&
|
||||
(w.decoration as BoxDecoration).shape == BoxShape.circle);
|
||||
final gd = find.ancestor(of: swatch, matching: find.byType(GestureDetector));
|
||||
await tester.tap(gd.first);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('each brush remembers its own color; switching restores it',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
await tester.pumpWidget(const ProviderScope(
|
||||
child: MaterialApp(home: PenNoteScreen()),
|
||||
));
|
||||
await tester.pump(); // let PenConfig load
|
||||
|
||||
// Defaults from _brushColors: fountain pen = black, ballpoint = blue.
|
||||
expect(canvasColor(tester), Colors.black,
|
||||
reason: 'fountain pen starts black');
|
||||
|
||||
// Switch to the ballpoint brush → its remembered color (blue) becomes active.
|
||||
await selectBrush(tester, 'Ballpoint');
|
||||
expect(canvasColor(tester), Colors.blue,
|
||||
reason: 'selecting ballpoint restores ITS remembered color');
|
||||
|
||||
// Change the ACTIVE (ballpoint) brush's color to red via a color dot.
|
||||
await tapColorDot(tester, Colors.red);
|
||||
expect(canvasColor(tester), Colors.red,
|
||||
reason: 'color change applies to the active brush');
|
||||
|
||||
// Switch back to the fountain pen → its color is still black (unchanged).
|
||||
await selectBrush(tester, 'Fountain pen');
|
||||
expect(canvasColor(tester), Colors.black,
|
||||
reason: 'fountain pen color was NOT affected by changing ballpoint');
|
||||
|
||||
// Back to ballpoint → it remembers the red we set.
|
||||
await selectBrush(tester, 'Ballpoint');
|
||||
expect(canvasColor(tester), Colors.red,
|
||||
reason: 'ballpoint remembers its own updated color');
|
||||
});
|
||||
}
|
||||
131
test/pen_select_move_test.dart
Normal file
131
test/pen_select_move_test.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
// test/pen_select_move_test.dart
|
||||
//
|
||||
// Pins the SELECT tool's select + move + delete on the PenCanvas editors (note):
|
||||
// * tapping a committed stroke selects it (onSelectStroke fires with its index);
|
||||
// * dragging the selection translates the stroke's normalized points (the
|
||||
// committed stroke list is replaced with shifted points);
|
||||
// * the move is one undoable step (undo restores the original points).
|
||||
//
|
||||
// Drives the real PenNoteScreen with a pre-loaded stroke and a stylus gesture.
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:badnote/editor/canvas/pen_canvas.dart';
|
||||
import 'package:badnote/editor/canvas/pen_note_screen.dart';
|
||||
import 'package:badnote/editor/canvas/pen_stroke.dart';
|
||||
import 'package:badnote/editor/engine/shape_geometry.dart';
|
||||
import 'package:badnote/models/ink_point.dart';
|
||||
import 'package:badnote/models/ink_stroke.dart';
|
||||
import 'package:badnote/models/note.dart';
|
||||
import 'package:badnote/models/pen_tool.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// A note with a single stroke crossing the page center so a center tap hits it.
|
||||
Note noteWithStroke() => Note(
|
||||
id: 'n1',
|
||||
title: 'Test',
|
||||
strokes: [
|
||||
InkStroke(
|
||||
id: 's1',
|
||||
// Through the page center (kNoteLogicalPage = 1000 x 1414) so a
|
||||
// center tap on the canvas hits the stroke.
|
||||
points: const [
|
||||
InkPoint(x: 300, y: 707, timestamp: 0),
|
||||
InkPoint(x: 500, y: 707, timestamp: 0),
|
||||
InkPoint(x: 700, y: 707, timestamp: 0),
|
||||
],
|
||||
tool: PenTool.pen,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
),
|
||||
],
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
|
||||
PenCanvas canvas(WidgetTester tester) =>
|
||||
tester.widget<PenCanvas>(find.byType(PenCanvas));
|
||||
|
||||
List<PenStroke> strokes(WidgetTester tester) => canvas(tester).strokes;
|
||||
|
||||
/// Activate the SELECT tool via its toolbar button (the "Select" tooltip).
|
||||
Future<void> activateSelect(WidgetTester tester) async {
|
||||
await tester.tap(find.byTooltip('Select'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('tapping a stroke selects it, dragging moves it, undo restores',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
child: MaterialApp(home: PenNoteScreen(note: noteWithStroke())),
|
||||
));
|
||||
await tester.pump(); // PenConfig load
|
||||
|
||||
expect(strokes(tester), hasLength(1));
|
||||
final before = strokes(tester).single;
|
||||
final beforeBounds = penStrokeBounds(before)!;
|
||||
|
||||
await activateSelect(tester);
|
||||
expect(canvas(tester).tool, CanvasTool.select);
|
||||
|
||||
// Tap the stroke (page center) to select, then drag it down-right.
|
||||
final center = tester.getCenter(find.byType(PenCanvas));
|
||||
final g = await tester.startGesture(center, kind: PointerDeviceKind.stylus);
|
||||
await tester.pump();
|
||||
// selection should now be set.
|
||||
expect(canvas(tester).selectedStrokeIndex, 0);
|
||||
|
||||
await g.moveBy(const Offset(40, 30));
|
||||
await g.moveBy(const Offset(20, 10));
|
||||
await g.up();
|
||||
await tester.pump();
|
||||
|
||||
final after = strokes(tester).single;
|
||||
final afterBounds = penStrokeBounds(after)!;
|
||||
// The stroke translated: its bounds shifted right and down.
|
||||
expect(afterBounds.left, greaterThan(beforeBounds.left),
|
||||
reason: 'stroke moved right');
|
||||
expect(afterBounds.top, greaterThan(beforeBounds.top),
|
||||
reason: 'stroke moved down');
|
||||
// Same number of points (a translate, not a redraw).
|
||||
expect(after.points.length, before.points.length);
|
||||
|
||||
// Undo restores the original position (one undoable step).
|
||||
await tester.tap(find.byTooltip('Undo'));
|
||||
await tester.pump();
|
||||
final undoneBounds = penStrokeBounds(strokes(tester).single)!;
|
||||
expect(undoneBounds.left, closeTo(beforeBounds.left, 1e-6));
|
||||
expect(undoneBounds.top, closeTo(beforeBounds.top, 1e-6));
|
||||
});
|
||||
|
||||
testWidgets('delete-selection removes the selected stroke (undoable)',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
child: MaterialApp(home: PenNoteScreen(note: noteWithStroke())),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
await activateSelect(tester);
|
||||
final center = tester.getCenter(find.byType(PenCanvas));
|
||||
final g = await tester.startGesture(center, kind: PointerDeviceKind.stylus);
|
||||
await g.up();
|
||||
await tester.pump();
|
||||
expect(canvas(tester).selectedStrokeIndex, 0);
|
||||
|
||||
// The delete button appears only with a live selection.
|
||||
await tester.tap(find.byTooltip('Delete selection'));
|
||||
await tester.pump();
|
||||
expect(strokes(tester), isEmpty);
|
||||
|
||||
await tester.tap(find.byTooltip('Undo'));
|
||||
await tester.pump();
|
||||
expect(strokes(tester), hasLength(1));
|
||||
});
|
||||
}
|
||||
132
test/shape_geometry_test.dart
Normal file
132
test/shape_geometry_test.dart
Normal file
@@ -0,0 +1,132 @@
|
||||
// test/shape_geometry_test.dart
|
||||
//
|
||||
// Pins the SHAPE-tool geometry (lib/editor/engine/shape_geometry.dart): each
|
||||
// shape is generated as a normalized PenPoint polyline with the documented point
|
||||
// counts (line→2, rect→5 closed, ellipse→kEllipseSamples+1), the shapes span the
|
||||
// requested start→end box, and translateStroke/translatePoints shift every point
|
||||
// without disturbing color/width/kind/brush (the SELECT-tool move primitive).
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:badnote/editor/canvas/editor_tool.dart';
|
||||
import 'package:badnote/editor/canvas/pen_stroke.dart';
|
||||
import 'package:badnote/editor/engine/brush.dart';
|
||||
import 'package:badnote/editor/engine/shape_geometry.dart';
|
||||
|
||||
void main() {
|
||||
const a = PenPoint(0.2, 0.3, 1.0);
|
||||
const b = PenPoint(0.8, 0.7, 1.0);
|
||||
|
||||
group('point counts', () {
|
||||
test('line → exactly 2 points (start, end)', () {
|
||||
final pts = generateShapePoints(ShapeKind.line, a, b);
|
||||
expect(pts, hasLength(2));
|
||||
expect(pts.first.x, closeTo(0.2, 1e-9));
|
||||
expect(pts.first.y, closeTo(0.3, 1e-9));
|
||||
expect(pts.last.x, closeTo(0.8, 1e-9));
|
||||
expect(pts.last.y, closeTo(0.7, 1e-9));
|
||||
});
|
||||
|
||||
test('rectangle → 5 points, closed (last == first)', () {
|
||||
final pts = generateShapePoints(ShapeKind.rectangle, a, b);
|
||||
expect(pts, hasLength(5));
|
||||
expect(pts.first.x, closeTo(pts.last.x, 1e-9));
|
||||
expect(pts.first.y, closeTo(pts.last.y, 1e-9));
|
||||
// Axis-aligned box corners spanning the start/end bounds.
|
||||
final xs = pts.map((p) => p.x).toSet();
|
||||
final ys = pts.map((p) => p.y).toSet();
|
||||
expect(xs, containsAll(<double>{0.2, 0.8}));
|
||||
expect(ys, containsAll(<double>{0.3, 0.7}));
|
||||
});
|
||||
|
||||
test('ellipse → kEllipseSamples + 1 points, closed', () {
|
||||
final pts = generateShapePoints(ShapeKind.ellipse, a, b);
|
||||
expect(pts, hasLength(kEllipseSamples + 1));
|
||||
expect(pts.first.x, closeTo(pts.last.x, 1e-9));
|
||||
expect(pts.first.y, closeTo(pts.last.y, 1e-9));
|
||||
// ~48 samples by spec.
|
||||
expect(kEllipseSamples, 48);
|
||||
});
|
||||
|
||||
test('arrow → shaft + 2 head barbs (6 points)', () {
|
||||
final pts = generateShapePoints(ShapeKind.arrow, a, b);
|
||||
// start, end, barb1, back-to-tip, barb2, back-to-tip = 6.
|
||||
expect(pts, hasLength(6));
|
||||
expect(pts[0].x, closeTo(0.2, 1e-9));
|
||||
expect(pts[1].x, closeTo(0.8, 1e-9));
|
||||
});
|
||||
|
||||
test('degenerate arrow (zero length) → just the 2 shaft points', () {
|
||||
final pts = generateShapePoints(ShapeKind.arrow, a, a);
|
||||
expect(pts, hasLength(2));
|
||||
});
|
||||
});
|
||||
|
||||
group('ellipse spans the start→end box', () {
|
||||
test('points stay within the bounding box (inclusive)', () {
|
||||
final pts = generateShapePoints(ShapeKind.ellipse, a, b);
|
||||
for (final p in pts) {
|
||||
expect(p.x, greaterThanOrEqualTo(0.2 - 1e-9));
|
||||
expect(p.x, lessThanOrEqualTo(0.8 + 1e-9));
|
||||
expect(p.y, greaterThanOrEqualTo(0.3 - 1e-9));
|
||||
expect(p.y, lessThanOrEqualTo(0.7 + 1e-9));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('translate (SELECT-tool move primitive)', () {
|
||||
test('translatePoints shifts every point, preserves pressure', () {
|
||||
const pts = [PenPoint(0.1, 0.2, 0.5), PenPoint(0.3, 0.4, null)];
|
||||
final moved = translatePoints(pts, 0.05, -0.1);
|
||||
expect(moved[0].x, closeTo(0.15, 1e-9));
|
||||
expect(moved[0].y, closeTo(0.1, 1e-9));
|
||||
expect(moved[0].pressure, 0.5);
|
||||
expect(moved[1].x, closeTo(0.35, 1e-9));
|
||||
expect(moved[1].pressure, isNull);
|
||||
});
|
||||
|
||||
test('translateStroke shifts points and preserves metadata', () {
|
||||
const stroke = PenStroke(
|
||||
points: [PenPoint(0.1, 0.1, 1.0), PenPoint(0.2, 0.2, 1.0)],
|
||||
color: 0xFF112233,
|
||||
width: 0.01,
|
||||
kind: PenStrokeKind.highlighter,
|
||||
brush: BrushKind.pencil,
|
||||
);
|
||||
final moved = translateStroke(stroke, 0.1, 0.2);
|
||||
expect(moved.points[0].x, closeTo(0.2, 1e-9));
|
||||
expect(moved.points[0].y, closeTo(0.3, 1e-9));
|
||||
expect(moved.points[1].x, closeTo(0.3, 1e-9));
|
||||
expect(moved.color, 0xFF112233);
|
||||
expect(moved.width, 0.01);
|
||||
expect(moved.kind, PenStrokeKind.highlighter);
|
||||
expect(moved.brush, BrushKind.pencil);
|
||||
});
|
||||
});
|
||||
|
||||
group('penStrokeBounds', () {
|
||||
test('tight bounds over the points', () {
|
||||
const stroke = PenStroke(
|
||||
points: [PenPoint(0.2, 0.5, 1.0), PenPoint(0.8, 0.1, 1.0)],
|
||||
color: 0xFF000000,
|
||||
width: 0.01,
|
||||
kind: PenStrokeKind.pen,
|
||||
);
|
||||
final bnds = penStrokeBounds(stroke)!;
|
||||
expect(bnds.left, closeTo(0.2, 1e-9));
|
||||
expect(bnds.right, closeTo(0.8, 1e-9));
|
||||
expect(bnds.top, closeTo(0.1, 1e-9));
|
||||
expect(bnds.bottom, closeTo(0.5, 1e-9));
|
||||
});
|
||||
|
||||
test('empty stroke → null bounds', () {
|
||||
const stroke = PenStroke(
|
||||
points: [],
|
||||
color: 0xFF000000,
|
||||
width: 0.01,
|
||||
kind: PenStrokeKind.pen,
|
||||
);
|
||||
expect(penStrokeBounds(stroke), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user