68 lines
6.4 KiB
Markdown
68 lines
6.4 KiB
Markdown
|
|
# Design: Side panel UX improvements
|
|||
|
|
|
|||
|
|
## Context
|
|||
|
|
|
|||
|
|
The side panel (`sidepanel/sidepanel.js`) is a single `<textarea>` editor with debounced autosave (500ms) into IndexedDB via `shared/storage.js`. Cross-panel consistency uses a `BroadcastChannel('side-notes')`: every save posts `{type:'save', key, updatedAt}`, and any other panel showing the same key reloads `editor.value` — which resets that panel's caret to the start. Note records are plain JS objects (IndexedDB is schemaless), so adding fields needs no version bump or migration. See proposal.md for motivation.
|
|||
|
|
|
|||
|
|
## Goals / Non-Goals
|
|||
|
|
|
|||
|
|
**Goals:**
|
|||
|
|
- Focus + caret/scroll restoration on every `loadNoteForTab` path (panel open, tab switch, navigation) with one code path.
|
|||
|
|
- Caret persistence that never disturbs other open panels.
|
|||
|
|
- Font-size adjustment (buttons + shortcuts) persisted in `localStorage`.
|
|||
|
|
|
|||
|
|
**Non-Goals:**
|
|||
|
|
- Restoring selection direction or multi-range selections (collapsed caret and simple ranges only).
|
|||
|
|
- Applying the font size to the notes-list page.
|
|||
|
|
- Syncing the font-size preference across devices (localStorage is per-machine).
|
|||
|
|
|
|||
|
|
## Decisions
|
|||
|
|
|
|||
|
|
### 1. Caret/scroll live on the note record
|
|||
|
|
|
|||
|
|
Store `caretStart`, `caretEnd`, `scrollTop` on the note record in IndexedDB rather than in `localStorage` keyed by note key. The position is conceptually part of the note's state, travels with the record, and avoids a second lookup store. `saveNote` gains an optional `caret` parameter; when present, the fields are written on the record; when absent, existing fields are preserved (spread of `existing` already does this on update).
|
|||
|
|
|
|||
|
|
Alternative considered: `localStorage` map of key → position. Simpler writes, but duplicates the keying logic, can leak entries for deleted notes, and splits note state across two stores.
|
|||
|
|
|
|||
|
|
### 2. Three write paths for the caret
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
typing ─────────► existing 500ms autosave ──► saveNote(key, content, meta, {caret, scrollTop}) + broadcast 'save'
|
|||
|
|
caret move only ─► 800ms debounced caret save ► saveNote(..., {caretOnly: true}) — no broadcast
|
|||
|
|
blur / visibilitychange(hidden) ──► flush pending caret save immediately
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Caret-only movement is tracked via the `selectionchange` event (fires on the document when the textarea selection changes) plus `scroll` on the editor. The caret-only save calls `saveNote` with the *current* content (unchanged) and new caret fields — it reuses the existing update-in-place path, so content, timestamps, and metadata are untouched. `updatedAt` is refreshed; that is acceptable and keeps "last touched" honest.
|
|||
|
|
|
|||
|
|
Alternative considered: writing caret fields via a dedicated storage function. Rejected — a second write path to the same record invites lost-update races with autosave; funneling both through `saveNote` serializes them naturally.
|
|||
|
|
|
|||
|
|
### 3. Caret-only saves do not broadcast
|
|||
|
|
|
|||
|
|
The cross-panel reload exists to propagate *content* changes. A caret-only save broadcasting `type:'save'` would make every other panel reload `editor.value` and lose its own caret — two panels would fight. So caret-only saves post nothing. As defense in depth (and fixing the latent bug where a blur-triggered reload in panel B discards B's caret even when content is identical), `reloadFromDB` compares fetched content to `editor.value` and skips the assignment when identical, updating only its local caret if unfocused.
|
|||
|
|
|
|||
|
|
Alternative considered: a `caretOnly: true` flag on the broadcast so receivers can update their stored caret for future restores. Unnecessary — each panel persists its own caret on its own save/blur, so remote caret values are never needed live; they're read on load.
|
|||
|
|
|
|||
|
|
### 4. Restore is part of `loadNoteForTab`, clamped
|
|||
|
|
|
|||
|
|
After setting `editor.value`, restore via `editor.setSelectionRange(min(caretStart, len), min(caretEnd, len))` and `editor.scrollTop = savedScrollTop`, then `editor.focus()`. Focus is applied only in `loadNoteForTab` paths triggered by panel init and explicit loads — the `chrome.tabs.onActivated` handler also calls `loadNoteForTab`, but focusing there is still correct per the spec (switching tabs restores that note's caret; the panel only updates when the panel itself is focused/visible, since side panels are per-window). `autofocus` is *not* used: it only runs on first page load and can't express the caret restore.
|
|||
|
|
|
|||
|
|
Clamping handles the case where another panel shortened the note after the caret was saved. Legacy records without caret fields default to `0`/`0`/scroll `0`.
|
|||
|
|
|
|||
|
|
### 5. Font size: CSS variable + localStorage, buttons and shortcuts
|
|||
|
|
|
|||
|
|
- Default 16px; range 10–24px; step 1px. Applied as `editor.style.fontSize` (inline style beats the stylesheet; line-height stays the unitless `1.6` so it scales automatically — `selectMatch`'s computed-line-height math keeps working).
|
|||
|
|
- Persisted under a single `localStorage` key (e.g. `side-notes:fontSize`), read at init before first paint of content.
|
|||
|
|
- Header gains two buttons labeled `A-` and `A+` placed before the existing `☰` button, styled like it.
|
|||
|
|
- Shortcuts handled in the existing `document` keydown listener alongside Ctrl+F: `Ctrl/Cmd+=` (also accept `Ctrl/Cmd+'+'` for shift variants), `Ctrl/Cmd+-`, `Ctrl/Cmd+0` reset — all `preventDefault()`ed. Extension pages aren't affected by browser zoom shortcuts, but preventDefault is harmless insurance.
|
|||
|
|
|
|||
|
|
## Risks / Trade-offs
|
|||
|
|
|
|||
|
|
- **`selectionchange` fires very often** (every arrow key) → 800ms debounce, plus the guard that a caret-only save is skipped if a content autosave is pending (the autosave will capture the caret anyway).
|
|||
|
|
- **Caret-only save resets nothing visible but does write IndexedDB frequently** → writes are tiny records on a local DB; debounce keeps this to ~1/second max while moving.
|
|||
|
|
- **Chrome may not grant focus to the panel when opened without a user gesture** → `editor.focus()` is best-effort; worst case the caret is still restored and one click focuses. Verify manually on open-via-toolbar and open-via-context-menu.
|
|||
|
|
- **Restoring `scrollTop` immediately after setting `value` can be clamped by layout timing** → if restore lands before layout, wrap the scroll assignment in `requestAnimationFrame`.
|
|||
|
|
|
|||
|
|
## Migration Plan
|
|||
|
|
|
|||
|
|
No data migration: IndexedDB schema is unchanged (no new indexes, no version bump); old records simply lack the caret fields and default to offset 0. Rollback is a revert — stray caret fields on records are harmless to older code (ignored on read, preserved on update via the `...existing` spread).
|