From 875dabcd893de1443cd2da10ad2131ba17d09b67 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Wed, 24 Jun 2026 20:38:18 +0800 Subject: [PATCH] feat(tools): rnote-style toolbar core writing batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/plans/2026-06-24-file-based-storage.md | 602 ++++++++++++++++++++ lib/editor/canvas/editor_tool.dart | 53 ++ lib/editor/canvas/ink_painters.dart | 46 ++ lib/editor/canvas/pen_canvas.dart | 186 +++++- lib/editor/canvas/pen_editor_screen.dart | 287 +++++++++- lib/editor/canvas/pen_note_screen.dart | 131 ++++- lib/editor/canvas/pen_palette_widgets.dart | 148 ++++- lib/editor/canvas/pen_slide_screen.dart | 117 +++- lib/editor/engine/shape_geometry.dart | 138 +++++ lib/l10n/app_en.arb | 8 + lib/l10n/app_localizations.dart | 48 ++ lib/l10n/app_localizations_en.dart | 24 + lib/l10n/app_localizations_zh.dart | 24 + lib/l10n/app_zh.arb | 8 + lib/screens/split_view_screen.dart | 3 + test/pen_brush_color_memory_test.dart | 80 +++ test/pen_select_move_test.dart | 131 +++++ test/shape_geometry_test.dart | 132 +++++ 18 files changed, 2104 insertions(+), 62 deletions(-) create mode 100644 docs/plans/2026-06-24-file-based-storage.md create mode 100644 lib/editor/canvas/editor_tool.dart create mode 100644 lib/editor/engine/shape_geometry.dart create mode 100644 test/pen_brush_color_memory_test.dart create mode 100644 test/pen_select_move_test.dart create mode 100644 test/shape_geometry_test.dart diff --git a/docs/plans/2026-06-24-file-based-storage.md b/docs/plans/2026-06-24-file-based-storage.md new file mode 100644 index 0000000..5f7a54e --- /dev/null +++ b/docs/plans/2026-06-24-file-based-storage.md @@ -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::page:"`, `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":, "width":, "filled":, "textContent":, "fontSize": }`. + **`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: + `//` containing the **source file** + one **sidecar** + `.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 + +``` +/ ← user-picked, persisted in SharedPreferences +├─ .badnote/ ← vault-level app metadata (hidden) +│ ├─ vault.json ← { "schemaVersion": 1, "vaultId": "", "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** = `.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) + +`.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": [ , , … ], + "3": [ , … ] + }, + + "highlights": { + "0": [ { "l": 0.12, "t": 0.20, "r": 0.88, "b": 0.235, "color": 1714657595 } ] + }, + + "bookmarks": [ + { "id": "", "pageNumber": 5, "label": "Proof", "color": 4283215696, "createdAt": "…" } + ], + + "scratchLinks": [ + { + "id": "", + "pageIndex": 7, + "nx": 0.83, "ny": 0.41, + "createdAt": "…", + "scratchpad": { + "canvasWidth": 4000, + "canvasHeight": 4000, + "strokes": [ , … ] // 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::page:"` 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()` 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 `//`. + b. Build the sidecar: + - `strokes`: query `ink WHERE host_id LIKE 'doc::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 getVaultRoot()` — reads `SharedPreferences` key `vaultRoot`. + - `Future setVaultRoot(String path)` — writes it. + - `Future 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 `/.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 + `.badnote.json.conflict-`** 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). diff --git a/lib/editor/canvas/editor_tool.dart b/lib/editor/canvas/editor_tool.dart new file mode 100644 index 0000000..6524fa5 --- /dev/null +++ b/lib/editor/canvas/editor_tool.dart @@ -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, +} diff --git a/lib/editor/canvas/ink_painters.dart b/lib/editor/canvas/ink_painters.dart index 51dd442..12c6605 100644 --- a/lib/editor/canvas/ink_painters.dart +++ b/lib/editor/canvas/ink_painters.dart @@ -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 { diff --git a/lib/editor/canvas/pen_canvas.dart b/lib/editor/canvas/pen_canvas.dart index 1ac2755..754aafd 100644 --- a/lib/editor/canvas/pen_canvas.dart +++ b/lib/editor/canvas/pen_canvas.dart @@ -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 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? 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 { /// 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 { ? 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 { 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 { 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 { _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 { ); } _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 { ), ), ), - // 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 { ), ), ), + // 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, + ), + ), + ), + ), ], ), ), diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index 01bfa3b..a3238d1 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -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 { 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 _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 { 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 { 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 { _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 { } 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 { } } + /// 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.of(list)); + setState(() { + final next = List.of(list); + next[sel.index] = translateStroke(next[sel.index], dx, dy); + _strokesByPage[sel.page] = next; + }); + _schedulePageSave(sel.page, List.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.of(list)); + setState(() { + final next = List.of(list)..removeAt(sel.index); + _strokesByPage[sel.page] = next; + _selected = null; + }); + _schedulePageSave(sel.page, List.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 { 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 { ); } _liveStrokePage = null; + _shapeDrag = null; + _selectLast = null; + _selectDragging = false; _livePoints.clear(); _liveStrokeVN.value = null; _bumpOverlay(); @@ -600,21 +774,21 @@ class _PenEditorScreenState extends State { 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 { _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 { setState(() { _selectTextMode = true; _placeLinkMode = false; + _selected = null; }); } @@ -966,6 +1142,10 @@ class _PenEditorScreenState extends State { 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 { 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 { } 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; } diff --git a/lib/editor/canvas/pen_note_screen.dart b/lib/editor/canvas/pen_note_screen.dart index 7d6c6ec..72a7919 100644 --- a/lib/editor/canvas/pen_note_screen.dart +++ b/lib/editor/canvas/pen_note_screen.dart @@ -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 { final List> _undo = []; final List> _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 _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 { _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.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 { 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 { 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 { } 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), diff --git a/lib/editor/canvas/pen_palette_widgets.dart b/lib/editor/canvas/pen_palette_widgets.dart index 2f1380f..f5f91d6 100644 --- a/lib/editor/canvas/pen_palette_widgets.dart +++ b/lib/editor/canvas/pen_palette_widgets.dart @@ -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 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 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( + tooltip: tooltip, + initialValue: selected, + onSelected: onSelected, + itemBuilder: (context) => [ + for (final s in ShapeKind.values) + PopupMenuItem( + 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({ diff --git a/lib/editor/canvas/pen_slide_screen.dart b/lib/editor/canvas/pen_slide_screen.dart index 3aeb3b2..0881b4f 100644 --- a/lib/editor/canvas/pen_slide_screen.dart +++ b/lib/editor/canvas/pen_slide_screen.dart @@ -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 { /// keeps the slide's aspect (no distortion). Null until loaded. Map? _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 _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 { setState(() { _slideIndex = clamped; _needsCenter = true; + _selectedStroke = null; // selection is per-slide }); } @@ -280,10 +303,41 @@ class _PenSlideScreenState extends State { _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.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 { 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 { 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 { } 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), diff --git a/lib/editor/engine/shape_geometry.dart b/lib/editor/engine/shape_geometry.dart new file mode 100644 index 0000000..84b4f0e --- /dev/null +++ b/lib/editor/engine/shape_geometry.dart @@ -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 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 = []; + 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(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 translatePoints(List 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); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 66748d5..06956d7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index df28fee..bb0fc94 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 30150bf..1bc24c4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a020a0b..daab903 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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 => '撤销'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index ad187ba..247d5c8 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -42,6 +42,14 @@ "brushBallpoint": "圆珠笔", "brushPencil": "铅笔", "brushHighlighter": "荧光笔", + "toolSelect": "选择", + "toolShape": "形状", + "shapePicker": "形状", + "shapeLine": "直线", + "shapeRectangle": "矩形", + "shapeEllipse": "椭圆", + "shapeArrow": "箭头", + "actionDeleteSelection": "删除所选", "actionUndo": "撤销", "actionRedo": "重做", "fingerDrawingOn": "手指书写:开", diff --git a/lib/screens/split_view_screen.dart b/lib/screens/split_view_screen.dart index 167b533..986e159 100644 --- a/lib/screens/split_view_screen.dart +++ b/lib/screens/split_view_screen.dart @@ -383,6 +383,9 @@ class _SplitViewState extends State { 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; diff --git a/test/pen_brush_color_memory_test.dart b/test/pen_brush_color_memory_test.dart new file mode 100644 index 0000000..7675751 --- /dev/null +++ b/test/pen_brush_color_memory_test.dart @@ -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(find.byType(PenCanvas)).color; + + /// Pick the brush named [label] from the BrushPickerButton popup menu. + Future 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 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'); + }); +} diff --git a/test/pen_select_move_test.dart b/test/pen_select_move_test.dart new file mode 100644 index 0000000..466097c --- /dev/null +++ b/test/pen_select_move_test.dart @@ -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(find.byType(PenCanvas)); + + List strokes(WidgetTester tester) => canvas(tester).strokes; + + /// Activate the SELECT tool via its toolbar button (the "Select" tooltip). + Future 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)); + }); +} diff --git a/test/shape_geometry_test.dart b/test/shape_geometry_test.dart new file mode 100644 index 0000000..184f22f --- /dev/null +++ b/test/shape_geometry_test.dart @@ -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({0.2, 0.8})); + expect(ys, containsAll({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); + }); + }); +}