Compare commits
No commits in common. "5affe87f21d4711a1a1236c939ea5c85df4966f8" and "66ba4c7b5c778446af7e0db3ef74ea807e70a746" have entirely different histories.
5affe87f21
...
66ba4c7b5c
10 changed files with 10 additions and 400 deletions
|
|
@ -1,2 +0,0 @@
|
||||||
schema: spec-driven
|
|
||||||
created: 2026-08-03
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
# 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).
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
# Proposal: Side panel UX improvements
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
Opening the side panel requires a manual click into the textarea before typing, the editor font is small (13px) with no way to adjust it, and the caret position is lost every time the panel closes or the tab switches — forcing users to scroll back to where they were.
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
- Focus the editor automatically when the side panel opens, restoring the caret to its last saved position (and scroll offset) instead of the top of the note.
|
|
||||||
- Persist caret position (`selectionStart`/`selectionEnd`) and `scrollTop` on the note record, written during autosave, on a debounce after caret-only movement, and flushed on blur/panel hide. Restored positions are clamped to the current content length.
|
|
||||||
- Prevent caret-only saves from disturbing other open panels: cross-panel reload messages distinguish content changes from caret-only updates so a remote caret save never resets another panel's caret.
|
|
||||||
- Bump the default editor font size to 16px (already monospace) and add font-size controls (`A-` / `A+`) in the header next to the notes-list button, plus `Ctrl+=` / `Ctrl+-` keyboard shortcuts and `Ctrl+0` to reset. The chosen size persists in `localStorage` and applies across panel sessions.
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
|
|
||||||
(none)
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
|
|
||||||
- `note-editor`: new requirements for focus-on-open with caret/scroll restoration, adjustable persisted editor font size, and cross-panel reload that ignores caret-only remote updates.
|
|
||||||
- `notes-storage`: the note record data model gains optional caret and scroll-position fields.
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- `sidepanel/sidepanel.js` — focus/restore logic, caret tracking, font-size controls and shortcuts, broadcast message handling.
|
|
||||||
- `sidepanel/sidepanel.html` — `A-` / `A+` buttons in the header.
|
|
||||||
- `sidepanel/sidepanel.css` — base font-size moved to a variable/inline style; button styles.
|
|
||||||
- `shared/storage.js` — save path accepts and preserves caret/scroll fields; no IndexedDB schema migration needed (records are schemaless).
|
|
||||||
- No manifest or permission changes; `localStorage` needs no permission.
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: Editor focused on panel open with restored caret
|
|
||||||
WHEN the side panel opens, the system SHALL place input focus in the note editor and restore the caret to the position last saved for that note, including the editor's scroll offset. Restoration also applies when the panel switches to a different tab's note or reloads after navigation. If the saved position exceeds the current content length (e.g., the note was edited elsewhere), it SHALL be clamped to a valid offset. Notes saved before this feature have no stored position and SHALL restore to the start of the note.
|
|
||||||
|
|
||||||
#### Scenario: Panel opens with caret where the user left it
|
|
||||||
- **WHEN** a user edits a note, moves the caret to a specific position, closes the panel, and reopens it on the same page
|
|
||||||
- **THEN** the editor has focus and the caret is at the last saved position with the same scroll offset
|
|
||||||
|
|
||||||
#### Scenario: Switching tabs restores that note's caret
|
|
||||||
- **WHEN** a user switches to a different tab whose note has a saved caret position
|
|
||||||
- **THEN** the panel shows the new tab's note with its own saved caret position restored
|
|
||||||
|
|
||||||
#### Scenario: Saved position beyond current content is clamped
|
|
||||||
- **WHEN** a note's content was shortened in another panel after the caret position was saved
|
|
||||||
- **THEN** the caret is restored at the end of the current content rather than an invalid offset
|
|
||||||
|
|
||||||
#### Scenario: Legacy note without a saved position
|
|
||||||
- **WHEN** a user opens a note that was saved before caret persistence existed
|
|
||||||
- **THEN** the editor has focus with the caret at the start of the note and no error occurs
|
|
||||||
|
|
||||||
### Requirement: Caret position saved continuously
|
|
||||||
The system SHALL save the caret position (and editor scroll offset) as part of every autosave, SHALL also persist caret-only movement (selection changes without content edits) after a short debounce, and SHALL flush any unsaved caret position when the editor loses focus or the panel is hidden.
|
|
||||||
|
|
||||||
#### Scenario: Caret saved with content autosave
|
|
||||||
- **WHEN** a user types in the editor and the autosave completes
|
|
||||||
- **THEN** the stored note record includes the caret position and scroll offset at save time
|
|
||||||
|
|
||||||
#### Scenario: Caret-only movement is persisted
|
|
||||||
- **WHEN** a user moves the caret without typing and then closes the panel
|
|
||||||
- **THEN** the last caret position has been persisted and is restored on the next open
|
|
||||||
|
|
||||||
### Requirement: Caret-only remote saves do not disturb other panels
|
|
||||||
WHEN the same note is open in more than one panel and a remote panel persists only a caret/scroll update (content unchanged), other panels SHALL NOT reload the editor content or move their own caret. Content-changing remote saves keep the existing refresh behavior (reload immediately, or defer until blur when the local editor is focused).
|
|
||||||
|
|
||||||
#### Scenario: Remote caret save leaves local caret untouched
|
|
||||||
- **WHEN** a note open in tab A persists a caret-only update and the same note is open in tab B
|
|
||||||
- **THEN** tab B's editor content and caret position remain unchanged
|
|
||||||
|
|
||||||
#### Scenario: Remote content save still refreshes
|
|
||||||
- **WHEN** a note open in tab A saves changed content and the same note is open in tab B without editor focus
|
|
||||||
- **THEN** tab B's panel updates to show the content saved from tab A
|
|
||||||
|
|
||||||
### Requirement: Adjustable editor font size
|
|
||||||
The editor SHALL render note text in a monospace font at a user-adjustable size, defaulting to 16px when no preference is stored. The size SHALL be changeable via decrease/increase controls in the panel header adjacent to the notes-list button, and via keyboard shortcuts (increase: `Ctrl+=`/`Cmd+=`, decrease: `Ctrl+-`/`Cmd+-`, reset to default: `Ctrl+0`/`Cmd+0`). The size SHALL be clamped to a bounded range (minimum 10px, maximum 24px, step 1px) and persisted in local storage so it applies to subsequent panel sessions.
|
|
||||||
|
|
||||||
#### Scenario: Default size for new users
|
|
||||||
- **WHEN** a user opens the side panel with no stored font-size preference
|
|
||||||
- **THEN** the editor text renders at 16px in a monospace font
|
|
||||||
|
|
||||||
#### Scenario: Increase and decrease via header controls
|
|
||||||
- **WHEN** a user clicks the font-size increase control, then the decrease control
|
|
||||||
- **THEN** the editor font size grows by one step, then returns to its previous size
|
|
||||||
|
|
||||||
#### Scenario: Keyboard shortcuts adjust the size
|
|
||||||
- **WHEN** the user presses `Ctrl+=` and later `Ctrl+-` while the panel is focused
|
|
||||||
- **THEN** the editor font size increases by one step, then decreases by one step, and the browser's page zoom is not triggered
|
|
||||||
|
|
||||||
#### Scenario: Reset shortcut restores the default
|
|
||||||
- **WHEN** a user has changed the font size and presses `Ctrl+0`
|
|
||||||
- **THEN** the editor font size returns to 16px
|
|
||||||
|
|
||||||
#### Scenario: Size is clamped at the bounds
|
|
||||||
- **WHEN** a user repeatedly decreases the font size below the minimum or increases it above the maximum
|
|
||||||
- **THEN** the size stops at the bound and the control remains usable in the opposite direction
|
|
||||||
|
|
||||||
#### Scenario: Size persists across sessions
|
|
||||||
- **WHEN** a user changes the font size, closes the panel, and reopens it later
|
|
||||||
- **THEN** the editor renders at the previously chosen size
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: Note record data model
|
|
||||||
Each stored note record SHALL include: a stable unique identifier, the derived URL key, the page title and favicon URL captured at save time, the markdown content, a creation timestamp, a last-updated timestamp, and an optional deletion timestamp. A record MAY additionally include the editor caret position (selection start and end offsets) and the editor scroll offset, captured at save time; these fields are optional and absent on records created before caret persistence existed. Caret/scroll updates MUST NOT modify the content, timestamps, or identity of the record.
|
|
||||||
|
|
||||||
#### Scenario: Metadata captured on first save
|
|
||||||
- **WHEN** a user's edits to a page's note are saved for the first time
|
|
||||||
- **THEN** the stored record includes a unique id, the derived key, the page's title, its favicon URL, the content, and matching creation and last-updated timestamps
|
|
||||||
|
|
||||||
#### Scenario: Caret and scroll captured on save
|
|
||||||
- **WHEN** a note is saved while the editor has a known caret position and scroll offset
|
|
||||||
- **THEN** the stored record includes those values alongside the content
|
|
||||||
|
|
||||||
#### Scenario: Caret-only update preserves other fields
|
|
||||||
- **WHEN** a caret/scroll update is persisted without any content change
|
|
||||||
- **THEN** the record's content, page metadata, and creation timestamp are unchanged
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
# Tasks: Side panel UX improvements
|
|
||||||
|
|
||||||
## 1. Storage: caret/scroll fields on the note record
|
|
||||||
|
|
||||||
- [x] 1.1 Extend `saveNote` in `shared/storage.js` with an optional caret parameter (`{ caretStart, caretEnd, scrollTop }`); when provided, write those fields onto the record (both insert and update-in-place paths); when absent, preserve existing fields
|
|
||||||
- [x] 1.2 Verify old records without caret fields still load and save unchanged (no IndexedDB version bump)
|
|
||||||
|
|
||||||
## 2. Caret persistence in the side panel
|
|
||||||
|
|
||||||
- [x] 2.1 In `sidepanel/sidepanel.js`, pass current caret (`selectionStart`/`selectionEnd`) and `scrollTop` to `saveNote` in the existing autosave (`doSave`)
|
|
||||||
- [x] 2.2 Track caret-only movement via `document` `selectionchange` (filtered to the editor) and editor `scroll`; debounce ~800ms into a caret-only save that calls `saveNote` without broadcasting; skip it while a content autosave is pending
|
|
||||||
- [x] 2.3 Flush any pending caret-only save on editor `blur` and on `visibilitychange` → hidden
|
|
||||||
|
|
||||||
## 3. Focus and caret/scroll restore
|
|
||||||
|
|
||||||
- [x] 3.1 In `loadNoteForTab`, after setting `editor.value`, restore caret via `setSelectionRange` with offsets clamped to content length (default 0 for legacy records), restore `scrollTop` (in `requestAnimationFrame` if layout timing clamps it), and call `editor.focus()`
|
|
||||||
- [x] 3.2 Make `reloadFromDB` skip the `editor.value` assignment when fetched content is identical, so remote saves never move the local caret unnecessarily
|
|
||||||
- [ ] 3.3 Manually verify focus lands when the panel opens via toolbar action and via keyboard shortcut; caret restore works on panel open, tab switch, and SPA navigation
|
|
||||||
|
|
||||||
## 4. Adjustable font size
|
|
||||||
|
|
||||||
- [x] 4.1 Add `A-` and `A+` buttons to the header in `sidepanel/sidepanel.html` before the `☰` button; style them like `#open-list` in `sidepanel/sidepanel.css`
|
|
||||||
- [x] 4.2 In `sidepanel/sidepanel.js`, implement font-size state: default 16px, range 10–24px, step 1px; apply via `editor.style.fontSize`; load from `localStorage` (`side-notes:fontSize`) at init and persist on every change
|
|
||||||
- [x] 4.3 Wire the two buttons to decrease/increase with clamping
|
|
||||||
- [x] 4.4 Add keyboard shortcuts in the existing keydown handler: `Ctrl/Cmd+=` (and `+` variant) increase, `Ctrl/Cmd+-` decrease, `Ctrl/Cmd+0` reset to 16px, all with `preventDefault()`
|
|
||||||
- [ ] 4.5 Verify the find match scrolling (`selectMatch`) still scrolls correctly at min and max font sizes
|
|
||||||
|
|
||||||
## 5. Validation
|
|
||||||
|
|
||||||
- [ ] 5.1 Load the unpacked extension and walk every scenario in `specs/note-editor/spec.md` and `specs/notes-storage/spec.md`
|
|
||||||
- [ ] 5.2 Two-panel test: same note in two tabs — caret-only movement in A does not disturb B's caret; content edit in A still refreshes B (immediately when unfocused, deferred until blur when focused)
|
|
||||||
|
|
@ -76,12 +76,9 @@ export async function getNoteByKey(key) {
|
||||||
* @param {string} key
|
* @param {string} key
|
||||||
* @param {string} content — markdown body
|
* @param {string} content — markdown body
|
||||||
* @param {{ pageTitle?: string, favIconUrl?: string }} pageMeta
|
* @param {{ pageTitle?: string, favIconUrl?: string }} pageMeta
|
||||||
* @param {{ caretStart?: number, caretEnd?: number, scrollTop?: number }} [caret]
|
|
||||||
* Optional editor caret/scroll state captured at save time. When
|
|
||||||
* omitted, any previously stored caret fields are preserved.
|
|
||||||
* @returns {Promise<object>} the saved record
|
* @returns {Promise<object>} the saved record
|
||||||
*/
|
*/
|
||||||
export async function saveNote(key, content, pageMeta = {}, caret) {
|
export async function saveNote(key, content, pageMeta = {}) {
|
||||||
const db = await openDB();
|
const db = await openDB();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
|
@ -103,11 +100,6 @@ export async function saveNote(key, content, pageMeta = {}, caret) {
|
||||||
favIconUrl: pageMeta.favIconUrl ?? existing.favIconUrl ?? '',
|
favIconUrl: pageMeta.favIconUrl ?? existing.favIconUrl ?? '',
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
if (caret) {
|
|
||||||
updated.caretStart = caret.caretStart ?? 0;
|
|
||||||
updated.caretEnd = caret.caretEnd ?? 0;
|
|
||||||
updated.scrollTop = caret.scrollTop ?? 0;
|
|
||||||
}
|
|
||||||
req = store.put(updated);
|
req = store.put(updated);
|
||||||
} else {
|
} else {
|
||||||
// Insert new.
|
// Insert new.
|
||||||
|
|
@ -121,11 +113,6 @@ export async function saveNote(key, content, pageMeta = {}, caret) {
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
if (caret) {
|
|
||||||
record.caretStart = caret.caretStart ?? 0;
|
|
||||||
record.caretEnd = caret.caretEnd ?? 0;
|
|
||||||
record.scrollTop = caret.scrollTop ?? 0;
|
|
||||||
}
|
|
||||||
req = store.add(record);
|
req = store.add(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ html, body {
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
|
font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
|
||||||
font-size: 16px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
resize: none;
|
resize: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
@ -142,31 +142,22 @@ html, body {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#open-list,
|
#open-list {
|
||||||
#font-decrease,
|
|
||||||
#font-increase {
|
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#open-list:hover,
|
#open-list:hover {
|
||||||
#font-decrease:hover,
|
|
||||||
#font-increase:hover {
|
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
#font-decrease,
|
|
||||||
#font-increase {
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Status bar ---- */
|
/* ---- Status bar ---- */
|
||||||
#status {
|
#status {
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,10 @@
|
||||||
<img id="favicon" src="" alt="" width="16" height="16" hidden>
|
<img id="favicon" src="" alt="" width="16" height="16" hidden>
|
||||||
<span id="page-title">Loading…</span>
|
<span id="page-title">Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="font-decrease" title="Decrease font size" aria-label="Decrease font size">A-</button>
|
|
||||||
<button id="font-increase" title="Increase font size" aria-label="Increase font size">A+</button>
|
|
||||||
<button id="open-list" title="View all notes" aria-label="View all notes">☰</button>
|
<button id="open-list" title="View all notes" aria-label="View all notes">☰</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea id="editor" placeholder="Write your note…" spellcheck="true" autofocus></textarea>
|
<textarea id="editor" placeholder="Write your note…" spellcheck="true"></textarea>
|
||||||
|
|
||||||
<div id="find-bar" hidden>
|
<div id="find-bar" hidden>
|
||||||
<input type="search" id="find-input" placeholder="Find in note…">
|
<input type="search" id="find-input" placeholder="Find in note…">
|
||||||
|
|
|
||||||
|
|
@ -30,30 +30,6 @@ const findPrev = document.getElementById('find-prev');
|
||||||
const findNext = document.getElementById('find-next');
|
const findNext = document.getElementById('find-next');
|
||||||
const findClose = document.getElementById('find-close');
|
const findClose = document.getElementById('find-close');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Font size — persisted in localStorage, applied as an inline style
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const FONT_SIZE_KEY = 'side-notes:fontSize';
|
|
||||||
const FONT_SIZE_DEFAULT = 16;
|
|
||||||
const FONT_SIZE_MIN = 10;
|
|
||||||
const FONT_SIZE_MAX = 24;
|
|
||||||
const FONT_SIZE_STEP = 1;
|
|
||||||
|
|
||||||
function loadFontSize() {
|
|
||||||
const raw = Number(localStorage.getItem(FONT_SIZE_KEY));
|
|
||||||
if (!Number.isFinite(raw) || raw <= 0) return FONT_SIZE_DEFAULT;
|
|
||||||
return Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, Math.round(raw)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFontSize(size) {
|
|
||||||
const clamped = Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, size));
|
|
||||||
editor.style.fontSize = `${clamped}px`;
|
|
||||||
localStorage.setItem(FONT_SIZE_KEY, String(clamped));
|
|
||||||
return clamped;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fontSize = applyFontSize(loadFontSize());
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// State
|
// State
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -124,19 +100,6 @@ async function loadNoteForTab(tabId) {
|
||||||
editor.value = note ? note.content : '';
|
editor.value = note ? note.content : '';
|
||||||
isLoadingContent = false;
|
isLoadingContent = false;
|
||||||
|
|
||||||
// Restore caret and scroll position, clamped to the current content
|
|
||||||
// length (another panel may have shortened the note since the position
|
|
||||||
// was saved). Records saved before caret persistence default to 0.
|
|
||||||
const len = editor.value.length;
|
|
||||||
const start = Math.min(note?.caretStart ?? 0, len);
|
|
||||||
const end = Math.min(note?.caretEnd ?? start, len);
|
|
||||||
editor.setSelectionRange(start, end);
|
|
||||||
const savedScrollTop = note?.scrollTop ?? 0;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
editor.scrollTop = savedScrollTop;
|
|
||||||
});
|
|
||||||
editor.focus();
|
|
||||||
|
|
||||||
updateStatus('Saved');
|
updateStatus('Saved');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Side Notes: failed to load note for tab', tabId, err);
|
console.error('Side Notes: failed to load note for tab', tabId, err);
|
||||||
|
|
@ -155,7 +118,6 @@ function scheduleSave() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSave() {
|
async function doSave() {
|
||||||
saveTimer = null;
|
|
||||||
if (!currentKey || currentTabId == null) return;
|
if (!currentKey || currentTabId == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -165,11 +127,7 @@ async function doSave() {
|
||||||
favIconUrl: tab.favIconUrl || '',
|
favIconUrl: tab.favIconUrl || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const saved = await saveNote(currentKey, editor.value, pageMeta, {
|
const saved = await saveNote(currentKey, editor.value, pageMeta);
|
||||||
caretStart: editor.selectionStart,
|
|
||||||
caretEnd: editor.selectionEnd,
|
|
||||||
scrollTop: editor.scrollTop,
|
|
||||||
});
|
|
||||||
currentNote = saved;
|
currentNote = saved;
|
||||||
updateStatus('Saved');
|
updateStatus('Saved');
|
||||||
|
|
||||||
|
|
@ -226,14 +184,9 @@ async function reloadFromDB() {
|
||||||
if (!currentKey) return;
|
if (!currentKey) return;
|
||||||
try {
|
try {
|
||||||
const note = await getNoteByKey(currentKey);
|
const note = await getNoteByKey(currentKey);
|
||||||
const content = note ? note.content : '';
|
isLoadingContent = true;
|
||||||
// Skip the assignment when content is unchanged: setting editor.value
|
editor.value = note ? note.content : '';
|
||||||
// would needlessly reset this panel's caret to the start.
|
isLoadingContent = false;
|
||||||
if (content !== editor.value) {
|
|
||||||
isLoadingContent = true;
|
|
||||||
editor.value = content;
|
|
||||||
isLoadingContent = false;
|
|
||||||
}
|
|
||||||
currentNote = note;
|
currentNote = note;
|
||||||
updateStatus('Saved');
|
updateStatus('Saved');
|
||||||
deferredReload = false;
|
deferredReload = false;
|
||||||
|
|
@ -262,69 +215,8 @@ editor.addEventListener('blur', () => {
|
||||||
if (deferredReload) {
|
if (deferredReload) {
|
||||||
reloadFromDB();
|
reloadFromDB();
|
||||||
}
|
}
|
||||||
flushCaretSave();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
|
||||||
if (document.visibilityState === 'hidden') {
|
|
||||||
flushCaretSave();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function flushCaretSave() {
|
|
||||||
if (caretTimer) {
|
|
||||||
clearTimeout(caretTimer);
|
|
||||||
doCaretSave();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Caret-only persistence
|
|
||||||
//
|
|
||||||
// Caret moves (selectionchange / scroll) without content edits are saved on
|
|
||||||
// a debounce. These saves never broadcast: a remote caret update must not
|
|
||||||
// make other panels reload and lose their own caret.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
let caretTimer = null;
|
|
||||||
const CARET_DEBOUNCE_MS = 800;
|
|
||||||
|
|
||||||
function scheduleCaretSave() {
|
|
||||||
if (!currentKey || isLoadingContent) return;
|
|
||||||
// A pending content autosave will capture the caret anyway.
|
|
||||||
if (saveTimer) return;
|
|
||||||
if (caretTimer) clearTimeout(caretTimer);
|
|
||||||
caretTimer = setTimeout(doCaretSave, CARET_DEBOUNCE_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doCaretSave() {
|
|
||||||
caretTimer = null;
|
|
||||||
if (!currentKey || currentTabId == null) return;
|
|
||||||
// A content save got scheduled meanwhile — it captures the caret.
|
|
||||||
if (saveTimer) return;
|
|
||||||
// A remote content change is waiting for blur; our content is stale and
|
|
||||||
// must not be written back over it.
|
|
||||||
if (deferredReload) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const saved = await saveNote(currentKey, editor.value, {}, {
|
|
||||||
caretStart: editor.selectionStart,
|
|
||||||
caretEnd: editor.selectionEnd,
|
|
||||||
scrollTop: editor.scrollTop,
|
|
||||||
});
|
|
||||||
currentNote = saved;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Side Notes: caret save failed', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('selectionchange', () => {
|
|
||||||
if (document.activeElement === editor) {
|
|
||||||
scheduleCaretSave();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.addEventListener('scroll', scheduleCaretSave);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// In-panel Find
|
// In-panel Find
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -414,19 +306,6 @@ document.addEventListener('keydown', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openFind();
|
openFind();
|
||||||
}
|
}
|
||||||
// Font size: Ctrl/Cmd+= (or +) increase, Ctrl/Cmd+- decrease, Ctrl/Cmd+0 reset
|
|
||||||
if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) {
|
|
||||||
e.preventDefault();
|
|
||||||
fontSize = applyFontSize(fontSize + FONT_SIZE_STEP);
|
|
||||||
}
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === '-') {
|
|
||||||
e.preventDefault();
|
|
||||||
fontSize = applyFontSize(fontSize - FONT_SIZE_STEP);
|
|
||||||
}
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === '0') {
|
|
||||||
e.preventDefault();
|
|
||||||
fontSize = applyFontSize(FONT_SIZE_DEFAULT);
|
|
||||||
}
|
|
||||||
// Escape → close find bar
|
// Escape → close find bar
|
||||||
if (e.key === 'Escape' && !findBar.hidden) {
|
if (e.key === 'Escape' && !findBar.hidden) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -470,14 +349,6 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('font-decrease').addEventListener('click', () => {
|
|
||||||
fontSize = applyFontSize(fontSize - FONT_SIZE_STEP);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('font-increase').addEventListener('click', () => {
|
|
||||||
fontSize = applyFontSize(fontSize + FONT_SIZE_STEP);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Open notes list page
|
// Open notes list page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -497,31 +368,10 @@ document.getElementById('open-list').addEventListener('click', () => {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Init
|
// Init
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Chrome does not always grant focus to the panel document the moment it
|
|
||||||
// opens (notably via the keyboard shortcut), so the focus() call in
|
|
||||||
// loadNoteForTab can silently fail. Retry briefly after init, stopping as
|
|
||||||
// soon as the editor has focus or the user interacts with anything else.
|
|
||||||
function retryEditorFocus() {
|
|
||||||
let attempts = 0;
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
attempts++;
|
|
||||||
if (document.activeElement === editor || attempts > 10) {
|
|
||||||
clearInterval(timer);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor.focus();
|
|
||||||
}, 100);
|
|
||||||
const stop = () => clearInterval(timer);
|
|
||||||
window.addEventListener('pointerdown', stop, { capture: true, once: true });
|
|
||||||
window.addEventListener('keydown', stop, { capture: true, once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
(async function init() {
|
(async function init() {
|
||||||
const tabId = await resolveHostTab();
|
const tabId = await resolveHostTab();
|
||||||
if (tabId != null) {
|
if (tabId != null) {
|
||||||
await loadNoteForTab(tabId);
|
await loadNoteForTab(tabId);
|
||||||
retryEditorFocus();
|
|
||||||
} else {
|
} else {
|
||||||
pageTitleEl.textContent = 'Side Notes';
|
pageTitleEl.textContent = 'Side Notes';
|
||||||
updateStatus('Could not detect active tab');
|
updateStatus('Could not detect active tab');
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue