From 55f938013c4b110287a99753f6aa7b58bd45d087 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 12:30:41 -0400 Subject: [PATCH 01/25] docs: add design spec for multi-tab scratchpad Captures decisions, storage schema, sync semantics, and code organization for adding tabs to the extension. --- .../specs/2026-05-03-tabs-design.md | 328 ++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-03-tabs-design.md diff --git a/docs/superpowers/specs/2026-05-03-tabs-design.md b/docs/superpowers/specs/2026-05-03-tabs-design.md new file mode 100644 index 0000000..1365383 --- /dev/null +++ b/docs/superpowers/specs/2026-05-03-tabs-design.md @@ -0,0 +1,328 @@ +# Scratchpad Tabs — Design + +## Summary + +Add multi-tab support to the Scratchpad Firefox extension. Each tab maintains +its own content with the same auto-save and cross-device sync semantics that +the single scratchpad has today. Tabs are unlimited, named, closeable with +undo, and stored as one item per tab in `browser.storage.sync`. + +## Goals + +- Each tab is an independent, syncing scratchpad with its own content. +- Existing single-scratchpad content is preserved as the first tab on upgrade. +- The user can create, rename, close, and undo-close tabs without leaving the + page. +- Cross-device sync continues to feel real-time for both content and the tab + list itself. +- The work area for an actively-edited tab is never silently overwritten by a + remote update. + +## Non-goals + +- Tab reordering (drag-and-drop or otherwise). +- Per-device tab visibility (every device sees the same set of tabs). +- Sharing the active-tab choice across devices. +- Tab pinning, color coding, archiving, or search. +- Importing/exporting tabs. +- A test framework. Verification stays manual through the existing + "load temporary add-on" workflow. + +## Key decisions + +| Decision | Choice | Rationale | +| --- | --- | --- | +| Existing content on upgrade | Becomes first tab named "Scratchpad" | No data loss; seamless upgrade | +| Tab naming | Auto-named "Tab N", double-click to rename | Low-friction, no required UI | +| Tab limit | Unlimited, overflow into `…` dropdown | Matches user request; storage scales per-tab | +| Storage layout | One key per tab + small index | Stays under sync per-item quota (~8KB) | +| Closing tabs | Undo toast (5s); cannot close last tab | Protects against accidental loss | +| Active tab on open | Last-active per device | Each device remembers its own focus | +| Tab order | Creation order, no reordering | Smallest scope satisfying the request | +| Keyboard shortcuts | `Ctrl+Alt+T` / `Ctrl+Alt+W` (new/close), `Ctrl+Tab` / `Ctrl+Shift+Tab` (next/prev) | Avoids Firefox-native shortcut conflicts | +| Remote tab deletion of active tab | Keep editor open with "Save as new tab?" banner | Protects in-progress work | +| Editor strategy | One `
` per tab, hidden via CSS | Preserves undo history and cursor per tab | + +## Storage schema + +### `browser.storage.sync` (synced across devices) + +- `tabIndex` — ordered array of tab metadata: + `[{ id: "t_", name: "Tab 1" }, …]` +- `tab_` — one item per tab whose value is the HTML content string for + that tab. + +### `browser.storage.local` (per-device) + +- `activeTabId` — id of the tab shown on this device when the page was last + open. + +### Migration on first launch + +Triggered when `tabIndex` is absent in sync storage: + +1. Read legacy `scratchpadContent`. +2. Generate a new tab id `t_`. +3. If legacy content exists: write `tab_ = `, + `tabIndex = [{ id, name: "Scratchpad" }]`, then delete + `scratchpadContent`. +4. If legacy content is absent: write `tab_ = ""`, + `tabIndex = [{ id, name: "Tab 1" }]`. +5. The invariant "at least one tab exists" is established by migration and + maintained by the close handler (which refuses to close the only tab). + +A migration failure (e.g., transient sync write error) aborts and leaves +`scratchpadContent` intact so the next launch retries cleanly. + +## DOM structure + +Top to bottom: + +1. Header (existing) — title, refresh button, last-update, status. +2. **Tab bar (new)** — horizontal list of tabs, `+` button, optional `…` + overflow menu. +3. Toolbar (existing) — formatting buttons. +4. **Editor stack (new)** — N sibling `
` elements, one + per tab. The active tab has class `active` (CSS `display: block`); others + are `display: none`. + +### Tab bar markup + +```html +
+
+ + +
+ + +
+``` + +### Overflow detection + +A `ResizeObserver` on `.tab-list` detects when rendered tabs do not fit. Tabs +that overflow are hidden (`display: none`) and listed in the `…` menu. +Selecting an overflowed tab in the menu activates it; the bar then +re-evaluates which tabs fit. + +### Tab interactions + +- Click tab label → activate that tab. +- Double-click tab label → swap label for an ``; blur or Enter + commits, Esc cancels. The new name is written to `tabIndex`. +- Click × on tab → close (with undo toast); the × is hidden when only one + tab exists. +- `+` button → create a new tab named `Tab N`, where `N` is the smallest + positive integer not currently used by any tab name; activate the new + tab and focus its editor. +- `Ctrl+Alt+T` and `Ctrl+Alt+W` mirror `+` and ×. +- `Ctrl+Tab` and `Ctrl+Shift+Tab` cycle through tabs (wrap-around). + +### Undo toast + +Bottom-right of the page: "Tab '\' closed — Undo". The toast +dismisses after 5s; clicking Undo restores the tab and its content. Both the +tab metadata and content are kept in memory until the toast expires. + +## In-memory state + +```text +tabs: Map + +tabOrder: id[] // mirrors tabIndex +activeTabId: id | null +recentlyClosed: { meta, content, timeoutId } | null +``` + +`tabs` and `tabOrder` are derived from `tabIndex` plus the per-tab content +items at startup (and resynced on every relevant `onChanged` event). + +## Save flow + +- `input` event on tab T's editor → + `tabs[T].contentChanged = true`; debounce → `saveTab(T)`. +- `saveTab(T)` → if current content differs from `lastSavedContent`, write + `tab_` to sync storage; on success update `lastSavedContent`, + clear `contentChanged`, refresh status indicator. +- 5-second auto-save interval iterates over `tabs` and saves any with + `contentChanged === true` (mirrors today's behavior, generalized). + +## Tab switch flow + +1. Synchronously flush any pending debounced save for the outgoing tab + (call `saveTab(outgoingId)` immediately rather than waiting for the + debounce). This prevents a remote sync check between switch and the + next debounce tick from overwriting unsaved work. +2. Toggle the `.active` class: outgoing editor and tab label lose it, + incoming gain it. +3. Update `activeTabId`, persist to `storage.local`. +4. Focus the incoming editor. + +## Cross-device sync + +The existing sync philosophy is preserved: real-time updates via +`storage.onChanged`, with a 10-second fallback poll, and a guard that +prevents overwriting actively-edited content. The guard is now per-tab. + +### `storage.onChanged` handler + +The `changes` payload may include `tabIndex` and any number of `tab_` +keys. Each is processed independently in this order: + +1. **`tab_` updates:** + - Unknown id → ignore for now; the upcoming or pending `tabIndex` change + will register it and trigger a content load. + - Known id, `contentChanged === false` → replace the editor content with + the new HTML, update `lastSavedContent`, refresh last-update time. + - Known id, `contentChanged === true` → defer. The next sync poll will + retry once typing pauses. (Same trade-off as today's single-tab guard.) + +2. **`tabIndex` updates:** diff old vs. new index by id and apply: + - **Added tabs:** create the editor element, append to the tab bar in + index order, fetch content from the change payload if present + otherwise read `tab_` from storage. + - **Removed tabs:** if the removed tab is not the active one, dispose + of its editor element and tab label. If it is the active one, switch + to a neighboring tab (the previous tab if any, otherwise the next), + keep the deleted editor in the DOM but hidden, and show a banner: + *"This tab was deleted on another device — Save as new tab?"* + The banner offers two actions: + - **Save as new tab** → create a fresh tab with the deleted editor's + current content. The new tab takes the deleted tab's previous name + (the user can rename afterward); dispose the deleted editor. + - **Dismiss (×)** → dispose the deleted editor; the work is gone. + - **Renamed tabs:** update the label text in the tab bar and the + overflow menu. + +### Periodic sync check (10s) + +The current single-key check is generalized: read `tabIndex` and every +`tab_` it references, then apply the same rules above. This catches +missed `onChanged` events (e.g., across device-sleep windows). + +### Concurrent tab creation (orphan recovery) + +If two devices each create a tab at roughly the same time, each writes a +different `tabIndex`. Last-writer-wins on `tabIndex` would drop the loser's +new tab from the index even though its `tab_` content item still +exists in storage. + +**Mitigation:** when applying a remote `tabIndex` update (or after the +periodic poll), enumerate all sync storage keys with prefix `tab_`. Any id +not present in the new `tabIndex` is treated as an orphan: append a +metadata entry for it to the local `tabIndex` (preserving its name from any +in-memory record, otherwise generating a new `Tab N` name) and write the +result back to sync storage. This is eventually-consistent without +coordination. + +### Conflict semantics + +Two devices editing the same tab simultaneously have last-writer-wins on +`tab_`, identical to current single-tab behavior. No new conflict +classes are introduced beyond orphan recovery above. + +## Keyboard shortcuts + +Bound at the `document` level (not per-editor) so they work even when the +focus is not on an editor: + +- `Ctrl+Alt+T` — new tab. +- `Ctrl+Alt+W` — close active tab (no-op if it is the only tab). +- `Ctrl+Tab` — next tab (wraps). +- `Ctrl+Shift+Tab` — previous tab (wraps). + +Existing `Ctrl+B`, `Ctrl+I`, `Ctrl+U`, `Ctrl+K` continue to work; they are +bound per-editor. + +`Ctrl+Tab` may be intercepted by Firefox depending on user configuration. +We capture it in the bubbling phase and `preventDefault()` to maximize the +chance of receiving it; if Firefox swallows it, we accept the fallback to +browser tab switching as not catastrophic. + +## Error handling + +- **Storage write failure for a tab content item** → show an error in the + status indicator naming the tab, retain `contentChanged = true` so the + next auto-save retries. +- **Quota exceeded** (most likely failure mode given unlimited tabs) → show + a banner *"Sync storage full. New changes will save locally only."* and + fall back to `browser.storage.local` for the offending tab. The fallback + pattern matches today's `initStorage()` behavior. +- **Migration failure** → log and abort; leave legacy `scratchpadContent` + intact so the next launch can retry. +- **Tab index write failure during create/rename/close** → revert the + in-memory change and show an error toast. The user's current view stays + consistent with what is in storage. + +## Code organization + +The current `scratchpad.js` is ~470 lines. Split into focused modules, +each with a single responsibility, all loaded as separate `` with: + +```html + + +``` + +(Order matters — `storage.js` must load first.) + +- [ ] **Step 3: Replace ad-hoc storage calls in `scratchpad.js`** + +In `scratchpad.js`: + +Delete lines 12–17 (the `storageArea` / `usingSyncStorage` declarations) and the entire `initStorage` function (lines 19–35). + +Replace the body of `loadContent` (around lines 162–181) with: + +```javascript +async function loadContent() { + try { + const result = await Scratchpad.Storage.get('scratchpadContent'); + if (result.scratchpadContent) { + setContent(result.scratchpadContent); + lastSavedContent = result.scratchpadContent; + console.log(`Loaded ${result.scratchpadContent.length} characters from storage`); + } + contentChanged = false; + updateStatus('loaded'); + updateLastUpdateTime(); + } catch (error) { + console.error('Error loading content:', error); + updateStatus('error', error.message); + } +} +``` + +Replace the body of `saveContent` (around lines 184–211) with: + +```javascript +async function saveContent() { + const currentContent = getContent(); + if (currentContent === lastSavedContent) { + console.log('Content unchanged, skipping save'); + return; + } + updateStatus('saving'); + try { + await Scratchpad.Storage.set({ scratchpadContent: currentContent }); + lastSavedContent = currentContent; + contentChanged = false; + updateStatus('saved'); + updateLastUpdateTime(); + console.log(`Saved ${currentContent.length} characters to ${Scratchpad.Storage.usingSync ? 'sync' : 'local'} storage`); + } catch (error) { + console.error('Error saving content:', error); + updateStatus('error', error.message); + } +} +``` + +Replace the `updateStatus` references to `usingSyncStorage` with `Scratchpad.Storage.usingSync` (search the file for `usingSyncStorage` and update each call). + +Replace the `browser.storage.onChanged.addListener` block (around lines 373–390) with: + +```javascript +Scratchpad.Storage.onChanged((changes) => { + if (changes.scratchpadContent) { + const newValue = changes.scratchpadContent.newValue || ''; + console.log(`Storage changed: ${newValue.length} chars`); + if (newValue !== getContent()) { + console.log('⚡ Real-time sync update detected'); + setContent(newValue); + lastSavedContent = newValue; + contentChanged = false; + updateLastUpdateTime(); + } + } +}); +``` + +Replace the body of `checkForSyncUpdates` (around lines 402–441) — replace the line `const result = await storageArea.get('scratchpadContent');` with `const result = await Scratchpad.Storage.get('scratchpadContent');` and remove the now-unused `if (!storageArea)` guard at the top. + +- [ ] **Step 4: Manual verification** + +Reload the extension, open the scratchpad. Verify: +- The status indicator shows "Ready (Synced)" or "Ready (Local)" — no console errors during load. +- Type "hello" — within 500ms the status flips to "Saving..." then "Synced" / "Saved". +- Reload the page (F5) — your "hello" reappears. +- Browser Console shows `[Storage] using sync` (or `local`) once at startup. + +- [ ] **Step 5: Commit** + +```bash +git add storage.js scratchpad.html scratchpad.js +git commit -m "refactor: extract storage layer into Scratchpad.Storage module" +``` + +--- + +## Task 2: Add editor module (refactor, no behavior change) + +**Files:** +- Create: `editor.js` +- Modify: `scratchpad.html` (add script tag) +- Modify: `scratchpad.js` (delegate to editor module) + +- [ ] **Step 1: Create `editor.js`** + +```javascript +// editor.js — Sanitization, formatting commands, link creation, per-editor shortcuts. +// Exposes window.Scratchpad.Editor. + +window.Scratchpad = window.Scratchpad || {}; + +const Editor = { + // Sanitize URL: only allow http, https, mailto, tel. Returns '' for unsafe URLs. + sanitizeURL(url) { + if (!url) return ''; + url = url.trim(); + const allowedProtocols = ['http:', 'https:', 'mailto:', 'tel:']; + try { + const parsed = new URL(url); + if (allowedProtocols.includes(parsed.protocol)) return url; + } catch (e) { + if (url.match(/^[a-zA-Z0-9][a-zA-Z0-9-._]*\.[a-zA-Z]{2,}/)) { + return 'https://' + url; + } + } + return ''; + }, + + // Sanitize HTML: allow a small whitelist of tags. Returns a DocumentFragment + // safe to insert into the editor. + sanitizeHTML(html) { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const allowedTags = ['B', 'STRONG', 'I', 'EM', 'U', 'UL', 'OL', 'LI', 'BR', 'DIV', 'P', 'SPAN', 'A']; + + function sanitizeNode(node) { + if (node.nodeType === Node.TEXT_NODE) return node.cloneNode(true); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + + if (!allowedTags.includes(node.tagName)) { + return document.createTextNode(node.textContent); + } + + const cleanNode = document.createElement(node.tagName); + + if (node.tagName === 'A') { + const sanitizedHref = Editor.sanitizeURL(node.getAttribute('href')); + if (sanitizedHref) { + cleanNode.setAttribute('href', sanitizedHref); + cleanNode.setAttribute('target', '_blank'); + cleanNode.setAttribute('rel', 'noopener noreferrer'); + } else { + return document.createTextNode(node.textContent); + } + } + + for (const child of node.childNodes) { + const sanitizedChild = sanitizeNode(child); + if (sanitizedChild) cleanNode.appendChild(sanitizedChild); + } + return cleanNode; + } + + const fragment = document.createDocumentFragment(); + for (const child of doc.body.childNodes) { + const sanitizedChild = sanitizeNode(child); + if (sanitizedChild) fragment.appendChild(sanitizedChild); + } + return fragment; + }, + + // Serialize an editor element's children to an HTML string (no innerHTML). + getContent(editorEl) { + const serializer = new XMLSerializer(); + let html = ''; + for (const child of editorEl.childNodes) { + if (child.nodeType === Node.TEXT_NODE) html += child.textContent; + else html += serializer.serializeToString(child); + } + return html; + }, + + // Replace an editor element's children with sanitized HTML. + setContent(editorEl, html) { + const sanitizedFragment = Editor.sanitizeHTML(html); + editorEl.replaceChildren(sanitizedFragment); + }, + + // Run a contenteditable formatting command on the active selection. + // Caller is responsible for re-focusing the editor afterward. + executeCommand(editorEl, command) { + document.execCommand(command, false, null); + editorEl.focus(); + }, + + // Prompt for a URL and create a link from the current selection. + // Returns true if a link was created, false otherwise. + createLink(editorEl) { + const selection = window.getSelection(); + const selectedText = selection.toString(); + let url = prompt('Enter URL:', selectedText.match(/^https?:\/\//) ? selectedText : 'https://'); + if (!url) return false; + url = Editor.sanitizeURL(url); + if (!url) { + alert('Invalid URL. Please use http://, https://, mailto:, or tel: URLs.'); + return false; + } + document.execCommand('createLink', false, url); + const links = editorEl.querySelectorAll('a[href]:not([target])'); + links.forEach(link => { + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); + }); + editorEl.focus(); + return true; + }, + + // Attach all per-editor behavior to a contenteditable element: + // - Ctrl+B/I/U formatting shortcuts + // - Ctrl+K link shortcut + // - click-to-open links in new tab + // - input event → onInputChange callback (for dirty tracking) + attachShortcuts(editorEl, onInputChange) { + editorEl.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.key === 'b') { e.preventDefault(); Editor.executeCommand(editorEl, 'bold'); onInputChange(); } + else if (e.ctrlKey && e.key === 'i') { e.preventDefault(); Editor.executeCommand(editorEl, 'italic'); onInputChange(); } + else if (e.ctrlKey && e.key === 'u') { e.preventDefault(); Editor.executeCommand(editorEl, 'underline'); onInputChange(); } + else if (e.ctrlKey && e.key === 'k') { e.preventDefault(); if (Editor.createLink(editorEl)) onInputChange(); } + }); + editorEl.addEventListener('input', () => onInputChange()); + editorEl.addEventListener('click', (e) => { + const link = e.target.closest('a[href]'); + if (!link) return; + e.preventDefault(); + const sanitizedHref = Editor.sanitizeURL(link.getAttribute('href')); + if (sanitizedHref) window.open(sanitizedHref, '_blank', 'noopener,noreferrer'); + }); + }, +}; + +window.Scratchpad.Editor = Editor; +``` + +- [ ] **Step 2: Add the script tag in `scratchpad.html`** + +After the ` +``` + +- [ ] **Step 3: Slim `scratchpad.js` to use the editor module** + +Delete from `scratchpad.js`: +- Functions `sanitizeURL` (lines 44–69) +- Function `sanitizeHTML` (lines 71–137) +- Function `getContent` (lines 139–152) +- Function `setContent` (lines 154–159) +- Function `executeCommand` (lines 257–262) +- Function `createLink` (lines 264–294) +- The `keydown` handler (lines 297–318), the `input` handler (line 321), and the `click` handler on `scratchpad` (lines 323–339) +- The toolbar-button delegation logic stays in `scratchpad.js` (it's wiring, not editor logic) + +Replace each call to the deleted functions inside the remaining code with the module call: + +| Old | New | +| --- | --- | +| `getContent()` | `Scratchpad.Editor.getContent(scratchpad)` | +| `setContent(html)` | `Scratchpad.Editor.setContent(scratchpad, html)` | +| `sanitizeURL(url)` | `Scratchpad.Editor.sanitizeURL(url)` | +| `executeCommand('bold')` | `Scratchpad.Editor.executeCommand(scratchpad, 'bold')` (then call `debouncedSave` to mark dirty) | +| `createLink()` | `if (Scratchpad.Editor.createLink(scratchpad)) debouncedSave();` | + +After the deletions, attach the per-editor shortcuts once at the bottom of `scratchpad.js`, just before `loadContent();`: + +```javascript +Scratchpad.Editor.attachShortcuts(scratchpad, () => { + contentChanged = true; + debouncedSave(); +}); +``` + +Update the toolbar button click handler so that the new-tab logic still works: + +```javascript +toolbarBtns.forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const command = btn.dataset.command; + if (command) { + Scratchpad.Editor.executeCommand(scratchpad, command); + contentChanged = true; + debouncedSave(); + } + }); +}); + +linkBtn.addEventListener('click', (e) => { + e.preventDefault(); + if (Scratchpad.Editor.createLink(scratchpad)) { + contentChanged = true; + debouncedSave(); + } +}); +``` + +- [ ] **Step 4: Manual verification** + +Reload the extension. Verify: +- Type some text, press Ctrl+B/I/U — formatting toggles work, content saves. +- Highlight text, press Ctrl+K, paste `https://example.com` — link is created, blue and underlined. +- Click the link — it opens in a new tab. +- Type `` literally (with `<` characters) — appears as text, no script execution. +- Reload page — formatting and links survive (sanitization on load preserves them). + +- [ ] **Step 5: Commit** + +```bash +git add editor.js scratchpad.html scratchpad.js +git commit -m "refactor: extract editor concerns into Scratchpad.Editor module" +``` + +--- + +## Task 3: Add migration module (single tab from legacy content) + +**Files:** +- Create: `migration.js` +- Modify: `scratchpad.html` (add script tag) +- Modify: `scratchpad.js` (call migration before loadContent) + +- [ ] **Step 1: Create `migration.js`** + +```javascript +// migration.js — One-time migration from legacy single-key storage to tabIndex + tab_. +// Exposes window.Scratchpad.Migration. + +window.Scratchpad = window.Scratchpad || {}; + +const Migration = { + // Returns a fresh tab id like "t_a1b2c3d4e5". + newTabId() { + return 't_' + Math.random().toString(36).slice(2, 12); + }, + + // Run migration if needed. Idempotent — safe to call on every page load. + // Side effect: after this returns, sync storage is guaranteed to contain + // a non-empty `tabIndex` and at least one `tab_` content item. + // Throws on storage failure (caller should log and proceed without further changes). + async runIfNeeded() { + const Storage = window.Scratchpad.Storage; + const existing = await Storage.get(['tabIndex', 'scratchpadContent']); + + if (Array.isArray(existing.tabIndex) && existing.tabIndex.length > 0) { + console.log('[Migration] already migrated, nothing to do'); + return; + } + + const id = Migration.newTabId(); + if (typeof existing.scratchpadContent === 'string' && existing.scratchpadContent.length > 0) { + console.log('[Migration] migrating legacy scratchpadContent into a tab'); + await Storage.set({ + tabIndex: [{ id, name: 'Scratchpad' }], + ['tab_' + id]: existing.scratchpadContent, + }); + await Storage.remove('scratchpadContent'); + } else { + console.log('[Migration] no legacy content; creating empty Tab 1'); + await Storage.set({ + tabIndex: [{ id, name: 'Tab 1' }], + ['tab_' + id]: '', + }); + } + }, +}; + +window.Scratchpad.Migration = Migration; +``` + +- [ ] **Step 2: Add the script tag in `scratchpad.html`** + +After the ` +``` + +- [ ] **Step 3: Run migration before content load in `scratchpad.js`** + +At the bottom of `scratchpad.js` where `loadContent();` is called, replace it with: + +```javascript +(async () => { + try { + await Scratchpad.Migration.runIfNeeded(); + } catch (e) { + console.error('[Migration] failed; continuing without migration:', e); + } + loadContent(); + startAutoSave(); + startSyncCheck(); +})(); +``` + +(Also remove the bare `loadContent(); startAutoSave(); startSyncCheck();` calls that were at the bottom — the IIFE replaces them.) + +> **Note:** the existing `loadContent` still reads the legacy `scratchpadContent` key. After migration, that key is gone. So after this task the editor will be **empty** even if you had content before. The next task replaces `loadContent` with tab-aware loading. This is a deliberate intermediate state — but to verify migration ran, we'll use the Browser Console. + +- [ ] **Step 4: Manual verification** + +Reload the extension. The editor will be empty (expected for now). In the Browser Console, run: + +```javascript +browser.storage.sync.get(null).then(console.log); +``` + +You should see an object containing: +- `tabIndex` — an array with one entry like `{ id: "t_xxxxx", name: "Scratchpad" }` (if you had legacy content) or `{ id: "t_xxxxx", name: "Tab 1" }` (if you didn't). +- `tab_t_xxxxx` — the legacy content string, or empty string for fresh installs. +- **No** `scratchpadContent` key (it should have been removed if migration ran with legacy content). + +Reload the page again. The console log should show `[Migration] already migrated, nothing to do`. + +- [ ] **Step 5: Commit** + +```bash +git add migration.js scratchpad.html scratchpad.js +git commit -m "feat: migrate legacy scratchpadContent to tabIndex schema" +``` + +--- + +## Task 4: Add tab bar markup and CSS (no functionality yet) + +**Files:** +- Modify: `scratchpad.html` +- Modify: `scratchpad.css` + +- [ ] **Step 1: Add tab bar markup** + +In `scratchpad.html`, between the `
` that closes `.header` and the `
` element, insert: + +```html +
+
+ + +
+``` + +- [ ] **Step 2: Add tab bar CSS** + +Append to `scratchpad.css`: + +```css +.tab-bar { + display: flex; + align-items: center; + background-color: #ececec; + border-bottom: 1px solid #ddd; + padding: 0 8px; + height: 36px; + flex-shrink: 0; +} + +.tab-list { + display: flex; + align-items: stretch; + flex: 1; + min-width: 0; + overflow: hidden; + height: 100%; +} + +.tab { + display: flex; + align-items: center; + gap: 6px; + padding: 0 12px; + height: 100%; + background-color: #dcdcdc; + border-right: 1px solid #ccc; + cursor: pointer; + user-select: none; + font-size: 13px; + color: #333; + max-width: 200px; + flex-shrink: 0; +} + +.tab:hover { background-color: #d0d0d0; } + +.tab.active { + background-color: #fff; + font-weight: 500; +} + +.tab-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tab-rename-input { + font: inherit; + border: 1px solid #4a90e2; + border-radius: 2px; + padding: 1px 4px; + width: 120px; + background: white; +} + +.tab-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + background: transparent; + color: #777; + cursor: pointer; + border-radius: 50%; + font-size: 14px; + line-height: 1; +} + +.tab-close:hover { background-color: #bbb; color: #000; } + +.tab.only-tab .tab-close { display: none; } + +.tab-add, .tab-overflow-btn { + background: transparent; + border: none; + font-size: 18px; + color: #555; + cursor: pointer; + padding: 4px 10px; + margin-left: 4px; + border-radius: 4px; +} + +.tab-add:hover, .tab-overflow-btn:hover { background-color: #c8c8c8; } + +.tab-overflow { position: relative; } + +.tab-overflow-menu { + position: absolute; + top: 100%; + right: 0; + background: white; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + z-index: 100; + min-width: 180px; + padding: 4px 0; + max-height: 300px; + overflow-y: auto; +} + +.tab-overflow-menu .menu-item { + display: block; + padding: 6px 12px; + cursor: pointer; + font-size: 13px; + white-space: nowrap; +} + +.tab-overflow-menu .menu-item:hover { background-color: #f0f0f0; } + +.tab-overflow-menu .menu-item.active { background-color: #e8f0fe; font-weight: 500; } + +/* Editor stack: each tab is its own contenteditable. Inactive ones are hidden. */ +.editor-stack { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.editor { + flex: 1; + width: 100%; + padding: 20px; + border: none; + outline: none; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.6; + overflow-y: auto; + display: none; +} + +.editor.active { display: block; } + +.editor:empty:before { + content: attr(data-placeholder); + color: #999; + pointer-events: none; +} + +.editor:focus:before { content: none; } + +/* Reapply formatting styles to .editor */ +.editor strong, .editor b { font-weight: bold; } +.editor em, .editor i { font-style: italic; } +.editor u { text-decoration: underline; } +.editor a { color: #4a90e2; text-decoration: underline; cursor: pointer; } +.editor a:hover { color: #357abd; text-decoration: none; } +.editor a:visited { color: #7b68c4; } +.editor ul, .editor ol { margin: 10px 0; padding-left: 30px; } +.editor li { margin: 5px 0; } + +/* Undo toast */ +.undo-toast { + position: fixed; + bottom: 16px; + right: 16px; + background: #333; + color: white; + padding: 10px 14px; + border-radius: 4px; + font-size: 13px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 2px 12px rgba(0,0,0,0.3); + z-index: 200; +} + +.undo-toast button { + background: transparent; + border: 1px solid #888; + color: white; + padding: 4px 10px; + border-radius: 3px; + cursor: pointer; + font-size: 12px; +} + +.undo-toast button:hover { border-color: #fff; } + +/* "Tab deleted on another device" banner */ +.tab-deleted-banner { + background: #fff3cd; + color: #856404; + border-bottom: 1px solid #f5c441; + padding: 8px 16px; + font-size: 13px; + display: flex; + align-items: center; + gap: 12px; +} + +.tab-deleted-banner button { + background: white; + color: #856404; + border: 1px solid #f5c441; + padding: 4px 10px; + border-radius: 3px; + cursor: pointer; + font-size: 12px; +} + +.tab-deleted-banner button:hover { background: #fff8e1; } + +.tab-deleted-banner .dismiss { + margin-left: auto; + background: transparent; + border: none; + font-size: 16px; + padding: 0 4px; +} +``` + +- [ ] **Step 3: Manual verification** + +Reload the extension. The page should now show an empty grey tab bar with a `+` button, but no actual tabs in it (since no module renders them yet). The editor below still works normally for now. + +Open DevTools → Inspector and confirm: +- `
` exists in the DOM. +- `
` is empty. +- `` is visible and focusable. +- Hovering the `+` shows the title tooltip "New tab (Ctrl+Alt+T)". + +- [ ] **Step 4: Commit** + +```bash +git add scratchpad.html scratchpad.css +git commit -m "feat: add tab bar markup and styles" +``` + +--- + +## Task 5: Replace single editor with tabs module (one tab from migration) + +**Files:** +- Create: `tabs.js` +- Modify: `scratchpad.html` (add script tag, remove the old `#scratchpad` div, add `.editor-stack` wrapper) +- Modify: `scratchpad.js` (replace single-editor wiring with Tabs module wiring) +- Modify: `scratchpad.css` (remove the old `#scratchpad` selector — keep only `.editor` rules added in Task 4) + +- [ ] **Step 1: Update HTML to use editor-stack instead of #scratchpad** + +In `scratchpad.html`, replace the line: + +```html +
+``` + +with: + +```html +
+``` + +Editors are created dynamically by `tabs.js`. + +- [ ] **Step 2: Remove obsolete CSS rules** + +In `scratchpad.css`, delete the rules that target `#scratchpad` (the original ones from before Task 4 — they live around lines 126–186 in the pre-Task-4 file). The `.editor` rules added in Task 4 cover their behavior on the new editor elements. Be careful **not** to delete the `.editor` rules from Task 4. + +- [ ] **Step 3: Create `tabs.js`** + +```javascript +// tabs.js — Tab map, CRUD, switching, undo, overflow detection. +// Exposes window.Scratchpad.Tabs. + +window.Scratchpad = window.Scratchpad || {}; + +const TABS_PLACEHOLDER = 'Start typing... Your notes will automatically sync across your Firefox devices. Use Ctrl+B for bold, Ctrl+I for italic, Ctrl+U for underline.'; +const SAVE_DEBOUNCE_MS = 500; +const UNDO_TIMEOUT_MS = 5000; + +const Tabs = { + // Map where TabState = { id, name, editor, lastSavedContent, contentChanged, saveTimeout } + tabs: new Map(), + tabOrder: [], // ordered list of tab ids + activeTabId: null, + recentlyClosed: null, // { meta: {id,name}, content, undoTimeoutId, removedFromIndex } | null + + // DOM refs (populated in init()) + tabListEl: null, + editorStackEl: null, + tabAddBtn: null, + tabOverflowEl: null, + tabOverflowBtn: null, + tabOverflowMenuEl: null, + + // Status callback supplied by scratchpad.js for "saving"/"saved"/"error" updates. + onStatus: null, // (status, message?) => void + onUpdate: null, // () => void called whenever last-update timestamp should refresh + + init({ tabListEl, editorStackEl, tabAddBtn, tabOverflowEl, tabOverflowBtn, tabOverflowMenuEl, onStatus, onUpdate }) { + Tabs.tabListEl = tabListEl; + Tabs.editorStackEl = editorStackEl; + Tabs.tabAddBtn = tabAddBtn; + Tabs.tabOverflowEl = tabOverflowEl; + Tabs.tabOverflowBtn = tabOverflowBtn; + Tabs.tabOverflowMenuEl = tabOverflowMenuEl; + Tabs.onStatus = onStatus || (() => {}); + Tabs.onUpdate = onUpdate || (() => {}); + + Tabs.tabAddBtn.addEventListener('click', () => Tabs.createTab()); + }, + + // Build initial tabs from a tabIndex array and a contentMap of {id → htmlString}. + // Activates the tab whose id matches `preferredActiveId` (or the first tab). + hydrate(tabIndex, contentMap, preferredActiveId) { + for (const meta of tabIndex) { + Tabs._addTabInternal(meta, contentMap[meta.id] || ''); + } + const activeId = (preferredActiveId && Tabs.tabs.has(preferredActiveId)) + ? preferredActiveId + : tabIndex[0]?.id; + if (activeId) Tabs.switchToTab(activeId); + Tabs._refreshOnlyTabClass(); + }, + + // Create the in-memory + DOM state for a tab and append it to the bar/stack. + // Does NOT update tabIndex storage. Returns the TabState. + _addTabInternal(meta, content) { + const editor = document.createElement('div'); + editor.className = 'editor'; + editor.contentEditable = 'true'; + editor.spellcheck = true; + editor.dataset.placeholder = TABS_PLACEHOLDER; + editor.dataset.tabId = meta.id; + Scratchpad.Editor.setContent(editor, content); + Scratchpad.Editor.attachShortcuts(editor, () => Tabs._markDirty(meta.id)); + Tabs.editorStackEl.appendChild(editor); + + const tabEl = document.createElement('div'); + tabEl.className = 'tab'; + tabEl.dataset.tabId = meta.id; + const labelEl = document.createElement('span'); + labelEl.className = 'tab-label'; + labelEl.textContent = meta.name; + const closeBtn = document.createElement('button'); + closeBtn.className = 'tab-close'; + closeBtn.title = 'Close tab'; + closeBtn.textContent = '×'; + tabEl.appendChild(labelEl); + tabEl.appendChild(closeBtn); + Tabs.tabListEl.appendChild(tabEl); + + tabEl.addEventListener('click', (e) => { + if (e.target === closeBtn) return; + if (e.detail >= 2 && e.target === labelEl) return; // dblclick handled below + Tabs.switchToTab(meta.id); + }); + labelEl.addEventListener('dblclick', () => Tabs._beginRename(meta.id)); + closeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + Tabs.closeTab(meta.id); + }); + + const state = { + id: meta.id, + name: meta.name, + editor, + tabEl, + labelEl, + lastSavedContent: content, + contentChanged: false, + saveTimeout: null, + }; + Tabs.tabs.set(meta.id, state); + Tabs.tabOrder.push(meta.id); + return state; + }, + + // Create a new tab via user action: writes tabIndex + tab_, switches to it. + async createTab() { + const id = Scratchpad.Migration.newTabId(); + const name = Tabs._nextTabName(); + Tabs._addTabInternal({ id, name }, ''); + try { + await Tabs._persistIndexAndContent(id, ''); + Tabs.switchToTab(id); + Tabs._refreshOnlyTabClass(); + Tabs.tabs.get(id).editor.focus(); + } catch (e) { + console.error('[Tabs] createTab failed; rolling back:', e); + Tabs._removeTabInternal(id); + Tabs.onStatus('error', 'Failed to create tab'); + } + }, + + // "Tab N" where N is the smallest positive integer not already used. + _nextTabName() { + const used = new Set(); + for (const s of Tabs.tabs.values()) { + const m = s.name.match(/^Tab (\d+)$/); + if (m) used.add(parseInt(m[1], 10)); + } + let n = 1; + while (used.has(n)) n++; + return 'Tab ' + n; + }, + + // Persist the entire tabIndex + a single tab's content in one storage write. + async _persistIndexAndContent(tabId, content) { + const tabIndex = Tabs._buildIndex(); + const update = { tabIndex }; + if (tabId !== null) update['tab_' + tabId] = content; + await Scratchpad.Storage.set(update); + }, + + _buildIndex() { + return Tabs.tabOrder.map(id => { + const s = Tabs.tabs.get(id); + return { id: s.id, name: s.name }; + }); + }, + + switchToTab(id) { + if (!Tabs.tabs.has(id)) return; + if (Tabs.activeTabId === id) return; + + // Flush any pending debounced save for the outgoing tab so a sync poll + // won't overwrite unsaved work between switch and the next debounce tick. + if (Tabs.activeTabId) { + const out = Tabs.tabs.get(Tabs.activeTabId); + if (out) { + if (out.saveTimeout) { clearTimeout(out.saveTimeout); out.saveTimeout = null; } + if (out.contentChanged) { Tabs._saveTab(out.id); } + out.tabEl.classList.remove('active'); + out.editor.classList.remove('active'); + } + } + + Tabs.activeTabId = id; + const next = Tabs.tabs.get(id); + next.tabEl.classList.add('active'); + next.editor.classList.add('active'); + + // Persist active tab id per-device. + Scratchpad.Storage.Local.set({ activeTabId: id }).catch(e => console.error('[Tabs] persist activeTabId failed:', e)); + + next.editor.focus(); + }, + + // Mark a tab as dirty and schedule a debounced save. + _markDirty(id) { + const s = Tabs.tabs.get(id); + if (!s) return; + s.contentChanged = true; + if (s.saveTimeout) clearTimeout(s.saveTimeout); + s.saveTimeout = setTimeout(() => Tabs._saveTab(id), SAVE_DEBOUNCE_MS); + }, + + async _saveTab(id) { + const s = Tabs.tabs.get(id); + if (!s) return; + s.saveTimeout = null; + const current = Scratchpad.Editor.getContent(s.editor); + if (current === s.lastSavedContent) { + console.log(`[Tabs] ${id}: unchanged, skipping save`); + return; + } + Tabs.onStatus('saving'); + try { + await Scratchpad.Storage.set({ ['tab_' + id]: current }); + s.lastSavedContent = current; + s.contentChanged = false; + Tabs.onStatus('saved'); + Tabs.onUpdate(); + console.log(`[Tabs] ${id}: saved ${current.length} chars`); + } catch (e) { + console.error(`[Tabs] save failed for ${id}:`, e); + Tabs.onStatus('error', `Save failed for "${s.name}"`); + } + }, + + // Auto-save tick: persist any dirty tabs. + flushDirty() { + for (const s of Tabs.tabs.values()) { + if (s.contentChanged) Tabs._saveTab(s.id); + } + }, + + // Remove tab DOM + state but do NOT touch storage. Used for rollback and remote-removal. + _removeTabInternal(id) { + const s = Tabs.tabs.get(id); + if (!s) return; + s.tabEl.remove(); + s.editor.remove(); + Tabs.tabs.delete(id); + Tabs.tabOrder = Tabs.tabOrder.filter(x => x !== id); + }, + + _refreshOnlyTabClass() { + const onlyOne = Tabs.tabs.size === 1; + for (const s of Tabs.tabs.values()) { + s.tabEl.classList.toggle('only-tab', onlyOne); + } + }, + + // Stubs filled in by later tasks. + closeTab(id) { console.warn('[Tabs] closeTab not yet implemented'); }, + _beginRename(id) { console.warn('[Tabs] rename not yet implemented'); }, +}; + +window.Scratchpad.Tabs = Tabs; +``` + +- [ ] **Step 4: Add the script tag in `scratchpad.html`** + +After the ` +``` + +- [ ] **Step 5: Rewrite `scratchpad.js` to use Tabs** + +Replace `scratchpad.js` entirely with: + +```javascript +// scratchpad.js — Wiring & lifecycle. + +const statusDiv = document.getElementById('status'); +const lastUpdateDiv = document.getElementById('lastUpdate'); +const refreshBtn = document.getElementById('refreshBtn'); +const linkBtn = document.getElementById('linkBtn'); +const toolbarBtns = document.querySelectorAll('.toolbar-btn'); + +const tabListEl = document.getElementById('tabList'); +const editorStackEl = document.getElementById('editorStack'); +const tabAddBtn = document.getElementById('tabAdd'); +const tabOverflowEl = document.getElementById('tabOverflow'); +const tabOverflowBtn = document.getElementById('tabOverflowBtn'); +const tabOverflowMenuEl = document.getElementById('tabOverflowMenu'); + +const AUTO_SAVE_INTERVAL = 5000; + +let autoSaveInterval = null; + +function updateLastUpdateTime() { + const now = new Date(); + const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + lastUpdateDiv.textContent = `Updated: ${timeStr}`; +} + +function updateStatus(status, message = '') { + statusDiv.className = 'status'; + switch (status) { + case 'saving': + statusDiv.textContent = 'Saving...'; + statusDiv.classList.add('saving'); + break; + case 'saved': + const storageType = Scratchpad.Storage.usingSync ? 'Synced' : 'Saved'; + statusDiv.textContent = storageType; + statusDiv.classList.add('saved'); + setTimeout(() => { + if (statusDiv.textContent === storageType) updateStatus('ready'); + }, 2000); + break; + case 'loaded': + case 'ready': + statusDiv.textContent = Scratchpad.Storage.usingSync ? 'Ready (Synced)' : 'Ready (Local)'; + break; + case 'error': + statusDiv.textContent = message ? `Error: ${message}` : 'Error'; + statusDiv.style.backgroundColor = '#f8d7da'; + statusDiv.style.color = '#721c24'; + break; + } +} + +// Toolbar buttons act on the active editor. +function activeEditor() { + const id = Scratchpad.Tabs.activeTabId; + return id ? Scratchpad.Tabs.tabs.get(id)?.editor : null; +} + +toolbarBtns.forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const ed = activeEditor(); + if (!ed) return; + const command = btn.dataset.command; + if (command) { + Scratchpad.Editor.executeCommand(ed, command); + Scratchpad.Tabs._markDirty(Scratchpad.Tabs.activeTabId); + } + }); +}); + +linkBtn.addEventListener('click', (e) => { + e.preventDefault(); + const ed = activeEditor(); + if (!ed) return; + if (Scratchpad.Editor.createLink(ed)) { + Scratchpad.Tabs._markDirty(Scratchpad.Tabs.activeTabId); + } +}); + +refreshBtn.addEventListener('click', async () => { + refreshBtn.disabled = true; + refreshBtn.textContent = '⟳ Refreshing...'; + // Sync.pollOnce will be added in Task 9; for now we just no-op. + if (window.Scratchpad.Sync && Scratchpad.Sync.pollOnce) { + await Scratchpad.Sync.pollOnce(); + } + setTimeout(() => { + refreshBtn.disabled = false; + refreshBtn.textContent = '↻ Refresh'; + }, 500); +}); + +window.addEventListener('beforeunload', () => { + if (autoSaveInterval) clearInterval(autoSaveInterval); + // Final flush of any dirty tabs. + Scratchpad.Tabs.flushDirty(); +}); + +(async () => { + try { + await Scratchpad.Storage.init(); + await Scratchpad.Migration.runIfNeeded(); + + Scratchpad.Tabs.init({ + tabListEl, editorStackEl, tabAddBtn, + tabOverflowEl, tabOverflowBtn, tabOverflowMenuEl, + onStatus: updateStatus, + onUpdate: updateLastUpdateTime, + }); + + const result = await Scratchpad.Storage.get('tabIndex'); + const tabIndex = Array.isArray(result.tabIndex) ? result.tabIndex : []; + const contentKeys = tabIndex.map(t => 'tab_' + t.id); + const contents = contentKeys.length > 0 ? await Scratchpad.Storage.get(contentKeys) : {}; + const contentMap = {}; + for (const t of tabIndex) contentMap[t.id] = contents['tab_' + t.id] || ''; + + const local = await Scratchpad.Storage.Local.get('activeTabId'); + Scratchpad.Tabs.hydrate(tabIndex, contentMap, local.activeTabId); + + updateStatus('loaded'); + updateLastUpdateTime(); + } catch (e) { + console.error('[Lifecycle] startup failed:', e); + updateStatus('error', e.message); + } + + autoSaveInterval = setInterval(() => Scratchpad.Tabs.flushDirty(), AUTO_SAVE_INTERVAL); +})(); +``` + +- [ ] **Step 6: Manual verification** + +Reload the extension. Verify: +- A single tab labeled "Scratchpad" (or "Tab 1" if you had no legacy content) appears in the tab bar with a white background (active state). +- The tab has no `×` button visible (only-tab class hides it). +- Type into the editor — content saves (status flips to "Synced"/"Saved"). Reload the page — content persists. +- Click the `+` button — a new tab labeled "Tab 1" (or "Tab 2", etc., depending on existing names) appears, becomes active, and the editor is empty. +- Click the first tab — the editor switches back to its content. Click the second — switches to its (empty) content. Cursor and any in-progress text are preserved per-tab (try typing in tab A, switch to tab B, type something, switch back to tab A — your tab A text is intact). +- Both tabs show `×` close buttons now (more than one tab). +- In the Browser Console, `await browser.storage.sync.get(null)` shows `tabIndex` with both tabs and a `tab_` entry for each. +- Reload the page — both tabs reappear; the active tab is the one you had active before reload (per-device persistence working). + +- [ ] **Step 7: Commit** + +```bash +git add tabs.js scratchpad.html scratchpad.js scratchpad.css +git commit -m "feat: introduce Tabs module with per-tab editors and basic switching" +``` + +--- + +## Task 6: Implement closeTab + undo toast (Ctrl+Alt+W still pending) + +**Files:** +- Modify: `tabs.js` + +- [ ] **Step 1: Implement `closeTab` and the undo toast** + +In `tabs.js`, replace the `closeTab` stub with: + +```javascript +async closeTab(id) { + if (Tabs.tabs.size <= 1) { + console.log('[Tabs] refusing to close last tab'); + return; + } + const s = Tabs.tabs.get(id); + if (!s) return; + + const removedIndex = Tabs.tabOrder.indexOf(id); + const meta = { id: s.id, name: s.name }; + const content = Scratchpad.Editor.getContent(s.editor); + + // If we're closing the active tab, switch to a neighbor first. + if (Tabs.activeTabId === id) { + const next = Tabs.tabOrder[removedIndex - 1] || Tabs.tabOrder[removedIndex + 1]; + if (next) Tabs.switchToTab(next); + } + + Tabs._removeTabInternal(id); + Tabs._refreshOnlyTabClass(); + + try { + await Scratchpad.Storage.set({ tabIndex: Tabs._buildIndex() }); + await Scratchpad.Storage.remove('tab_' + id); + } catch (e) { + console.error('[Tabs] closeTab persist failed; restoring tab:', e); + Tabs._addTabInternal(meta, content); + // Restore order: re-insert at removedIndex. + Tabs.tabOrder = Tabs.tabOrder.filter(x => x !== id); + Tabs.tabOrder.splice(removedIndex, 0, id); + Tabs._reorderDom(); + Tabs._refreshOnlyTabClass(); + Tabs.onStatus('error', 'Close failed'); + return; + } + + Tabs._showUndoToast(meta, content, removedIndex); +}, + +_showUndoToast(meta, content, removedIndex) { + // Clear any existing toast first. + if (Tabs.recentlyClosed) { + clearTimeout(Tabs.recentlyClosed.undoTimeoutId); + document.getElementById('undoToast')?.remove(); + } + + const toast = document.createElement('div'); + toast.id = 'undoToast'; + toast.className = 'undo-toast'; + const msg = document.createElement('span'); + msg.textContent = `Tab "${meta.name}" closed`; + const undoBtn = document.createElement('button'); + undoBtn.textContent = 'Undo'; + toast.appendChild(msg); + toast.appendChild(undoBtn); + document.body.appendChild(toast); + + const timeoutId = setTimeout(() => { + toast.remove(); + Tabs.recentlyClosed = null; + }, UNDO_TIMEOUT_MS); + + undoBtn.addEventListener('click', async () => { + clearTimeout(timeoutId); + toast.remove(); + Tabs.recentlyClosed = null; + await Tabs._restoreClosedTab(meta, content, removedIndex); + }); + + Tabs.recentlyClosed = { meta, content, undoTimeoutId: timeoutId, removedIndex }; +}, + +async _restoreClosedTab(meta, content, removedIndex) { + const state = Tabs._addTabInternal(meta, content); + // Re-insert at original index. + Tabs.tabOrder = Tabs.tabOrder.filter(x => x !== meta.id); + Tabs.tabOrder.splice(removedIndex, 0, meta.id); + Tabs._reorderDom(); + Tabs._refreshOnlyTabClass(); + try { + await Tabs._persistIndexAndContent(meta.id, content); + Tabs.switchToTab(meta.id); + } catch (e) { + console.error('[Tabs] restore failed; removing again:', e); + Tabs._removeTabInternal(meta.id); + Tabs._refreshOnlyTabClass(); + Tabs.onStatus('error', 'Restore failed'); + } +}, + +// Reorder both the tab bar children and the editor stack to match Tabs.tabOrder. +_reorderDom() { + for (const id of Tabs.tabOrder) { + const s = Tabs.tabs.get(id); + if (!s) continue; + Tabs.tabListEl.appendChild(s.tabEl); + Tabs.editorStackEl.appendChild(s.editor); + } +}, +``` + +- [ ] **Step 2: Manual verification** + +Reload the extension. Verify: +- With two or more tabs, click `×` on a non-active tab — it disappears, a toast at the bottom-right shows `Tab "" closed Undo`. +- Click `×` on the active tab — focus moves to a neighbor, the closed tab disappears, toast appears. +- Click the toast's "Undo" button within 5s — the tab returns at its original position with its content. Active tab becomes the restored one. +- Close another tab and wait 5s — toast disappears; re-open the page (F5) and the closed tab does not return. +- With only one tab open, the `×` is hidden — confirmed by previous tasks. +- Use the Browser Console: `await browser.storage.sync.get(null)` after closing a tab — its `tab_` key is gone, `tabIndex` no longer includes it. + +- [ ] **Step 3: Commit** + +```bash +git add tabs.js +git commit -m "feat: close tab with undo toast" +``` + +--- + +## Task 7: Implement renameTab (double-click) + +**Files:** +- Modify: `tabs.js` + +- [ ] **Step 1: Implement `_beginRename` and `_commitRename`** + +In `tabs.js`, replace the `_beginRename` stub: + +```javascript +_beginRename(id) { + const s = Tabs.tabs.get(id); + if (!s) return; + if (s.labelEl.dataset.renaming === '1') return; + + const oldName = s.name; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'tab-rename-input'; + input.value = oldName; + s.labelEl.dataset.renaming = '1'; + s.labelEl.replaceChildren(input); + input.select(); + + let committed = false; + const finish = async (commit) => { + if (committed) return; + committed = true; + s.labelEl.dataset.renaming = '0'; + if (commit) { + const newName = input.value.trim(); + if (newName && newName !== oldName) { + s.name = newName; + s.labelEl.textContent = newName; + try { + await Scratchpad.Storage.set({ tabIndex: Tabs._buildIndex() }); + } catch (e) { + console.error('[Tabs] rename persist failed; reverting:', e); + s.name = oldName; + s.labelEl.textContent = oldName; + Tabs.onStatus('error', 'Rename failed'); + } + } else { + s.labelEl.textContent = oldName; + } + } else { + s.labelEl.textContent = oldName; + } + }; + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); finish(true); } + else if (e.key === 'Escape') { e.preventDefault(); finish(false); } + }); + input.addEventListener('blur', () => finish(true)); +}, +``` + +- [ ] **Step 2: Manual verification** + +Reload the extension. Verify: +- Double-click a tab label — it becomes a text input pre-filled with the current name and the text is selected. +- Type a new name and press Enter — the label updates, the input disappears. +- Double-click again, edit the name, click outside the tab — the new name commits (blur). +- Double-click, edit, press Esc — the original name is restored. +- After renaming, reload the page — the new name persists. +- Browser Console: `await browser.storage.sync.get('tabIndex')` shows the renamed entry. + +- [ ] **Step 3: Commit** + +```bash +git add tabs.js +git commit -m "feat: rename tab with double-click" +``` + +--- + +## Task 8: Tab navigation keyboard shortcuts + +**Files:** +- Modify: `tabs.js` + +- [ ] **Step 1: Add shortcut bindings** + +Append a new method to `Tabs` and call it from `init()`. After the existing `Tabs.tabAddBtn.addEventListener` line in `init`, add: + +```javascript +Tabs._installShortcuts(); +``` + +And add the method: + +```javascript +_installShortcuts() { + document.addEventListener('keydown', (e) => { + // Ctrl+Alt+T → new tab + if (e.ctrlKey && e.altKey && (e.key === 't' || e.key === 'T')) { + e.preventDefault(); + Tabs.createTab(); + return; + } + // Ctrl+Alt+W → close active tab + if (e.ctrlKey && e.altKey && (e.key === 'w' || e.key === 'W')) { + e.preventDefault(); + if (Tabs.activeTabId) Tabs.closeTab(Tabs.activeTabId); + return; + } + // Ctrl+Tab / Ctrl+Shift+Tab → cycle tabs + if (e.ctrlKey && !e.altKey && e.key === 'Tab') { + e.preventDefault(); + if (Tabs.tabOrder.length === 0) return; + const idx = Tabs.tabOrder.indexOf(Tabs.activeTabId); + const len = Tabs.tabOrder.length; + const next = e.shiftKey + ? Tabs.tabOrder[(idx - 1 + len) % len] + : Tabs.tabOrder[(idx + 1) % len]; + Tabs.switchToTab(next); + return; + } + }); +}, +``` + +- [ ] **Step 2: Manual verification** + +Reload the extension. Verify: +- Press Ctrl+Alt+T — a new tab is created and focused. +- Type into it. Press Ctrl+Alt+W — the tab closes; toast appears; pressing the toast's Undo restores it. +- With multiple tabs, press Ctrl+Tab — focus moves to the next tab (wraps from last → first). +- Press Ctrl+Shift+Tab — focus moves backward (wraps from first → last). +- The existing per-editor shortcuts (Ctrl+B/I/U/K) still work in the active tab. +- **Note on Firefox interception:** Ctrl+Tab may be intercepted by Firefox depending on its `browser.ctrlTab.sortByRecentlyUsed` and tab-cycling settings. If your Ctrl+Tab opens Firefox's tab switcher instead, that's the documented limitation in the spec — the in-page handler still fires for users where Firefox doesn't intercept. + +- [ ] **Step 3: Commit** + +```bash +git add tabs.js +git commit -m "feat: tab navigation keyboard shortcuts" +``` + +--- + +## Task 9: Cross-device sync — content updates (per-tab dirty guard) + +**Files:** +- Create: `sync.js` +- Modify: `scratchpad.html` (add script tag) +- Modify: `scratchpad.js` (start sync polling and install onChanged listener) +- Modify: `tabs.js` (expose helper for applying remote content) + +- [ ] **Step 1: Add a helper to `tabs.js` for applying remote content** + +Insert these methods on `Tabs`: + +```javascript +// Apply a remote content update for an existing tab. Skips if the tab is +// currently dirty (user is typing). Returns true if applied. +applyRemoteContent(id, newHtml) { + const s = Tabs.tabs.get(id); + if (!s) return false; + if (s.contentChanged) { + console.log(`[Tabs] ${id}: deferring remote update (user is typing)`); + return false; + } + const current = Scratchpad.Editor.getContent(s.editor); + if (current === newHtml) return false; + Scratchpad.Editor.setContent(s.editor, newHtml); + s.lastSavedContent = newHtml; + console.log(`[Tabs] ${id}: applied remote update (${newHtml.length} chars)`); + return true; +}, + +hasTab(id) { return Tabs.tabs.has(id); }, +``` + +- [ ] **Step 2: Create `sync.js`** + +```javascript +// sync.js — Cross-device sync: onChanged listener + periodic poll. +// Exposes window.Scratchpad.Sync. + +window.Scratchpad = window.Scratchpad || {}; + +const SYNC_POLL_MS = 10000; + +const Sync = { + pollIntervalId: null, + onUpdate: null, // () => void called whenever a remote update is applied + + install({ onUpdate }) { + Sync.onUpdate = onUpdate || (() => {}); + Scratchpad.Storage.onChanged((changes) => Sync._handleChanges(changes)); + }, + + startPolling() { + if (Sync.pollIntervalId) return; + Sync.pollIntervalId = setInterval(() => Sync.pollOnce(), SYNC_POLL_MS); + }, + + stopPolling() { + if (Sync.pollIntervalId) { + clearInterval(Sync.pollIntervalId); + Sync.pollIntervalId = null; + } + }, + + _handleChanges(changes) { + let applied = false; + for (const [key, change] of Object.entries(changes)) { + if (key.startsWith('tab_')) { + const id = key.slice(4); + const newValue = change.newValue; + if (typeof newValue === 'string' && Scratchpad.Tabs.hasTab(id)) { + if (Scratchpad.Tabs.applyRemoteContent(id, newValue)) applied = true; + } + } + // tabIndex changes handled in Task 10. + } + if (applied) Sync.onUpdate(); + }, + + // Read all relevant keys and apply any updates we missed. + async pollOnce() { + try { + const result = await Scratchpad.Storage.get('tabIndex'); + const tabIndex = Array.isArray(result.tabIndex) ? result.tabIndex : []; + const ids = tabIndex.map(t => t.id); + const contentKeys = ids.map(id => 'tab_' + id); + const contents = contentKeys.length ? await Scratchpad.Storage.get(contentKeys) : {}; + + let applied = false; + for (const id of ids) { + if (!Scratchpad.Tabs.hasTab(id)) continue; + const v = contents['tab_' + id]; + if (typeof v === 'string' && Scratchpad.Tabs.applyRemoteContent(id, v)) applied = true; + } + if (applied) Sync.onUpdate(); + // tabIndex diff applied in Task 10. + } catch (e) { + console.error('[Sync] poll failed:', e); + } + }, +}; + +window.Scratchpad.Sync = Sync; +``` + +- [ ] **Step 3: Add the script tag in `scratchpad.html`** + +After the ` +``` + +- [ ] **Step 4: Wire sync from `scratchpad.js`** + +At the end of the IIFE in `scratchpad.js`, after the `autoSaveInterval = setInterval(...)` line, add: + +```javascript +Scratchpad.Sync.install({ onUpdate: updateLastUpdateTime }); +Scratchpad.Sync.startPolling(); +``` + +- [ ] **Step 5: Manual verification (single device)** + +Reload the extension. Verify: +- Open the scratchpad. Open Browser Console. Run: + + ```javascript + // Simulate a remote update for an existing tab. + const idx = await browser.storage.sync.get('tabIndex'); + const id = idx.tabIndex[0].id; + await browser.storage.sync.set({ ['tab_' + id]: '

Remote update test

' }); + ``` + + The active tab's editor should now show "Remote update test" — applied via `onChanged`. +- Click the Refresh button — `[Sync] poll once` log appears, no errors. +- Type into a tab, then run the same console snippet — the update should NOT apply (deferred because user is typing). Check the console for `deferring remote update`. +- Stop typing for 1s, click Refresh — the deferred update now applies (since `contentChanged` was reset by the auto-save). + +- [ ] **Step 6: Manual verification (two profiles, optional but recommended)** + +If you have two Firefox profiles signed into the same account: +- Load the extension on both. +- Type in tab T on profile A. Within 5–30s, profile B's tab T updates. +- Type in T on both simultaneously — last writer wins, no crash. + +- [ ] **Step 7: Commit** + +```bash +git add sync.js scratchpad.html scratchpad.js tabs.js +git commit -m "feat: cross-device sync for tab content with per-tab dirty guard" +``` + +--- + +## Task 10: Cross-device sync — tab list diff (add/remove/rename) + +**Files:** +- Modify: `sync.js` +- Modify: `tabs.js` (helpers for applying remote tab list changes) + +- [ ] **Step 1: Add helpers to `tabs.js`** + +Insert on `Tabs`: + +```javascript +// Apply a remote tabIndex change. `newIndex` is the array from storage. +// Returns { added: [...metas], removed: [...ids], renamed: [{id, oldName, newName}] }. +applyRemoteIndex(newIndex, contentMap) { + if (!Array.isArray(newIndex)) return { added: [], removed: [], renamed: [] }; + + const remoteIds = new Set(newIndex.map(t => t.id)); + const localIds = new Set(Tabs.tabOrder); + + const added = newIndex.filter(t => !localIds.has(t.id)); + const removed = Tabs.tabOrder.filter(id => !remoteIds.has(id)); + const renamed = []; + for (const meta of newIndex) { + const local = Tabs.tabs.get(meta.id); + if (local && local.name !== meta.name) { + renamed.push({ id: meta.id, oldName: local.name, newName: meta.name }); + } + } + + // Apply renames first. + for (const r of renamed) { + const s = Tabs.tabs.get(r.id); + if (!s) continue; + if (s.labelEl.dataset.renaming === '1') continue; // user editing — skip + s.name = r.newName; + s.labelEl.textContent = r.newName; + } + + // Apply additions. + for (const meta of added) { + Tabs._addTabInternal(meta, contentMap[meta.id] || ''); + } + + // Apply removals — handler in caller decides what to do with active-tab removal. + // Here we only remove non-active tabs; the caller will handle the active one. + const activeRemoved = removed.includes(Tabs.activeTabId); + for (const id of removed) { + if (id === Tabs.activeTabId) continue; + Tabs._removeTabInternal(id); + } + + // Reorder DOM to match the new index (skipping any locally still-present + // active tab that was remotely removed). + Tabs.tabOrder = Tabs.tabOrder.filter(id => Tabs.tabs.has(id)); + // Place tabs in newIndex order (active-removed tab will be appended at end if still present). + const ordered = []; + for (const meta of newIndex) { + if (Tabs.tabs.has(meta.id)) ordered.push(meta.id); + } + if (activeRemoved && Tabs.tabs.has(Tabs.activeTabId) && !ordered.includes(Tabs.activeTabId)) { + ordered.push(Tabs.activeTabId); + } + Tabs.tabOrder = ordered; + Tabs._reorderDom(); + Tabs._refreshOnlyTabClass(); + + return { added, removed, renamed, activeRemoved }; +}, + +// Show a banner for an active tab that was deleted on another device. +showTabDeletedBanner(tabId, tabName, content) { + const existing = document.getElementById('tabDeletedBanner'); + if (existing) existing.remove(); + + const banner = document.createElement('div'); + banner.id = 'tabDeletedBanner'; + banner.className = 'tab-deleted-banner'; + + const msg = document.createElement('span'); + msg.textContent = `Tab "${tabName}" was deleted on another device.`; + + const saveBtn = document.createElement('button'); + saveBtn.textContent = 'Save as new tab'; + + const dismissBtn = document.createElement('button'); + dismissBtn.className = 'dismiss'; + dismissBtn.textContent = '×'; + dismissBtn.title = 'Dismiss'; + + banner.appendChild(msg); + banner.appendChild(saveBtn); + banner.appendChild(dismissBtn); + + // Insert before tab-bar. + const tabBar = document.getElementById('tabBar'); + tabBar.parentNode.insertBefore(banner, tabBar); + + saveBtn.addEventListener('click', async () => { + banner.remove(); + const newId = Scratchpad.Migration.newTabId(); + Tabs._addTabInternal({ id: newId, name: tabName }, content); + try { + await Tabs._persistIndexAndContent(newId, content); + Tabs.switchToTab(newId); + Tabs._refreshOnlyTabClass(); + } catch (e) { + console.error('[Tabs] save-as-new-tab failed:', e); + Tabs._removeTabInternal(newId); + Tabs.onStatus('error', 'Save as new tab failed'); + } + Tabs._removeTabInternal(tabId); + Tabs._refreshOnlyTabClass(); + }); + + dismissBtn.addEventListener('click', () => { + banner.remove(); + Tabs._removeTabInternal(tabId); + Tabs._refreshOnlyTabClass(); + }); +}, +``` + +- [ ] **Step 2: Update `Sync._handleChanges` and `pollOnce` to handle `tabIndex`** + +In `sync.js`, replace `_handleChanges`: + +```javascript +_handleChanges(changes) { + let applied = false; + + // Process per-tab content first. + for (const [key, change] of Object.entries(changes)) { + if (key.startsWith('tab_')) { + const id = key.slice(4); + const newValue = change.newValue; + if (typeof newValue === 'string' && Scratchpad.Tabs.hasTab(id)) { + if (Scratchpad.Tabs.applyRemoteContent(id, newValue)) applied = true; + } + } + } + + // Then process tabIndex. + if (changes.tabIndex) { + Sync._applyIndex(changes.tabIndex.newValue, changes); + applied = true; + } + + if (applied) Sync.onUpdate(); +}, + +_applyIndex(newIndex, contentChanges) { + const Tabs = Scratchpad.Tabs; + // Build a contentMap from the change payload for any added tabs whose content + // is in this same change event. + const contentMap = {}; + if (contentChanges) { + for (const [key, c] of Object.entries(contentChanges)) { + if (key.startsWith('tab_')) { + const id = key.slice(4); + if (typeof c.newValue === 'string') contentMap[id] = c.newValue; + } + } + } + + // Capture state about the active tab before applying changes (we may need it for the banner). + const activeId = Tabs.activeTabId; + const activeState = activeId ? Tabs.tabs.get(activeId) : null; + const activeName = activeState?.name; + const activeContent = activeState ? Scratchpad.Editor.getContent(activeState.editor) : ''; + + // Apply additions/removals/renames (the function leaves a removed-active tab in DOM for now). + const result = Tabs.applyRemoteIndex(newIndex, contentMap); + + // For added tabs whose content wasn't in the change payload, fetch from storage. + const missingContent = result.added.filter(meta => !(meta.id in contentMap)); + if (missingContent.length) { + const keys = missingContent.map(m => 'tab_' + m.id); + Scratchpad.Storage.get(keys).then(res => { + for (const meta of missingContent) { + const v = res['tab_' + meta.id]; + if (typeof v === 'string') Scratchpad.Tabs.applyRemoteContent(meta.id, v); + } + }).catch(e => console.error('[Sync] backfill content failed:', e)); + } + + // If the active tab was removed remotely, show the banner. + if (result.activeRemoved && activeId) { + // Switch to a neighboring tab first. + const neighbor = newIndex[0]?.id; + if (neighbor) Tabs.switchToTab(neighbor); + Tabs.showTabDeletedBanner(activeId, activeName, activeContent); + } +}, +``` + +Replace the existing `pollOnce`: + +```javascript +async pollOnce() { + try { + const result = await Scratchpad.Storage.get('tabIndex'); + const tabIndex = Array.isArray(result.tabIndex) ? result.tabIndex : []; + const ids = tabIndex.map(t => t.id); + + // First, apply tabIndex if it differs from local order. + const localIndex = Scratchpad.Tabs.tabOrder.map(id => { + const s = Scratchpad.Tabs.tabs.get(id); + return { id: s.id, name: s.name }; + }); + const indexChanged = JSON.stringify(localIndex) !== JSON.stringify(tabIndex); + if (indexChanged) Sync._applyIndex(tabIndex, null); + + // Then, refresh content for all known tabs. + const contentKeys = ids.map(id => 'tab_' + id); + const contents = contentKeys.length ? await Scratchpad.Storage.get(contentKeys) : {}; + let applied = false; + for (const id of ids) { + if (!Scratchpad.Tabs.hasTab(id)) continue; + const v = contents['tab_' + id]; + if (typeof v === 'string' && Scratchpad.Tabs.applyRemoteContent(id, v)) applied = true; + } + if (applied || indexChanged) Sync.onUpdate(); + } catch (e) { + console.error('[Sync] poll failed:', e); + } +}, +``` + +- [ ] **Step 3: Manual verification (single device, simulated remote)** + +Reload the extension. In the Browser Console: + +```javascript +// Simulate a remote tab addition. +const idx = await browser.storage.sync.get('tabIndex'); +const newId = 't_' + Math.random().toString(36).slice(2, 12); +const newIdx = [...idx.tabIndex, { id: newId, name: 'Remote Tab' }]; +await browser.storage.sync.set({ tabIndex: newIdx, ['tab_' + newId]: '

created remotely

' }); +``` + +The new "Remote Tab" should appear in the tab bar. Click it — it shows "created remotely". + +```javascript +// Simulate a remote rename of the first tab. +const idx2 = await browser.storage.sync.get('tabIndex'); +idx2.tabIndex[0].name = 'Renamed Remotely'; +await browser.storage.sync.set({ tabIndex: idx2.tabIndex }); +``` + +The first tab's label updates to "Renamed Remotely". + +```javascript +// Simulate remote deletion of the active tab. (Make sure "Remote Tab" is the active one first.) +const idx3 = await browser.storage.sync.get('tabIndex'); +const activeId = idx3.tabIndex.find(t => t.name === 'Remote Tab').id; +const filtered = idx3.tabIndex.filter(t => t.id !== activeId); +await browser.storage.sync.set({ tabIndex: filtered }); +await browser.storage.sync.remove('tab_' + activeId); +``` + +The active tab should switch to a neighbor, and a yellow banner should appear: `Tab "Remote Tab" was deleted on another device. [Save as new tab] [×]`. + +- Click "Save as new tab" — a new tab named "Remote Tab" is created with the content; the banner clears. +- Or close the banner with "×" — the deleted editor is gone, content lost. + +- [ ] **Step 4: Manual verification (two profiles, optional)** + +With profile A and profile B both signed in, create/rename/close tabs on A; B reflects within ~30s. + +- [ ] **Step 5: Commit** + +```bash +git add sync.js tabs.js +git commit -m "feat: cross-device sync for tab list with deletion banner" +``` + +--- + +## Task 11: Orphan recovery (concurrent tab creation) + +**Files:** +- Modify: `sync.js` + +- [ ] **Step 1: Add orphan recovery to `_applyIndex` and `pollOnce`** + +In `sync.js`, add a helper: + +```javascript +// Scan all sync storage for tab_ keys not present in `tabIndex`. If any +// are found, treat them as orphans: append metadata and write back. +async _recoverOrphans(tabIndex) { + const all = await Scratchpad.Storage.getAll(); + const knownIds = new Set(tabIndex.map(t => t.id)); + const orphanKeys = Object.keys(all).filter(k => k.startsWith('tab_') && !knownIds.has(k.slice(4))); + if (orphanKeys.length === 0) return tabIndex; + + console.warn(`[Sync] recovering ${orphanKeys.length} orphaned tab(s)`); + // Compute next "Tab N" name(s) starting from the largest used number. + const used = new Set(); + for (const t of tabIndex) { + const m = t.name.match(/^Tab (\d+)$/); + if (m) used.add(parseInt(m[1], 10)); + } + let n = 1; + const rescued = orphanKeys.map(k => { + while (used.has(n)) n++; + used.add(n); + return { id: k.slice(4), name: 'Tab ' + (n++) }; + }); + const merged = [...tabIndex, ...rescued]; + await Scratchpad.Storage.set({ tabIndex: merged }); + return merged; +}, +``` + +Call it from `_applyIndex` (right before invoking `Tabs.applyRemoteIndex`): + +```javascript +// Orphan recovery: if storage has tab_ keys not in the new index, append them. +const merged = await Sync._recoverOrphans(newIndex); +const result = Tabs.applyRemoteIndex(merged, contentMap); +``` + +(Note: this changes `_applyIndex` to be async. Mark it `async` and `await Sync._applyIndex(...)` everywhere it's called — both inside `_handleChanges` and `pollOnce`.) + +Also call orphan recovery once at startup. In `scratchpad.js`, after the IIFE's `Scratchpad.Tabs.hydrate(...)` line: + +```javascript +// One-shot orphan recovery on startup (cheap; helps if a previous session crashed mid-create). +const tabIndexNow = (await Scratchpad.Storage.get('tabIndex')).tabIndex || []; +const merged = await Scratchpad.Sync._recoverOrphans(tabIndexNow); +if (merged.length !== tabIndexNow.length) { + // Re-hydrate any added orphans. + const newIds = merged.map(m => m.id).filter(id => !Scratchpad.Tabs.hasTab(id)); + if (newIds.length) { + const keys = newIds.map(id => 'tab_' + id); + const contents = await Scratchpad.Storage.get(keys); + for (const meta of merged) { + if (!Scratchpad.Tabs.hasTab(meta.id)) { + Scratchpad.Tabs._addTabInternal(meta, contents['tab_' + meta.id] || ''); + } + } + Scratchpad.Tabs._reorderDom(); + Scratchpad.Tabs._refreshOnlyTabClass(); + } +} +``` + +- [ ] **Step 2: Manual verification** + +In the Browser Console (with the extension loaded): + +```javascript +// Simulate an orphan: write a tab_ without adding it to tabIndex. +const orphanId = 't_orphan' + Math.random().toString(36).slice(2, 6); +await browser.storage.sync.set({ ['tab_' + orphanId]: '

orphaned content

' }); +``` + +Reload the page. Verify: +- A new tab appears in the bar with a "Tab N" name (where N is the next free integer). +- Clicking it shows "orphaned content". +- `await browser.storage.sync.get('tabIndex')` now includes the recovered tab. +- The console shows `[Sync] recovering 1 orphaned tab(s)`. + +- [ ] **Step 3: Commit** + +```bash +git add sync.js scratchpad.js +git commit -m "feat: orphan recovery for concurrent tab creation" +``` + +--- + +## Task 12: Tab overflow detection and `…` menu + +**Files:** +- Modify: `tabs.js` + +- [ ] **Step 1: Implement overflow detection and dropdown** + +Add to `Tabs`: + +```javascript +_initOverflow() { + if (Tabs._overflowObserver) return; + Tabs._overflowObserver = new ResizeObserver(() => Tabs._recomputeOverflow()); + Tabs._overflowObserver.observe(Tabs.tabListEl); + Tabs._overflowObserver.observe(Tabs.tabBarEl || Tabs.tabListEl.parentElement); + + Tabs.tabOverflowBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const open = !Tabs.tabOverflowMenuEl.hidden; + Tabs.tabOverflowMenuEl.hidden = open; + if (!open) Tabs._populateOverflowMenu(); + }); + + document.addEventListener('click', (e) => { + if (!Tabs.tabOverflowEl.contains(e.target)) { + Tabs.tabOverflowMenuEl.hidden = true; + } + }); +}, + +_recomputeOverflow() { + // Reveal all tabs first to measure natural sizes. + for (const s of Tabs.tabs.values()) { + s.tabEl.style.display = ''; + } + + const listRect = Tabs.tabListEl.getBoundingClientRect(); + const containerRight = listRect.right; + const overflowed = []; + for (const id of Tabs.tabOrder) { + const s = Tabs.tabs.get(id); + if (!s) continue; + const rect = s.tabEl.getBoundingClientRect(); + if (rect.right > containerRight + 0.5) { + // This tab is clipped — hide it (unless it's the active one; we always show that). + if (id === Tabs.activeTabId) continue; + s.tabEl.style.display = 'none'; + overflowed.push(id); + } + } + Tabs.tabOverflowEl.hidden = overflowed.length === 0; + Tabs._overflowedIds = overflowed; + if (!Tabs.tabOverflowMenuEl.hidden) Tabs._populateOverflowMenu(); +}, + +_populateOverflowMenu() { + Tabs.tabOverflowMenuEl.replaceChildren(); + // Show overflowed tabs first; then any tabs not visible for any other reason. + const ids = Tabs._overflowedIds || []; + for (const id of ids) { + const s = Tabs.tabs.get(id); + if (!s) continue; + const item = document.createElement('div'); + item.className = 'menu-item' + (id === Tabs.activeTabId ? ' active' : ''); + item.textContent = s.name; + item.addEventListener('click', () => { + Tabs.tabOverflowMenuEl.hidden = true; + Tabs.switchToTab(id); + Tabs._recomputeOverflow(); + }); + Tabs.tabOverflowMenuEl.appendChild(item); + } + if (ids.length === 0) { + const empty = document.createElement('div'); + empty.className = 'menu-item'; + empty.style.color = '#999'; + empty.textContent = '(no overflowed tabs)'; + Tabs.tabOverflowMenuEl.appendChild(empty); + } +}, +``` + +In `init()`, after `Tabs._installShortcuts();`, add: + +```javascript +Tabs.tabBarEl = Tabs.tabListEl.parentElement; +Tabs._initOverflow(); +``` + +Also call `Tabs._recomputeOverflow()` at the end of `_addTabInternal`, `_removeTabInternal`, `_reorderDom`, `_commitRename` paths, and `switchToTab`. The cleanest hook point: append a single line at the end of each method after the DOM mutations: + +```javascript +Tabs._recomputeOverflow?.(); +``` + +(Use optional chaining so early calls during init — before `_initOverflow` runs — don't throw.) + +- [ ] **Step 2: Manual verification** + +Reload the extension. Verify: +- With only a few tabs, no `…` button is visible. Resize the browser window narrower — once tabs no longer fit, the `…` button appears at the right of the bar. +- Click `…` — a dropdown shows the overflowed tabs in order. +- Click a tab name in the dropdown — the dropdown closes; that tab becomes active and visible (the bar reshuffles since the active tab is always visible). +- Click outside the dropdown — it closes. +- Add many tabs (Ctrl+Alt+T repeatedly) — overflow gracefully accommodates a growing list. + +- [ ] **Step 3: Commit** + +```bash +git add tabs.js +git commit -m "feat: tab overflow with ⋯ dropdown menu" +``` + +--- + +## Task 13: Quota-exceeded fallback per tab + +**Files:** +- Modify: `tabs.js` + +- [ ] **Step 1: Detect quota errors and fall back per-tab** + +Add a small map and helper to `Tabs`: + +```javascript +_localFallbackTabs: new Set(), + +_isQuotaError(e) { + const msg = (e && (e.message || String(e))).toLowerCase(); + return msg.includes('quota') || msg.includes('exceed'); +}, + +_showQuotaBanner() { + if (document.getElementById('quotaBanner')) return; + const banner = document.createElement('div'); + banner.id = 'quotaBanner'; + banner.className = 'tab-deleted-banner'; + banner.textContent = 'Sync storage full. New changes will save locally only.'; + const dismiss = document.createElement('button'); + dismiss.className = 'dismiss'; + dismiss.textContent = '×'; + dismiss.addEventListener('click', () => banner.remove()); + banner.appendChild(dismiss); + const tabBar = document.getElementById('tabBar'); + tabBar.parentNode.insertBefore(banner, tabBar); +}, +``` + +Modify `_saveTab` to fall back on quota errors: + +```javascript +async _saveTab(id) { + const s = Tabs.tabs.get(id); + if (!s) return; + s.saveTimeout = null; + const current = Scratchpad.Editor.getContent(s.editor); + if (current === s.lastSavedContent) { + console.log(`[Tabs] ${id}: unchanged, skipping save`); + return; + } + Tabs.onStatus('saving'); + + const writeKey = 'tab_' + id; + const useLocal = Tabs._localFallbackTabs.has(id); + const target = useLocal ? Scratchpad.Storage.Local : Scratchpad.Storage; + + try { + await target.set({ [writeKey]: current }); + s.lastSavedContent = current; + s.contentChanged = false; + Tabs.onStatus('saved'); + Tabs.onUpdate(); + console.log(`[Tabs] ${id}: saved ${current.length} chars (${useLocal ? 'local fallback' : 'sync'})`); + } catch (e) { + if (Tabs._isQuotaError(e) && !useLocal) { + console.warn(`[Tabs] ${id}: quota exceeded; falling back to local storage`); + Tabs._localFallbackTabs.add(id); + Tabs._showQuotaBanner(); + // Retry once with local. + try { + await Scratchpad.Storage.Local.set({ [writeKey]: current }); + s.lastSavedContent = current; + s.contentChanged = false; + Tabs.onStatus('saved'); + Tabs.onUpdate(); + } catch (e2) { + console.error(`[Tabs] ${id}: local fallback also failed:`, e2); + Tabs.onStatus('error', `Save failed for "${s.name}"`); + } + } else { + console.error(`[Tabs] save failed for ${id}:`, e); + Tabs.onStatus('error', `Save failed for "${s.name}"`); + } + } +}, +``` + +- [ ] **Step 2: Manual verification** + +Verifying the actual quota behavior requires filling sync storage. Approximate this: + +In the Browser Console: + +```javascript +// Force a quota error by writing a large value to a fake key directly. +try { + await browser.storage.sync.set({ scratchpadBig: 'x'.repeat(120 * 1024) }); +} catch (e) { console.log('expected quota error:', e.message); } +``` + +Then in the scratchpad UI, type a few characters into a tab. Within 500ms the status should flip to "Saving..." and you should see in the console either: +- A normal "saved" message (if Firefox prioritizes the new write over the bogus one), or +- A `quota exceeded; falling back to local storage` log, followed by the yellow `Sync storage full…` banner. + +If you see the banner, type more — saves succeed and persist on this device. They will not appear on other devices for that tab until quota frees up. Reload the page; the local-fallback content still shows. + +Clean up: + +```javascript +await browser.storage.sync.remove('scratchpadBig'); +``` + +> **Note:** sync storage quotas can be racy and Firefox occasionally accepts oversized writes silently. The banner test is best-effort. The code path is straightforward enough that exercising it manually is a strong indicator without comprehensive automation. + +- [ ] **Step 3: Commit** + +```bash +git add tabs.js +git commit -m "feat: per-tab fallback to local storage on sync quota error" +``` + +--- + +## Task 14: Bump manifest version + +**Files:** +- Modify: `manifest.json` + +- [ ] **Step 1: Increment version** + +In `manifest.json`, change `"version": "1.9"` to `"version": "2.0"`. Update the `description` to mention tabs: + +```json +"description": "A synced multi-tab scratchpad with rich text formatting and hyperlinks that syncs across your Firefox devices", +``` + +- [ ] **Step 2: Manual verification** + +Reload the extension at `about:debugging`. The version shown in the entry should now be 2.0. + +- [ ] **Step 3: Commit** + +```bash +git add manifest.json +git commit -m "chore: bump version to 2.0 for multi-tab release" +``` + +--- + +## Task 15: End-to-end manual regression pass + +**Files:** none. + +- [ ] **Step 1: Run the full verification matrix** + +Working through this once at the end catches integration bugs that per-task verification can miss. + +1. **Fresh install:** uninstall and reinstall the extension via `about:debugging`. Open the scratchpad — you should see one empty tab named "Tab 1". Browser Console shows the `[Migration] no legacy content; creating empty Tab 1` message. + +2. **Migration from legacy:** restore the pre-feature build from git stash or a separate branch, type some content, then load the new build over it. The content appears under a tab named "Scratchpad". `browser.storage.sync.get('scratchpadContent')` returns `undefined`. + +3. **Tab CRUD:** + - Create 5 tabs (`+` and Ctrl+Alt+T). Each is named "Tab N" where N is the lowest unused integer. + - Rename tab 3 by double-click → blur. Rename tab 4 by double-click → Enter. Rename tab 5 by double-click → Esc (no change). + - Close tab 2 with `×`, then click "Undo" within 5s — restored at original index. + - Close tab 2 again, wait 6s — toast disappears; tab is gone. + - Close tab 2 with Ctrl+Alt+W — same behavior. + - With one tab left, the `×` is hidden and Ctrl+Alt+W is a no-op. + +4. **Active-tab persistence:** reload the page; the same tab that was active before reload is active again. + +5. **Keyboard shortcuts:** + - Ctrl+B / Ctrl+I / Ctrl+U toggle formatting in the active editor. + - Ctrl+K opens the link prompt; entering `https://example.com` creates a working link. + - Ctrl+Tab and Ctrl+Shift+Tab cycle through tabs (subject to Firefox interception). + +6. **Two-device sync (if possible):** + - Edit tab T on profile A, observe profile B reflects within 30s. + - Type on T on B while typing on T on A — A's in-progress text is not lost. + - Create on A, rename on B, close on A while B has it active — B shows the "Save as new tab?" banner with intact content. + - Concurrent create on both while disconnected, then reconnect — both tabs end up on both profiles. + +7. **Overflow:** narrow the window until tabs no longer fit. The `⋯` button appears; clicking shows hidden tabs; selecting one activates it. + +8. **Orphan recovery:** in the Console, write a `tab_` directly; reload; the tab appears with its content. + +9. **Quota fallback:** force-fill sync storage; observe the yellow banner and continued saves. + +- [ ] **Step 2: Optional final tag** + +If everything passes, optionally tag the release: + +```bash +git tag v2.0 +``` + +--- + +## Self-review checklist + +(Performed by the plan author after writing the plan above. Checked once; not re-run.) + +- **Spec coverage:** + - Storage schema ✓ (Task 1, 3, 5). + - Migration ✓ (Task 3). + - DOM structure & UI ✓ (Tasks 4, 5, 6, 7). + - In-memory state ✓ (Task 5). + - Save/switch flow ✓ (Task 5, 6). + - Cross-device sync content ✓ (Task 9). + - Cross-device sync tab list ✓ (Task 10). + - Orphan recovery ✓ (Task 11). + - Keyboard shortcuts ✓ (Task 8). + - Overflow ✓ (Task 12). + - Error/quota handling ✓ (Task 13). + - Code organization (storage/editor/migration/tabs/sync) ✓ (Tasks 1–11). + - Manifest bump ✓ (Task 14). +- **Type/method consistency:** `_addTabInternal`, `_removeTabInternal`, `_persistIndexAndContent`, `_buildIndex`, `applyRemoteContent`, `applyRemoteIndex`, `hasTab`, `flushDirty` are used consistently across tasks. `Migration.newTabId` is referenced from Tabs and Sync — defined in Task 3. +- **No placeholders:** every code block is concrete; verification steps are concrete. From 54a419b58002e46626596de7a6632304284cca3f Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 12:47:43 -0400 Subject: [PATCH 03/25] refactor: extract storage layer into Scratchpad.Storage module --- scratchpad.html | 1 + scratchpad.js | 61 +++++++----------------------------------- storage.js | 70 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 52 deletions(-) create mode 100644 storage.js diff --git a/scratchpad.html b/scratchpad.html index 27b6f95..ecf99e4 100644 --- a/scratchpad.html +++ b/scratchpad.html @@ -30,6 +30,7 @@

Scratchpad

+ diff --git a/scratchpad.js b/scratchpad.js index a086b5f..a6b4e56 100644 --- a/scratchpad.js +++ b/scratchpad.js @@ -9,31 +9,11 @@ let saveTimeout; const SAVE_DELAY = 500; // milliseconds to wait after typing stops before saving const AUTO_SAVE_INTERVAL = 5000; // auto-save every 5 seconds const SYNC_CHECK_INTERVAL = 10000; // check for sync updates every 10 seconds -let storageArea = null; // Will be set to either 'sync' or 'local' -let usingSyncStorage = false; let lastSavedContent = ''; // Track last saved content let contentChanged = false; // Track if content has changed since last save let autoSaveInterval = null; // Interval for periodic auto-save let syncCheckInterval = null; // Interval for checking sync updates -// Detect which storage to use -async function initStorage() { - try { - // Try to use sync storage first - await browser.storage.sync.set({ scratchpadTest: 'test' }); - await browser.storage.sync.remove('scratchpadTest'); - storageArea = browser.storage.sync; - usingSyncStorage = true; - console.log('Using sync storage'); - } catch (error) { - // Fall back to local storage if sync fails - console.warn('Sync storage not available, falling back to local storage:', error); - storageArea = browser.storage.local; - usingSyncStorage = false; - console.log('Using local storage'); - } -} - // Update last update timestamp function updateLastUpdateTime() { const now = new Date(); @@ -161,11 +141,7 @@ function setContent(html) { // Load saved content when page opens async function loadContent() { try { - if (!storageArea) { - await initStorage(); - } - - const result = await storageArea.get('scratchpadContent'); + const result = await Scratchpad.Storage.get('scratchpadContent'); if (result.scratchpadContent) { setContent(result.scratchpadContent); lastSavedContent = result.scratchpadContent; @@ -183,27 +159,18 @@ async function loadContent() { // Save content to storage async function saveContent() { const currentContent = getContent(); - - // Only save if content has actually changed if (currentContent === lastSavedContent) { console.log('Content unchanged, skipping save'); return; } - updateStatus('saving'); try { - if (!storageArea) { - await initStorage(); - } - - await storageArea.set({ - scratchpadContent: currentContent - }); + await Scratchpad.Storage.set({ scratchpadContent: currentContent }); lastSavedContent = currentContent; contentChanged = false; updateStatus('saved'); updateLastUpdateTime(); - console.log(`Saved ${currentContent.length} characters to ${usingSyncStorage ? 'sync' : 'local'} storage`); + console.log(`Saved ${currentContent.length} characters to ${Scratchpad.Storage.usingSync ? 'sync' : 'local'} storage`); } catch (error) { console.error('Error saving content:', error); updateStatus('error', error.message); @@ -220,7 +187,7 @@ function updateStatus(status, message = '') { statusDiv.classList.add('saving'); break; case 'saved': - const storageType = usingSyncStorage ? 'Synced' : 'Saved'; + const storageType = Scratchpad.Storage.usingSync ? 'Synced' : 'Saved'; statusDiv.textContent = storageType; statusDiv.classList.add('saved'); // Reset to neutral status after 2 seconds @@ -231,11 +198,11 @@ function updateStatus(status, message = '') { }, 2000); break; case 'loaded': - const readyText = usingSyncStorage ? 'Ready (Synced)' : 'Ready (Local)'; + const readyText = Scratchpad.Storage.usingSync ? 'Ready (Synced)' : 'Ready (Local)'; statusDiv.textContent = readyText; break; case 'ready': - statusDiv.textContent = usingSyncStorage ? 'Ready (Synced)' : 'Ready (Local)'; + statusDiv.textContent = Scratchpad.Storage.usingSync ? 'Ready (Synced)' : 'Ready (Local)'; break; case 'error': statusDiv.textContent = message ? `Error: ${message}` : 'Error'; @@ -370,15 +337,10 @@ refreshBtn.addEventListener('click', async () => { }); // Listen for storage changes from other tabs/devices -browser.storage.onChanged.addListener((changes, areaName) => { - const relevantArea = usingSyncStorage ? 'sync' : 'local'; - console.log(`Storage change event: area=${areaName}, relevant=${relevantArea}`, changes); - - if (areaName === relevantArea && changes.scratchpadContent) { +Scratchpad.Storage.onChanged((changes) => { + if (changes.scratchpadContent) { const newValue = changes.scratchpadContent.newValue || ''; console.log(`Storage changed: ${newValue.length} chars`); - - // Only update if the content is different from what's currently in the editor if (newValue !== getContent()) { console.log('⚡ Real-time sync update detected'); setContent(newValue); @@ -401,12 +363,7 @@ function startAutoSave() { // Periodic sync check - checks for updates from other devices every 10 seconds async function checkForSyncUpdates() { try { - if (!storageArea) { - console.log('Storage not initialized yet'); - return; - } - - const result = await storageArea.get('scratchpadContent'); + const result = await Scratchpad.Storage.get('scratchpadContent'); const storedContent = result.scratchpadContent || ''; const currentContent = getContent(); diff --git a/storage.js b/storage.js new file mode 100644 index 0000000..191c59e --- /dev/null +++ b/storage.js @@ -0,0 +1,70 @@ +// storage.js — Storage area init and generic wrappers. +// Exposes window.Scratchpad.Storage. + +window.Scratchpad = window.Scratchpad || {}; + +const Storage = { + area: null, // browser.storage.sync or browser.storage.local + usingSync: false, // whether we're using sync (vs. local) for the primary area + + // Initialize storage. Tries sync first; falls back to local on failure. + // Idempotent — safe to call multiple times. + async init() { + if (Storage.area) return; + try { + await browser.storage.sync.set({ scratchpadTest: 'test' }); + await browser.storage.sync.remove('scratchpadTest'); + Storage.area = browser.storage.sync; + Storage.usingSync = true; + console.log('[Storage] using sync'); + } catch (e) { + console.warn('[Storage] sync unavailable, falling back to local:', e); + Storage.area = browser.storage.local; + Storage.usingSync = false; + console.log('[Storage] using local'); + } + }, + + // Read one or more keys. Pass a string for a single key, or an array of strings. + async get(keys) { + await Storage.init(); + return Storage.area.get(keys); + }, + + // Write a key-value object. + async set(obj) { + await Storage.init(); + return Storage.area.set(obj); + }, + + // Remove one or more keys. + async remove(keys) { + await Storage.init(); + return Storage.area.remove(keys); + }, + + // Read every key in the primary storage area. Returns an object. + async getAll() { + await Storage.init(); + return Storage.area.get(null); + }, + + // Listen for changes to the primary storage area only (filters out the + // wrong area for our usingSync mode). + onChanged(callback) { + browser.storage.onChanged.addListener((changes, areaName) => { + const expected = Storage.usingSync ? 'sync' : 'local'; + if (areaName === expected) callback(changes); + }); + }, + + // Direct access to browser.storage.local (for activeTabId persistence, + // which is always per-device regardless of sync availability). + Local: { + async get(keys) { return browser.storage.local.get(keys); }, + async set(obj) { return browser.storage.local.set(obj); }, + async remove(keys) { return browser.storage.local.remove(keys); }, + }, +}; + +window.Scratchpad.Storage = Storage; From 4e54375d204820f2483a11dc887b043d9c8143bf Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 12:53:34 -0400 Subject: [PATCH 04/25] refactor: extract editor concerns into Scratchpad.Editor module --- editor.js | 135 ++++++++++++++++++++++++++++ scratchpad.html | 1 + scratchpad.js | 232 +++++------------------------------------------- 3 files changed, 157 insertions(+), 211 deletions(-) create mode 100644 editor.js diff --git a/editor.js b/editor.js new file mode 100644 index 0000000..cc6d08d --- /dev/null +++ b/editor.js @@ -0,0 +1,135 @@ +// editor.js — Sanitization, formatting commands, link creation, per-editor shortcuts. +// Exposes window.Scratchpad.Editor. + +window.Scratchpad = window.Scratchpad || {}; + +const Editor = { + // Sanitize URL: only allow http, https, mailto, tel. Returns '' for unsafe URLs. + sanitizeURL(url) { + if (!url) return ''; + url = url.trim(); + const allowedProtocols = ['http:', 'https:', 'mailto:', 'tel:']; + try { + const parsed = new URL(url); + if (allowedProtocols.includes(parsed.protocol)) return url; + } catch (e) { + if (url.match(/^[a-zA-Z0-9][a-zA-Z0-9-._]*\.[a-zA-Z]{2,}/)) { + return 'https://' + url; + } + } + return ''; + }, + + // Sanitize HTML: allow a small whitelist of tags. Returns a DocumentFragment + // safe to insert into the editor. + sanitizeHTML(html) { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const allowedTags = ['B', 'STRONG', 'I', 'EM', 'U', 'UL', 'OL', 'LI', 'BR', 'DIV', 'P', 'SPAN', 'A']; + + function sanitizeNode(node) { + if (node.nodeType === Node.TEXT_NODE) return node.cloneNode(true); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + + if (!allowedTags.includes(node.tagName)) { + return document.createTextNode(node.textContent); + } + + const cleanNode = document.createElement(node.tagName); + + if (node.tagName === 'A') { + const sanitizedHref = Editor.sanitizeURL(node.getAttribute('href')); + if (sanitizedHref) { + cleanNode.setAttribute('href', sanitizedHref); + cleanNode.setAttribute('target', '_blank'); + cleanNode.setAttribute('rel', 'noopener noreferrer'); + } else { + return document.createTextNode(node.textContent); + } + } + + for (const child of node.childNodes) { + const sanitizedChild = sanitizeNode(child); + if (sanitizedChild) cleanNode.appendChild(sanitizedChild); + } + return cleanNode; + } + + const fragment = document.createDocumentFragment(); + for (const child of doc.body.childNodes) { + const sanitizedChild = sanitizeNode(child); + if (sanitizedChild) fragment.appendChild(sanitizedChild); + } + return fragment; + }, + + // Serialize an editor element's children to an HTML string (no innerHTML). + getContent(editorEl) { + const serializer = new XMLSerializer(); + let html = ''; + for (const child of editorEl.childNodes) { + if (child.nodeType === Node.TEXT_NODE) html += child.textContent; + else html += serializer.serializeToString(child); + } + return html; + }, + + // Replace an editor element's children with sanitized HTML. + setContent(editorEl, html) { + const sanitizedFragment = Editor.sanitizeHTML(html); + editorEl.replaceChildren(sanitizedFragment); + }, + + // Run a contenteditable formatting command on the active selection. + // Caller is responsible for re-focusing the editor afterward. + executeCommand(editorEl, command) { + document.execCommand(command, false, null); + editorEl.focus(); + }, + + // Prompt for a URL and create a link from the current selection. + // Returns true if a link was created, false otherwise. + createLink(editorEl) { + const selection = window.getSelection(); + const selectedText = selection.toString(); + let url = prompt('Enter URL:', selectedText.match(/^https?:\/\//) ? selectedText : 'https://'); + if (!url) return false; + url = Editor.sanitizeURL(url); + if (!url) { + alert('Invalid URL. Please use http://, https://, mailto:, or tel: URLs.'); + return false; + } + document.execCommand('createLink', false, url); + const links = editorEl.querySelectorAll('a[href]:not([target])'); + links.forEach(link => { + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); + }); + editorEl.focus(); + return true; + }, + + // Attach all per-editor behavior to a contenteditable element: + // - Ctrl+B/I/U formatting shortcuts + // - Ctrl+K link shortcut + // - click-to-open links in new tab + // - input event → onInputChange callback (for dirty tracking) + attachShortcuts(editorEl, onInputChange) { + editorEl.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.key === 'b') { e.preventDefault(); Editor.executeCommand(editorEl, 'bold'); onInputChange(); } + else if (e.ctrlKey && e.key === 'i') { e.preventDefault(); Editor.executeCommand(editorEl, 'italic'); onInputChange(); } + else if (e.ctrlKey && e.key === 'u') { e.preventDefault(); Editor.executeCommand(editorEl, 'underline'); onInputChange(); } + else if (e.ctrlKey && e.key === 'k') { e.preventDefault(); if (Editor.createLink(editorEl)) onInputChange(); } + }); + editorEl.addEventListener('input', () => onInputChange()); + editorEl.addEventListener('click', (e) => { + const link = e.target.closest('a[href]'); + if (!link) return; + e.preventDefault(); + const sanitizedHref = Editor.sanitizeURL(link.getAttribute('href')); + if (sanitizedHref) window.open(sanitizedHref, '_blank', 'noopener,noreferrer'); + }); + }, +}; + +window.Scratchpad.Editor = Editor; diff --git a/scratchpad.html b/scratchpad.html index ecf99e4..252b76d 100644 --- a/scratchpad.html +++ b/scratchpad.html @@ -31,6 +31,7 @@

Scratchpad

+ diff --git a/scratchpad.js b/scratchpad.js index a6b4e56..93a0c74 100644 --- a/scratchpad.js +++ b/scratchpad.js @@ -21,129 +21,12 @@ function updateLastUpdateTime() { lastUpdateDiv.textContent = `Updated: ${timeStr}`; } -// Sanitize URL to prevent XSS attacks -function sanitizeURL(url) { - if (!url) return ''; - - // Remove whitespace - url = url.trim(); - - // Only allow http, https, mailto, and tel protocols - const allowedProtocols = ['http:', 'https:', 'mailto:', 'tel:']; - - try { - const parsed = new URL(url); - if (allowedProtocols.includes(parsed.protocol)) { - return url; - } - } catch (e) { - // If URL parsing fails, assume it's a relative URL or domain without protocol - // Add https:// if it looks like a domain - if (url.match(/^[a-zA-Z0-9][a-zA-Z0-9-._]*\.[a-zA-Z]{2,}/)) { - return 'https://' + url; - } - } - - // Block dangerous protocols like javascript:, data:, file:, etc. - return ''; -} - -// Sanitize HTML to only allow safe formatting tags -function sanitizeHTML(html) { - // Use DOMParser to safely parse HTML without innerHTML - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - - // Allowed tags for formatting - const allowedTags = ['B', 'STRONG', 'I', 'EM', 'U', 'UL', 'OL', 'LI', 'BR', 'DIV', 'P', 'SPAN', 'A']; - - // Recursively sanitize nodes - function sanitizeNode(node) { - // If it's a text node, keep it - if (node.nodeType === Node.TEXT_NODE) { - return node.cloneNode(true); - } - - // If it's an element node - if (node.nodeType === Node.ELEMENT_NODE) { - // Only allow specific tags - if (!allowedTags.includes(node.tagName)) { - // For disallowed tags, just return their text content - return document.createTextNode(node.textContent); - } - - // Create a clean version of the allowed element - const cleanNode = document.createElement(node.tagName); - - // Special handling for tags - allow href but sanitize it - if (node.tagName === 'A') { - const href = node.getAttribute('href'); - const sanitizedHref = sanitizeURL(href); - - if (sanitizedHref) { - cleanNode.setAttribute('href', sanitizedHref); - cleanNode.setAttribute('target', '_blank'); - cleanNode.setAttribute('rel', 'noopener noreferrer'); - } else { - // If href is unsafe, convert to plain text - return document.createTextNode(node.textContent); - } - } - - // Recursively sanitize children - for (const child of node.childNodes) { - const sanitizedChild = sanitizeNode(child); - if (sanitizedChild) { - cleanNode.appendChild(sanitizedChild); - } - } - - return cleanNode; - } - - return null; - } - - // Sanitize all children from the body - const fragment = document.createDocumentFragment(); - for (const child of doc.body.childNodes) { - const sanitizedChild = sanitizeNode(child); - if (sanitizedChild) { - fragment.appendChild(sanitizedChild); - } - } - - return fragment; -} - -// Get content from editor as HTML string -function getContent() { - // Serialize DOM to string without using innerHTML - const serializer = new XMLSerializer(); - let html = ''; - for (const child of scratchpad.childNodes) { - if (child.nodeType === Node.TEXT_NODE) { - html += child.textContent; - } else { - html += serializer.serializeToString(child); - } - } - return html; -} - -// Set content in editor (with sanitization) -function setContent(html) { - const sanitizedFragment = sanitizeHTML(html); - // Use replaceChildren instead of innerHTML - scratchpad.replaceChildren(sanitizedFragment); -} - // Load saved content when page opens async function loadContent() { try { const result = await Scratchpad.Storage.get('scratchpadContent'); if (result.scratchpadContent) { - setContent(result.scratchpadContent); + Scratchpad.Editor.setContent(scratchpad, result.scratchpadContent); lastSavedContent = result.scratchpadContent; console.log(`Loaded ${result.scratchpadContent.length} characters from storage`); } @@ -158,7 +41,7 @@ async function loadContent() { // Save content to storage async function saveContent() { - const currentContent = getContent(); + const currentContent = Scratchpad.Editor.getContent(scratchpad); if (currentContent === lastSavedContent) { console.log('Content unchanged, skipping save'); return; @@ -221,97 +104,15 @@ function debouncedSave() { }, SAVE_DELAY); } -// Execute formatting command -function executeCommand(command) { - document.execCommand(command, false, null); - scratchpad.focus(); - contentChanged = true; -} - -// Create a hyperlink -function createLink() { - // Get selected text - const selection = window.getSelection(); - const selectedText = selection.toString(); - - // Prompt for URL - let url = prompt('Enter URL:', selectedText.match(/^https?:\/\//) ? selectedText : 'https://'); - - if (url) { - // Sanitize the URL - url = sanitizeURL(url); - - if (url) { - // Create the link - document.execCommand('createLink', false, url); - - // Find the newly created link and add security attributes - const links = scratchpad.querySelectorAll('a[href]:not([target])'); - links.forEach(link => { - link.setAttribute('target', '_blank'); - link.setAttribute('rel', 'noopener noreferrer'); - }); - - scratchpad.focus(); - contentChanged = true; - } else { - alert('Invalid URL. Please use http://, https://, mailto:, or tel: URLs.'); - } - } -} - -// Handle keyboard shortcuts -scratchpad.addEventListener('keydown', (e) => { - // Ctrl+B for bold - if (e.ctrlKey && e.key === 'b') { - e.preventDefault(); - executeCommand('bold'); - } - // Ctrl+I for italic - else if (e.ctrlKey && e.key === 'i') { - e.preventDefault(); - executeCommand('italic'); - } - // Ctrl+U for underline - else if (e.ctrlKey && e.key === 'u') { - e.preventDefault(); - executeCommand('underline'); - } - // Ctrl+K for link - else if (e.ctrlKey && e.key === 'k') { - e.preventDefault(); - createLink(); - } -}); - -// Listen for changes in the editor -scratchpad.addEventListener('input', debouncedSave); - -// Handle clicks on links - open them in new tabs -scratchpad.addEventListener('click', (e) => { - // Check if clicked element is a link or inside a link - const link = e.target.closest('a[href]'); - - if (link) { - e.preventDefault(); - const href = link.getAttribute('href'); - - // Sanitize the URL before opening - const sanitizedHref = sanitizeURL(href); - - if (sanitizedHref) { - window.open(sanitizedHref, '_blank', 'noopener,noreferrer'); - } - } -}); - // Handle toolbar button clicks toolbarBtns.forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); const command = btn.dataset.command; if (command) { - executeCommand(command); + Scratchpad.Editor.executeCommand(scratchpad, command); + contentChanged = true; + debouncedSave(); } }); }); @@ -319,7 +120,10 @@ toolbarBtns.forEach(btn => { // Handle link button click linkBtn.addEventListener('click', (e) => { e.preventDefault(); - createLink(); + if (Scratchpad.Editor.createLink(scratchpad)) { + contentChanged = true; + debouncedSave(); + } }); // Handle refresh button click @@ -341,9 +145,9 @@ Scratchpad.Storage.onChanged((changes) => { if (changes.scratchpadContent) { const newValue = changes.scratchpadContent.newValue || ''; console.log(`Storage changed: ${newValue.length} chars`); - if (newValue !== getContent()) { + if (newValue !== Scratchpad.Editor.getContent(scratchpad)) { console.log('⚡ Real-time sync update detected'); - setContent(newValue); + Scratchpad.Editor.setContent(scratchpad, newValue); lastSavedContent = newValue; contentChanged = false; updateLastUpdateTime(); @@ -354,7 +158,7 @@ Scratchpad.Storage.onChanged((changes) => { // Periodic auto-save - saves every 5 seconds if content has changed function startAutoSave() { autoSaveInterval = setInterval(() => { - if (contentChanged && getContent() !== lastSavedContent) { + if (contentChanged && Scratchpad.Editor.getContent(scratchpad) !== lastSavedContent) { saveContent(); } }, AUTO_SAVE_INTERVAL); @@ -365,7 +169,7 @@ async function checkForSyncUpdates() { try { const result = await Scratchpad.Storage.get('scratchpadContent'); const storedContent = result.scratchpadContent || ''; - const currentContent = getContent(); + const currentContent = Scratchpad.Editor.getContent(scratchpad); console.log(`Sync check: stored=${storedContent.length} chars, current=${currentContent.length} chars, lastSaved=${lastSavedContent.length} chars`); @@ -379,7 +183,7 @@ async function checkForSyncUpdates() { if (!userIsTyping) { // Safe to update - user isn't actively typing - setContent(storedContent); + Scratchpad.Editor.setContent(scratchpad, storedContent); lastSavedContent = storedContent; contentChanged = false; updateLastUpdateTime(); @@ -412,11 +216,17 @@ window.addEventListener('beforeunload', () => { clearInterval(syncCheckInterval); } // Do a final save if there are unsaved changes - if (contentChanged && getContent() !== lastSavedContent) { + if (contentChanged && Scratchpad.Editor.getContent(scratchpad) !== lastSavedContent) { saveContent(); } }); +// Attach per-editor shortcuts (Ctrl+B/I/U/K, link clicks, input tracking) +Scratchpad.Editor.attachShortcuts(scratchpad, () => { + contentChanged = true; + debouncedSave(); +}); + // Load content when page opens loadContent(); From e3fe903dcc5ca5c89e0da81e746fce59abb84a7c Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 12:58:36 -0400 Subject: [PATCH 05/25] feat: migrate legacy scratchpadContent to tabIndex schema --- migration.js | 43 +++++++++++++++++++++++++++++++++++++++++++ scratchpad.html | 1 + scratchpad.js | 18 ++++++++++-------- 3 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 migration.js diff --git a/migration.js b/migration.js new file mode 100644 index 0000000..be95ef0 --- /dev/null +++ b/migration.js @@ -0,0 +1,43 @@ +// migration.js — One-time migration from legacy single-key storage to tabIndex + tab_. +// Exposes window.Scratchpad.Migration. + +window.Scratchpad = window.Scratchpad || {}; + +const Migration = { + // Returns a fresh tab id like "t_a1b2c3d4e5". + newTabId() { + return 't_' + Math.random().toString(36).slice(2, 12); + }, + + // Run migration if needed. Idempotent — safe to call on every page load. + // Side effect: after this returns, sync storage is guaranteed to contain + // a non-empty `tabIndex` and at least one `tab_` content item. + // Throws on storage failure (caller should log and proceed without further changes). + async runIfNeeded() { + const Storage = window.Scratchpad.Storage; + const existing = await Storage.get(['tabIndex', 'scratchpadContent']); + + if (Array.isArray(existing.tabIndex) && existing.tabIndex.length > 0) { + console.log('[Migration] already migrated, nothing to do'); + return; + } + + const id = Migration.newTabId(); + if (typeof existing.scratchpadContent === 'string' && existing.scratchpadContent.length > 0) { + console.log('[Migration] migrating legacy scratchpadContent into a tab'); + await Storage.set({ + tabIndex: [{ id, name: 'Scratchpad' }], + ['tab_' + id]: existing.scratchpadContent, + }); + await Storage.remove('scratchpadContent'); + } else { + console.log('[Migration] no legacy content; creating empty Tab 1'); + await Storage.set({ + tabIndex: [{ id, name: 'Tab 1' }], + ['tab_' + id]: '', + }); + } + }, +}; + +window.Scratchpad.Migration = Migration; diff --git a/scratchpad.html b/scratchpad.html index 252b76d..490cddb 100644 --- a/scratchpad.html +++ b/scratchpad.html @@ -32,6 +32,7 @@

Scratchpad

+ diff --git a/scratchpad.js b/scratchpad.js index 93a0c74..4c0d1fd 100644 --- a/scratchpad.js +++ b/scratchpad.js @@ -227,11 +227,13 @@ Scratchpad.Editor.attachShortcuts(scratchpad, () => { debouncedSave(); }); -// Load content when page opens -loadContent(); - -// Start the periodic auto-save -startAutoSave(); - -// Start periodic sync checking -startSyncCheck(); +(async () => { + try { + await Scratchpad.Migration.runIfNeeded(); + } catch (e) { + console.error('[Migration] failed; continuing without migration:', e); + } + loadContent(); + startAutoSave(); + startSyncCheck(); +})(); From 476295e1edac0a027cd7bf4e12967c01f87e24f2 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:01:49 -0400 Subject: [PATCH 06/25] fix: sweep orphaned scratchpadContent on every load --- migration.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/migration.js b/migration.js index be95ef0..64733cc 100644 --- a/migration.js +++ b/migration.js @@ -16,8 +16,16 @@ const Migration = { async runIfNeeded() { const Storage = window.Scratchpad.Storage; const existing = await Storage.get(['tabIndex', 'scratchpadContent']); + const alreadyMigrated = Array.isArray(existing.tabIndex) && existing.tabIndex.length > 0; - if (Array.isArray(existing.tabIndex) && existing.tabIndex.length > 0) { + // Self-healing sweep: if we already migrated but the legacy key is still + // around (e.g., a previous run failed between set and remove), drop it now. + if (alreadyMigrated && existing.scratchpadContent !== undefined) { + console.log('[Migration] sweeping orphaned scratchpadContent key'); + await Storage.remove('scratchpadContent'); + } + + if (alreadyMigrated) { console.log('[Migration] already migrated, nothing to do'); return; } From 54b297c7d5b8f9d4d302e3397184ee2148789415 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:03:28 -0400 Subject: [PATCH 07/25] feat: add tab bar markup and styles --- scratchpad.css | 220 ++++++++++++++++++++++++++++++++++++++++++++++++ scratchpad.html | 8 ++ 2 files changed, 228 insertions(+) diff --git a/scratchpad.css b/scratchpad.css index bc2f18c..9edb329 100644 --- a/scratchpad.css +++ b/scratchpad.css @@ -184,3 +184,223 @@ body { #scratchpad li { margin: 5px 0; } + +.tab-bar { + display: flex; + align-items: center; + background-color: #ececec; + border-bottom: 1px solid #ddd; + padding: 0 8px; + height: 36px; + flex-shrink: 0; +} + +.tab-list { + display: flex; + align-items: stretch; + flex: 1; + min-width: 0; + overflow: hidden; + height: 100%; +} + +.tab { + display: flex; + align-items: center; + gap: 6px; + padding: 0 12px; + height: 100%; + background-color: #dcdcdc; + border-right: 1px solid #ccc; + cursor: pointer; + user-select: none; + font-size: 13px; + color: #333; + max-width: 200px; + flex-shrink: 0; +} + +.tab:hover { background-color: #d0d0d0; } + +.tab.active { + background-color: #fff; + font-weight: 500; +} + +.tab-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tab-rename-input { + font: inherit; + border: 1px solid #4a90e2; + border-radius: 2px; + padding: 1px 4px; + width: 120px; + background: white; +} + +.tab-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + background: transparent; + color: #777; + cursor: pointer; + border-radius: 50%; + font-size: 14px; + line-height: 1; +} + +.tab-close:hover { background-color: #bbb; color: #000; } + +.tab.only-tab .tab-close { display: none; } + +.tab-add, .tab-overflow-btn { + background: transparent; + border: none; + font-size: 18px; + color: #555; + cursor: pointer; + padding: 4px 10px; + margin-left: 4px; + border-radius: 4px; +} + +.tab-add:hover, .tab-overflow-btn:hover { background-color: #c8c8c8; } + +.tab-overflow { position: relative; } + +.tab-overflow-menu { + position: absolute; + top: 100%; + right: 0; + background: white; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + z-index: 100; + min-width: 180px; + padding: 4px 0; + max-height: 300px; + overflow-y: auto; +} + +.tab-overflow-menu .menu-item { + display: block; + padding: 6px 12px; + cursor: pointer; + font-size: 13px; + white-space: nowrap; +} + +.tab-overflow-menu .menu-item:hover { background-color: #f0f0f0; } + +.tab-overflow-menu .menu-item.active { background-color: #e8f0fe; font-weight: 500; } + +/* Editor stack: each tab is its own contenteditable. Inactive ones are hidden. */ +.editor-stack { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.editor { + flex: 1; + width: 100%; + padding: 20px; + border: none; + outline: none; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.6; + overflow-y: auto; + display: none; +} + +.editor.active { display: block; } + +.editor:empty:before { + content: attr(data-placeholder); + color: #999; + pointer-events: none; +} + +.editor:focus:before { content: none; } + +/* Reapply formatting styles to .editor */ +.editor strong, .editor b { font-weight: bold; } +.editor em, .editor i { font-style: italic; } +.editor u { text-decoration: underline; } +.editor a { color: #4a90e2; text-decoration: underline; cursor: pointer; } +.editor a:hover { color: #357abd; text-decoration: none; } +.editor a:visited { color: #7b68c4; } +.editor ul, .editor ol { margin: 10px 0; padding-left: 30px; } +.editor li { margin: 5px 0; } + +/* Undo toast */ +.undo-toast { + position: fixed; + bottom: 16px; + right: 16px; + background: #333; + color: white; + padding: 10px 14px; + border-radius: 4px; + font-size: 13px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 2px 12px rgba(0,0,0,0.3); + z-index: 200; +} + +.undo-toast button { + background: transparent; + border: 1px solid #888; + color: white; + padding: 4px 10px; + border-radius: 3px; + cursor: pointer; + font-size: 12px; +} + +.undo-toast button:hover { border-color: #fff; } + +/* "Tab deleted on another device" banner */ +.tab-deleted-banner { + background: #fff3cd; + color: #856404; + border-bottom: 1px solid #f5c441; + padding: 8px 16px; + font-size: 13px; + display: flex; + align-items: center; + gap: 12px; +} + +.tab-deleted-banner button { + background: white; + color: #856404; + border: 1px solid #f5c441; + padding: 4px 10px; + border-radius: 3px; + cursor: pointer; + font-size: 12px; +} + +.tab-deleted-banner button:hover { background: #fff8e1; } + +.tab-deleted-banner .dismiss { + margin-left: auto; + background: transparent; + border: none; + font-size: 16px; + padding: 0 4px; +} diff --git a/scratchpad.html b/scratchpad.html index 490cddb..bf33b97 100644 --- a/scratchpad.html +++ b/scratchpad.html @@ -16,6 +16,14 @@

Scratchpad

Loaded
+
+
+ + +
From 8b68103fcad2ed2be40e1bfb20f1c3eeaaa580a3 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:09:06 -0400 Subject: [PATCH 08/25] feat: introduce Tabs module with per-tab editors and basic switching --- scratchpad.css | 62 ------------- scratchpad.html | 3 +- scratchpad.js | 221 +++++++++++--------------------------------- tabs.js | 240 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 298 insertions(+), 228 deletions(-) create mode 100644 tabs.js diff --git a/scratchpad.css b/scratchpad.css index 9edb329..7cf8074 100644 --- a/scratchpad.css +++ b/scratchpad.css @@ -123,68 +123,6 @@ body { margin: 0 4px; } -#scratchpad { - flex: 1; - width: 100%; - padding: 20px; - border: none; - outline: none; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; - line-height: 1.6; - overflow-y: auto; -} - -#scratchpad:empty:before { - content: attr(data-placeholder); - color: #999; - pointer-events: none; -} - -#scratchpad:focus:before { - content: none; -} - -/* Rich text formatting styles */ -#scratchpad strong, -#scratchpad b { - font-weight: bold; -} - -#scratchpad em, -#scratchpad i { - font-style: italic; -} - -#scratchpad u { - text-decoration: underline; -} - -#scratchpad a { - color: #4a90e2; - text-decoration: underline; - cursor: pointer; -} - -#scratchpad a:hover { - color: #357abd; - text-decoration: none; -} - -#scratchpad a:visited { - color: #7b68c4; -} - -#scratchpad ul, -#scratchpad ol { - margin: 10px 0; - padding-left: 30px; -} - -#scratchpad li { - margin: 5px 0; -} - .tab-bar { display: flex; align-items: center; diff --git a/scratchpad.html b/scratchpad.html index bf33b97..7647e9c 100644 --- a/scratchpad.html +++ b/scratchpad.html @@ -36,11 +36,12 @@

Scratchpad

-
+
+ diff --git a/scratchpad.js b/scratchpad.js index 4c0d1fd..a994473 100644 --- a/scratchpad.js +++ b/scratchpad.js @@ -1,69 +1,30 @@ -const scratchpad = document.getElementById('scratchpad'); +// scratchpad.js — Wiring & lifecycle. + const statusDiv = document.getElementById('status'); const lastUpdateDiv = document.getElementById('lastUpdate'); const refreshBtn = document.getElementById('refreshBtn'); const linkBtn = document.getElementById('linkBtn'); const toolbarBtns = document.querySelectorAll('.toolbar-btn'); -let saveTimeout; -const SAVE_DELAY = 500; // milliseconds to wait after typing stops before saving -const AUTO_SAVE_INTERVAL = 5000; // auto-save every 5 seconds -const SYNC_CHECK_INTERVAL = 10000; // check for sync updates every 10 seconds -let lastSavedContent = ''; // Track last saved content -let contentChanged = false; // Track if content has changed since last save -let autoSaveInterval = null; // Interval for periodic auto-save -let syncCheckInterval = null; // Interval for checking sync updates +const tabListEl = document.getElementById('tabList'); +const editorStackEl = document.getElementById('editorStack'); +const tabAddBtn = document.getElementById('tabAdd'); +const tabOverflowEl = document.getElementById('tabOverflow'); +const tabOverflowBtn = document.getElementById('tabOverflowBtn'); +const tabOverflowMenuEl = document.getElementById('tabOverflowMenu'); + +const AUTO_SAVE_INTERVAL = 5000; + +let autoSaveInterval = null; -// Update last update timestamp function updateLastUpdateTime() { const now = new Date(); const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); lastUpdateDiv.textContent = `Updated: ${timeStr}`; } -// Load saved content when page opens -async function loadContent() { - try { - const result = await Scratchpad.Storage.get('scratchpadContent'); - if (result.scratchpadContent) { - Scratchpad.Editor.setContent(scratchpad, result.scratchpadContent); - lastSavedContent = result.scratchpadContent; - console.log(`Loaded ${result.scratchpadContent.length} characters from storage`); - } - contentChanged = false; - updateStatus('loaded'); - updateLastUpdateTime(); - } catch (error) { - console.error('Error loading content:', error); - updateStatus('error', error.message); - } -} - -// Save content to storage -async function saveContent() { - const currentContent = Scratchpad.Editor.getContent(scratchpad); - if (currentContent === lastSavedContent) { - console.log('Content unchanged, skipping save'); - return; - } - updateStatus('saving'); - try { - await Scratchpad.Storage.set({ scratchpadContent: currentContent }); - lastSavedContent = currentContent; - contentChanged = false; - updateStatus('saved'); - updateLastUpdateTime(); - console.log(`Saved ${currentContent.length} characters to ${Scratchpad.Storage.usingSync ? 'sync' : 'local'} storage`); - } catch (error) { - console.error('Error saving content:', error); - updateStatus('error', error.message); - } -} - -// Update status indicator function updateStatus(status, message = '') { statusDiv.className = 'status'; - switch (status) { case 'saving': statusDiv.textContent = 'Saving...'; @@ -73,17 +34,11 @@ function updateStatus(status, message = '') { const storageType = Scratchpad.Storage.usingSync ? 'Synced' : 'Saved'; statusDiv.textContent = storageType; statusDiv.classList.add('saved'); - // Reset to neutral status after 2 seconds setTimeout(() => { - if (statusDiv.textContent === storageType) { - updateStatus('ready'); - } + if (statusDiv.textContent === storageType) updateStatus('ready'); }, 2000); break; case 'loaded': - const readyText = Scratchpad.Storage.usingSync ? 'Ready (Synced)' : 'Ready (Local)'; - statusDiv.textContent = readyText; - break; case 'ready': statusDiv.textContent = Scratchpad.Storage.usingSync ? 'Ready (Synced)' : 'Ready (Local)'; break; @@ -95,145 +50,81 @@ function updateStatus(status, message = '') { } } -// Debounced save function - saves after user stops typing -function debouncedSave() { - contentChanged = true; - clearTimeout(saveTimeout); - saveTimeout = setTimeout(() => { - saveContent(); - }, SAVE_DELAY); +// Toolbar buttons act on the active editor. +function activeEditor() { + const id = Scratchpad.Tabs.activeTabId; + return id ? Scratchpad.Tabs.tabs.get(id)?.editor : null; } -// Handle toolbar button clicks toolbarBtns.forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); + const ed = activeEditor(); + if (!ed) return; const command = btn.dataset.command; if (command) { - Scratchpad.Editor.executeCommand(scratchpad, command); - contentChanged = true; - debouncedSave(); + Scratchpad.Editor.executeCommand(ed, command); + Scratchpad.Tabs._markDirty(Scratchpad.Tabs.activeTabId); } }); }); -// Handle link button click linkBtn.addEventListener('click', (e) => { e.preventDefault(); - if (Scratchpad.Editor.createLink(scratchpad)) { - contentChanged = true; - debouncedSave(); + const ed = activeEditor(); + if (!ed) return; + if (Scratchpad.Editor.createLink(ed)) { + Scratchpad.Tabs._markDirty(Scratchpad.Tabs.activeTabId); } }); -// Handle refresh button click refreshBtn.addEventListener('click', async () => { - console.log('Manual refresh triggered'); refreshBtn.disabled = true; refreshBtn.textContent = '⟳ Refreshing...'; - - await checkForSyncUpdates(); - + // Sync.pollOnce will be added in Task 9; for now we just no-op. + if (window.Scratchpad.Sync && Scratchpad.Sync.pollOnce) { + await Scratchpad.Sync.pollOnce(); + } setTimeout(() => { refreshBtn.disabled = false; refreshBtn.textContent = '↻ Refresh'; }, 500); }); -// Listen for storage changes from other tabs/devices -Scratchpad.Storage.onChanged((changes) => { - if (changes.scratchpadContent) { - const newValue = changes.scratchpadContent.newValue || ''; - console.log(`Storage changed: ${newValue.length} chars`); - if (newValue !== Scratchpad.Editor.getContent(scratchpad)) { - console.log('⚡ Real-time sync update detected'); - Scratchpad.Editor.setContent(scratchpad, newValue); - lastSavedContent = newValue; - contentChanged = false; - updateLastUpdateTime(); - } - } +window.addEventListener('beforeunload', () => { + if (autoSaveInterval) clearInterval(autoSaveInterval); + // Final flush of any dirty tabs. + Scratchpad.Tabs.flushDirty(); }); -// Periodic auto-save - saves every 5 seconds if content has changed -function startAutoSave() { - autoSaveInterval = setInterval(() => { - if (contentChanged && Scratchpad.Editor.getContent(scratchpad) !== lastSavedContent) { - saveContent(); - } - }, AUTO_SAVE_INTERVAL); -} - -// Periodic sync check - checks for updates from other devices every 10 seconds -async function checkForSyncUpdates() { +(async () => { try { - const result = await Scratchpad.Storage.get('scratchpadContent'); - const storedContent = result.scratchpadContent || ''; - const currentContent = Scratchpad.Editor.getContent(scratchpad); - - console.log(`Sync check: stored=${storedContent.length} chars, current=${currentContent.length} chars, lastSaved=${lastSavedContent.length} chars`); - - // Only update if stored content is different from current editor - // and different from what we last saved (to avoid overwriting user's typing) - if (storedContent !== currentContent && storedContent !== lastSavedContent) { - // Check if user is currently typing (has made changes since last save) - const userIsTyping = contentChanged; - - console.log(`Content differs! User typing: ${userIsTyping}`); - - if (!userIsTyping) { - // Safe to update - user isn't actively typing - Scratchpad.Editor.setContent(scratchpad, storedContent); - lastSavedContent = storedContent; - contentChanged = false; - updateLastUpdateTime(); - console.log(`✓ Pulled ${storedContent.length} characters from storage`); - } else { - console.log('⚠ Update available but user is typing - deferring update'); - } - } else if (storedContent === currentContent) { - console.log('✓ Content in sync'); - } else { - console.log('Content matches last saved version'); - } - } catch (error) { - console.error('Error checking for sync updates:', error); - } -} + await Scratchpad.Storage.init(); + await Scratchpad.Migration.runIfNeeded(); -function startSyncCheck() { - syncCheckInterval = setInterval(() => { - checkForSyncUpdates(); - }, SYNC_CHECK_INTERVAL); -} + Scratchpad.Tabs.init({ + tabListEl, editorStackEl, tabAddBtn, + tabOverflowEl, tabOverflowBtn, tabOverflowMenuEl, + onStatus: updateStatus, + onUpdate: updateLastUpdateTime, + }); -// Clean up intervals when page is closed -window.addEventListener('beforeunload', () => { - if (autoSaveInterval) { - clearInterval(autoSaveInterval); - } - if (syncCheckInterval) { - clearInterval(syncCheckInterval); - } - // Do a final save if there are unsaved changes - if (contentChanged && Scratchpad.Editor.getContent(scratchpad) !== lastSavedContent) { - saveContent(); - } -}); + const result = await Scratchpad.Storage.get('tabIndex'); + const tabIndex = Array.isArray(result.tabIndex) ? result.tabIndex : []; + const contentKeys = tabIndex.map(t => 'tab_' + t.id); + const contents = contentKeys.length > 0 ? await Scratchpad.Storage.get(contentKeys) : {}; + const contentMap = {}; + for (const t of tabIndex) contentMap[t.id] = contents['tab_' + t.id] || ''; -// Attach per-editor shortcuts (Ctrl+B/I/U/K, link clicks, input tracking) -Scratchpad.Editor.attachShortcuts(scratchpad, () => { - contentChanged = true; - debouncedSave(); -}); + const local = await Scratchpad.Storage.Local.get('activeTabId'); + Scratchpad.Tabs.hydrate(tabIndex, contentMap, local.activeTabId); -(async () => { - try { - await Scratchpad.Migration.runIfNeeded(); + updateStatus('loaded'); + updateLastUpdateTime(); } catch (e) { - console.error('[Migration] failed; continuing without migration:', e); + console.error('[Lifecycle] startup failed:', e); + updateStatus('error', e.message); } - loadContent(); - startAutoSave(); - startSyncCheck(); + + autoSaveInterval = setInterval(() => Scratchpad.Tabs.flushDirty(), AUTO_SAVE_INTERVAL); })(); diff --git a/tabs.js b/tabs.js new file mode 100644 index 0000000..a176d9a --- /dev/null +++ b/tabs.js @@ -0,0 +1,240 @@ +// tabs.js — Tab map, CRUD, switching, undo, overflow detection. +// Exposes window.Scratchpad.Tabs. + +window.Scratchpad = window.Scratchpad || {}; + +const TABS_PLACEHOLDER = 'Start typing... Your notes will automatically sync across your Firefox devices. Use Ctrl+B for bold, Ctrl+I for italic, Ctrl+U for underline.'; +const SAVE_DEBOUNCE_MS = 500; +const UNDO_TIMEOUT_MS = 5000; + +const Tabs = { + // Map where TabState = { id, name, editor, lastSavedContent, contentChanged, saveTimeout } + tabs: new Map(), + tabOrder: [], // ordered list of tab ids + activeTabId: null, + recentlyClosed: null, // { meta: {id,name}, content, undoTimeoutId, removedFromIndex } | null + + // DOM refs (populated in init()) + tabListEl: null, + editorStackEl: null, + tabAddBtn: null, + tabOverflowEl: null, + tabOverflowBtn: null, + tabOverflowMenuEl: null, + + // Status callback supplied by scratchpad.js for "saving"/"saved"/"error" updates. + onStatus: null, // (status, message?) => void + onUpdate: null, // () => void called whenever last-update timestamp should refresh + + init({ tabListEl, editorStackEl, tabAddBtn, tabOverflowEl, tabOverflowBtn, tabOverflowMenuEl, onStatus, onUpdate }) { + Tabs.tabListEl = tabListEl; + Tabs.editorStackEl = editorStackEl; + Tabs.tabAddBtn = tabAddBtn; + Tabs.tabOverflowEl = tabOverflowEl; + Tabs.tabOverflowBtn = tabOverflowBtn; + Tabs.tabOverflowMenuEl = tabOverflowMenuEl; + Tabs.onStatus = onStatus || (() => {}); + Tabs.onUpdate = onUpdate || (() => {}); + + Tabs.tabAddBtn.addEventListener('click', () => Tabs.createTab()); + }, + + // Build initial tabs from a tabIndex array and a contentMap of {id → htmlString}. + // Activates the tab whose id matches `preferredActiveId` (or the first tab). + hydrate(tabIndex, contentMap, preferredActiveId) { + for (const meta of tabIndex) { + Tabs._addTabInternal(meta, contentMap[meta.id] || ''); + } + const activeId = (preferredActiveId && Tabs.tabs.has(preferredActiveId)) + ? preferredActiveId + : tabIndex[0]?.id; + if (activeId) Tabs.switchToTab(activeId); + Tabs._refreshOnlyTabClass(); + }, + + // Create the in-memory + DOM state for a tab and append it to the bar/stack. + // Does NOT update tabIndex storage. Returns the TabState. + _addTabInternal(meta, content) { + const editor = document.createElement('div'); + editor.className = 'editor'; + editor.contentEditable = 'true'; + editor.spellcheck = true; + editor.dataset.placeholder = TABS_PLACEHOLDER; + editor.dataset.tabId = meta.id; + Scratchpad.Editor.setContent(editor, content); + Scratchpad.Editor.attachShortcuts(editor, () => Tabs._markDirty(meta.id)); + Tabs.editorStackEl.appendChild(editor); + + const tabEl = document.createElement('div'); + tabEl.className = 'tab'; + tabEl.dataset.tabId = meta.id; + const labelEl = document.createElement('span'); + labelEl.className = 'tab-label'; + labelEl.textContent = meta.name; + const closeBtn = document.createElement('button'); + closeBtn.className = 'tab-close'; + closeBtn.title = 'Close tab'; + closeBtn.textContent = '×'; + tabEl.appendChild(labelEl); + tabEl.appendChild(closeBtn); + Tabs.tabListEl.appendChild(tabEl); + + tabEl.addEventListener('click', (e) => { + if (e.target === closeBtn) return; + if (e.detail >= 2 && e.target === labelEl) return; // dblclick handled below + Tabs.switchToTab(meta.id); + }); + labelEl.addEventListener('dblclick', () => Tabs._beginRename(meta.id)); + closeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + Tabs.closeTab(meta.id); + }); + + const state = { + id: meta.id, + name: meta.name, + editor, + tabEl, + labelEl, + lastSavedContent: content, + contentChanged: false, + saveTimeout: null, + }; + Tabs.tabs.set(meta.id, state); + Tabs.tabOrder.push(meta.id); + return state; + }, + + // Create a new tab via user action: writes tabIndex + tab_, switches to it. + async createTab() { + const id = Scratchpad.Migration.newTabId(); + const name = Tabs._nextTabName(); + Tabs._addTabInternal({ id, name }, ''); + try { + await Tabs._persistIndexAndContent(id, ''); + Tabs.switchToTab(id); + Tabs._refreshOnlyTabClass(); + Tabs.tabs.get(id).editor.focus(); + } catch (e) { + console.error('[Tabs] createTab failed; rolling back:', e); + Tabs._removeTabInternal(id); + Tabs.onStatus('error', 'Failed to create tab'); + } + }, + + // "Tab N" where N is the smallest positive integer not already used. + _nextTabName() { + const used = new Set(); + for (const s of Tabs.tabs.values()) { + const m = s.name.match(/^Tab (\d+)$/); + if (m) used.add(parseInt(m[1], 10)); + } + let n = 1; + while (used.has(n)) n++; + return 'Tab ' + n; + }, + + // Persist the entire tabIndex + a single tab's content in one storage write. + async _persistIndexAndContent(tabId, content) { + const tabIndex = Tabs._buildIndex(); + const update = { tabIndex }; + if (tabId !== null) update['tab_' + tabId] = content; + await Scratchpad.Storage.set(update); + }, + + _buildIndex() { + return Tabs.tabOrder.map(id => { + const s = Tabs.tabs.get(id); + return { id: s.id, name: s.name }; + }); + }, + + switchToTab(id) { + if (!Tabs.tabs.has(id)) return; + if (Tabs.activeTabId === id) return; + + // Flush any pending debounced save for the outgoing tab so a sync poll + // won't overwrite unsaved work between switch and the next debounce tick. + if (Tabs.activeTabId) { + const out = Tabs.tabs.get(Tabs.activeTabId); + if (out) { + if (out.saveTimeout) { clearTimeout(out.saveTimeout); out.saveTimeout = null; } + if (out.contentChanged) { Tabs._saveTab(out.id); } + out.tabEl.classList.remove('active'); + out.editor.classList.remove('active'); + } + } + + Tabs.activeTabId = id; + const next = Tabs.tabs.get(id); + next.tabEl.classList.add('active'); + next.editor.classList.add('active'); + + // Persist active tab id per-device. + Scratchpad.Storage.Local.set({ activeTabId: id }).catch(e => console.error('[Tabs] persist activeTabId failed:', e)); + + next.editor.focus(); + }, + + // Mark a tab as dirty and schedule a debounced save. + _markDirty(id) { + const s = Tabs.tabs.get(id); + if (!s) return; + s.contentChanged = true; + if (s.saveTimeout) clearTimeout(s.saveTimeout); + s.saveTimeout = setTimeout(() => Tabs._saveTab(id), SAVE_DEBOUNCE_MS); + }, + + async _saveTab(id) { + const s = Tabs.tabs.get(id); + if (!s) return; + s.saveTimeout = null; + const current = Scratchpad.Editor.getContent(s.editor); + if (current === s.lastSavedContent) { + console.log(`[Tabs] ${id}: unchanged, skipping save`); + return; + } + Tabs.onStatus('saving'); + try { + await Scratchpad.Storage.set({ ['tab_' + id]: current }); + s.lastSavedContent = current; + s.contentChanged = false; + Tabs.onStatus('saved'); + Tabs.onUpdate(); + console.log(`[Tabs] ${id}: saved ${current.length} chars`); + } catch (e) { + console.error(`[Tabs] save failed for ${id}:`, e); + Tabs.onStatus('error', `Save failed for "${s.name}"`); + } + }, + + // Auto-save tick: persist any dirty tabs. + flushDirty() { + for (const s of Tabs.tabs.values()) { + if (s.contentChanged) Tabs._saveTab(s.id); + } + }, + + // Remove tab DOM + state but do NOT touch storage. Used for rollback and remote-removal. + _removeTabInternal(id) { + const s = Tabs.tabs.get(id); + if (!s) return; + s.tabEl.remove(); + s.editor.remove(); + Tabs.tabs.delete(id); + Tabs.tabOrder = Tabs.tabOrder.filter(x => x !== id); + }, + + _refreshOnlyTabClass() { + const onlyOne = Tabs.tabs.size === 1; + for (const s of Tabs.tabs.values()) { + s.tabEl.classList.toggle('only-tab', onlyOne); + } + }, + + // Stubs filled in by later tasks. + closeTab(id) { console.warn('[Tabs] closeTab not yet implemented'); }, + _beginRename(id) { console.warn('[Tabs] rename not yet implemented'); }, +}; + +window.Scratchpad.Tabs = Tabs; From 892e403793497c8d8da2ac70cb37372b6f12c53c Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:16:03 -0400 Subject: [PATCH 09/25] fix: serialize per-tab saves via promise chain to prevent stale writes --- tabs.js | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tabs.js b/tabs.js index a176d9a..55b49a2 100644 --- a/tabs.js +++ b/tabs.js @@ -8,7 +8,7 @@ const SAVE_DEBOUNCE_MS = 500; const UNDO_TIMEOUT_MS = 5000; const Tabs = { - // Map where TabState = { id, name, editor, lastSavedContent, contentChanged, saveTimeout } + // Map where TabState = { id, name, editor, tabEl, labelEl, lastSavedContent, contentChanged, saveTimeout, saveChain } tabs: new Map(), tabOrder: [], // ordered list of tab ids activeTabId: null, @@ -153,8 +153,10 @@ const Tabs = { if (!Tabs.tabs.has(id)) return; if (Tabs.activeTabId === id) return; - // Flush any pending debounced save for the outgoing tab so a sync poll - // won't overwrite unsaved work between switch and the next debounce tick. + // Cancel pending debounced save for the outgoing tab and kick off an + // immediate save if dirty. The save runs async; serialization in + // _saveTab (saveChain) ensures it lands before the next save for this + // tab, so a quick switch-back doesn't read pre-flush content. if (Tabs.activeTabId) { const out = Tabs.tabs.get(Tabs.activeTabId); if (out) { @@ -185,10 +187,20 @@ const Tabs = { s.saveTimeout = setTimeout(() => Tabs._saveTab(id), SAVE_DEBOUNCE_MS); }, - async _saveTab(id) { + _saveTab(id) { const s = Tabs.tabs.get(id); - if (!s) return; + if (!s) return Promise.resolve(); s.saveTimeout = null; + // Serialize per-tab saves: chain onto any in-flight save so writes for the + // same tab apply in call order. Without this, two concurrent Storage.set + // calls have no ordering guarantee, and a stale write could land last. + s.saveChain = (s.saveChain || Promise.resolve()).then(() => Tabs._saveTabNow(id)); + return s.saveChain; + }, + + async _saveTabNow(id) { + const s = Tabs.tabs.get(id); + if (!s) return; const current = Scratchpad.Editor.getContent(s.editor); if (current === s.lastSavedContent) { console.log(`[Tabs] ${id}: unchanged, skipping save`); From 53daaef1129ea31fa85daa7366edcff3103c9e07 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:17:31 -0400 Subject: [PATCH 10/25] feat: close tab with undo toast --- tabs.js | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/tabs.js b/tabs.js index 55b49a2..9d9b9c5 100644 --- a/tabs.js +++ b/tabs.js @@ -245,7 +245,106 @@ const Tabs = { }, // Stubs filled in by later tasks. - closeTab(id) { console.warn('[Tabs] closeTab not yet implemented'); }, + async closeTab(id) { + if (Tabs.tabs.size <= 1) { + console.log('[Tabs] refusing to close last tab'); + return; + } + const s = Tabs.tabs.get(id); + if (!s) return; + + const removedIndex = Tabs.tabOrder.indexOf(id); + const meta = { id: s.id, name: s.name }; + const content = Scratchpad.Editor.getContent(s.editor); + + // If we're closing the active tab, switch to a neighbor first. + if (Tabs.activeTabId === id) { + const next = Tabs.tabOrder[removedIndex - 1] || Tabs.tabOrder[removedIndex + 1]; + if (next) Tabs.switchToTab(next); + } + + Tabs._removeTabInternal(id); + Tabs._refreshOnlyTabClass(); + + try { + await Scratchpad.Storage.set({ tabIndex: Tabs._buildIndex() }); + await Scratchpad.Storage.remove('tab_' + id); + } catch (e) { + console.error('[Tabs] closeTab persist failed; restoring tab:', e); + Tabs._addTabInternal(meta, content); + // Restore order: re-insert at removedIndex. + Tabs.tabOrder = Tabs.tabOrder.filter(x => x !== id); + Tabs.tabOrder.splice(removedIndex, 0, id); + Tabs._reorderDom(); + Tabs._refreshOnlyTabClass(); + Tabs.onStatus('error', 'Close failed'); + return; + } + + Tabs._showUndoToast(meta, content, removedIndex); + }, + + _showUndoToast(meta, content, removedIndex) { + // Clear any existing toast first. + if (Tabs.recentlyClosed) { + clearTimeout(Tabs.recentlyClosed.undoTimeoutId); + document.getElementById('undoToast')?.remove(); + } + + const toast = document.createElement('div'); + toast.id = 'undoToast'; + toast.className = 'undo-toast'; + const msg = document.createElement('span'); + msg.textContent = `Tab "${meta.name}" closed`; + const undoBtn = document.createElement('button'); + undoBtn.textContent = 'Undo'; + toast.appendChild(msg); + toast.appendChild(undoBtn); + document.body.appendChild(toast); + + const timeoutId = setTimeout(() => { + toast.remove(); + Tabs.recentlyClosed = null; + }, UNDO_TIMEOUT_MS); + + undoBtn.addEventListener('click', async () => { + clearTimeout(timeoutId); + toast.remove(); + Tabs.recentlyClosed = null; + await Tabs._restoreClosedTab(meta, content, removedIndex); + }); + + Tabs.recentlyClosed = { meta, content, undoTimeoutId: timeoutId, removedIndex }; + }, + + async _restoreClosedTab(meta, content, removedIndex) { + const state = Tabs._addTabInternal(meta, content); + // Re-insert at original index. + Tabs.tabOrder = Tabs.tabOrder.filter(x => x !== meta.id); + Tabs.tabOrder.splice(removedIndex, 0, meta.id); + Tabs._reorderDom(); + Tabs._refreshOnlyTabClass(); + try { + await Tabs._persistIndexAndContent(meta.id, content); + Tabs.switchToTab(meta.id); + } catch (e) { + console.error('[Tabs] restore failed; removing again:', e); + Tabs._removeTabInternal(meta.id); + Tabs._refreshOnlyTabClass(); + Tabs.onStatus('error', 'Restore failed'); + } + }, + + // Reorder both the tab bar children and the editor stack to match Tabs.tabOrder. + _reorderDom() { + for (const id of Tabs.tabOrder) { + const s = Tabs.tabs.get(id); + if (!s) continue; + Tabs.tabListEl.appendChild(s.tabEl); + Tabs.editorStackEl.appendChild(s.editor); + } + }, + _beginRename(id) { console.warn('[Tabs] rename not yet implemented'); }, }; From 921821955fbd9c67c9232f7efcc5cafb9b874a6c Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:20:10 -0400 Subject: [PATCH 11/25] docs: align recentlyClosed comment with field name --- tabs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabs.js b/tabs.js index 9d9b9c5..50c33aa 100644 --- a/tabs.js +++ b/tabs.js @@ -12,7 +12,7 @@ const Tabs = { tabs: new Map(), tabOrder: [], // ordered list of tab ids activeTabId: null, - recentlyClosed: null, // { meta: {id,name}, content, undoTimeoutId, removedFromIndex } | null + recentlyClosed: null, // { meta: {id,name}, content, undoTimeoutId, removedIndex } | null // DOM refs (populated in init()) tabListEl: null, From 2e5ec491ff8d9e0013a127ed9c866b28084dc555 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:21:10 -0400 Subject: [PATCH 12/25] feat: rename tab with double-click --- tabs.js | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/tabs.js b/tabs.js index 50c33aa..8dd1290 100644 --- a/tabs.js +++ b/tabs.js @@ -345,7 +345,52 @@ const Tabs = { } }, - _beginRename(id) { console.warn('[Tabs] rename not yet implemented'); }, + _beginRename(id) { + const s = Tabs.tabs.get(id); + if (!s) return; + if (s.labelEl.dataset.renaming === '1') return; + + const oldName = s.name; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'tab-rename-input'; + input.value = oldName; + s.labelEl.dataset.renaming = '1'; + s.labelEl.replaceChildren(input); + input.select(); + + let committed = false; + const finish = async (commit) => { + if (committed) return; + committed = true; + s.labelEl.dataset.renaming = '0'; + if (commit) { + const newName = input.value.trim(); + if (newName && newName !== oldName) { + s.name = newName; + s.labelEl.textContent = newName; + try { + await Scratchpad.Storage.set({ tabIndex: Tabs._buildIndex() }); + } catch (e) { + console.error('[Tabs] rename persist failed; reverting:', e); + s.name = oldName; + s.labelEl.textContent = oldName; + Tabs.onStatus('error', 'Rename failed'); + } + } else { + s.labelEl.textContent = oldName; + } + } else { + s.labelEl.textContent = oldName; + } + }; + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); finish(true); } + else if (e.key === 'Escape') { e.preventDefault(); finish(false); } + }); + input.addEventListener('blur', () => finish(true)); + }, }; window.Scratchpad.Tabs = Tabs; From 815a2b10eb56b80ab129feb59dca52ee252f24f1 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:24:34 -0400 Subject: [PATCH 13/25] feat: tab navigation keyboard shortcuts --- tabs.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tabs.js b/tabs.js index 8dd1290..0a09f4b 100644 --- a/tabs.js +++ b/tabs.js @@ -37,6 +37,7 @@ const Tabs = { Tabs.onUpdate = onUpdate || (() => {}); Tabs.tabAddBtn.addEventListener('click', () => Tabs.createTab()); + Tabs._installShortcuts(); }, // Build initial tabs from a tabIndex array and a contentMap of {id → htmlString}. @@ -244,6 +245,35 @@ const Tabs = { } }, + _installShortcuts() { + document.addEventListener('keydown', (e) => { + // Ctrl+Alt+T → new tab + if (e.ctrlKey && e.altKey && (e.key === 't' || e.key === 'T')) { + e.preventDefault(); + Tabs.createTab(); + return; + } + // Ctrl+Alt+W → close active tab + if (e.ctrlKey && e.altKey && (e.key === 'w' || e.key === 'W')) { + e.preventDefault(); + if (Tabs.activeTabId) Tabs.closeTab(Tabs.activeTabId); + return; + } + // Ctrl+Tab / Ctrl+Shift+Tab → cycle tabs + if (e.ctrlKey && !e.altKey && e.key === 'Tab') { + e.preventDefault(); + if (Tabs.tabOrder.length === 0) return; + const idx = Tabs.tabOrder.indexOf(Tabs.activeTabId); + const len = Tabs.tabOrder.length; + const next = e.shiftKey + ? Tabs.tabOrder[(idx - 1 + len) % len] + : Tabs.tabOrder[(idx + 1) % len]; + Tabs.switchToTab(next); + return; + } + }); + }, + // Stubs filled in by later tasks. async closeTab(id) { if (Tabs.tabs.size <= 1) { From 0416ec577676767e19f300ef8ae14131de83f26d Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:27:31 -0400 Subject: [PATCH 14/25] feat: cross-device sync for tab content with per-tab dirty guard --- scratchpad.html | 1 + scratchpad.js | 3 +++ sync.js | 67 +++++++++++++++++++++++++++++++++++++++++++++++++ tabs.js | 19 ++++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 sync.js diff --git a/scratchpad.html b/scratchpad.html index 7647e9c..9b6d912 100644 --- a/scratchpad.html +++ b/scratchpad.html @@ -42,6 +42,7 @@

Scratchpad

+ diff --git a/scratchpad.js b/scratchpad.js index a994473..cf23fb1 100644 --- a/scratchpad.js +++ b/scratchpad.js @@ -127,4 +127,7 @@ window.addEventListener('beforeunload', () => { } autoSaveInterval = setInterval(() => Scratchpad.Tabs.flushDirty(), AUTO_SAVE_INTERVAL); + + Scratchpad.Sync.install({ onUpdate: updateLastUpdateTime }); + Scratchpad.Sync.startPolling(); })(); diff --git a/sync.js b/sync.js new file mode 100644 index 0000000..3a97199 --- /dev/null +++ b/sync.js @@ -0,0 +1,67 @@ +// sync.js — Cross-device sync: onChanged listener + periodic poll. +// Exposes window.Scratchpad.Sync. + +window.Scratchpad = window.Scratchpad || {}; + +const SYNC_POLL_MS = 10000; + +const Sync = { + pollIntervalId: null, + onUpdate: null, // () => void called whenever a remote update is applied + + install({ onUpdate }) { + Sync.onUpdate = onUpdate || (() => {}); + Scratchpad.Storage.onChanged((changes) => Sync._handleChanges(changes)); + }, + + startPolling() { + if (Sync.pollIntervalId) return; + Sync.pollIntervalId = setInterval(() => Sync.pollOnce(), SYNC_POLL_MS); + }, + + stopPolling() { + if (Sync.pollIntervalId) { + clearInterval(Sync.pollIntervalId); + Sync.pollIntervalId = null; + } + }, + + _handleChanges(changes) { + let applied = false; + for (const [key, change] of Object.entries(changes)) { + if (key.startsWith('tab_')) { + const id = key.slice(4); + const newValue = change.newValue; + if (typeof newValue === 'string' && Scratchpad.Tabs.hasTab(id)) { + if (Scratchpad.Tabs.applyRemoteContent(id, newValue)) applied = true; + } + } + // tabIndex changes handled in Task 10. + } + if (applied) Sync.onUpdate(); + }, + + // Read all relevant keys and apply any updates we missed. + async pollOnce() { + try { + const result = await Scratchpad.Storage.get('tabIndex'); + const tabIndex = Array.isArray(result.tabIndex) ? result.tabIndex : []; + const ids = tabIndex.map(t => t.id); + const contentKeys = ids.map(id => 'tab_' + id); + const contents = contentKeys.length ? await Scratchpad.Storage.get(contentKeys) : {}; + + let applied = false; + for (const id of ids) { + if (!Scratchpad.Tabs.hasTab(id)) continue; + const v = contents['tab_' + id]; + if (typeof v === 'string' && Scratchpad.Tabs.applyRemoteContent(id, v)) applied = true; + } + if (applied) Sync.onUpdate(); + // tabIndex diff applied in Task 10. + } catch (e) { + console.error('[Sync] poll failed:', e); + } + }, +}; + +window.Scratchpad.Sync = Sync; diff --git a/tabs.js b/tabs.js index 0a09f4b..684e4f7 100644 --- a/tabs.js +++ b/tabs.js @@ -228,6 +228,25 @@ const Tabs = { } }, + // Apply a remote content update for an existing tab. Skips if the tab is + // currently dirty (user is typing). Returns true if applied. + applyRemoteContent(id, newHtml) { + const s = Tabs.tabs.get(id); + if (!s) return false; + if (s.contentChanged) { + console.log(`[Tabs] ${id}: deferring remote update (user is typing)`); + return false; + } + const current = Scratchpad.Editor.getContent(s.editor); + if (current === newHtml) return false; + Scratchpad.Editor.setContent(s.editor, newHtml); + s.lastSavedContent = newHtml; + console.log(`[Tabs] ${id}: applied remote update (${newHtml.length} chars)`); + return true; + }, + + hasTab(id) { return Tabs.tabs.has(id); }, + // Remove tab DOM + state but do NOT touch storage. Used for rollback and remote-removal. _removeTabInternal(id) { const s = Tabs.tabs.get(id); From edd8ee1255bf702572e2256399d4f17847a346d3 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:31:44 -0400 Subject: [PATCH 15/25] fix: drop stale Sync.pollOnce guard and align lastSavedContent serialization --- scratchpad.js | 5 +---- tabs.js | 5 ++++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/scratchpad.js b/scratchpad.js index cf23fb1..67bd55d 100644 --- a/scratchpad.js +++ b/scratchpad.js @@ -81,10 +81,7 @@ linkBtn.addEventListener('click', (e) => { refreshBtn.addEventListener('click', async () => { refreshBtn.disabled = true; refreshBtn.textContent = '⟳ Refreshing...'; - // Sync.pollOnce will be added in Task 9; for now we just no-op. - if (window.Scratchpad.Sync && Scratchpad.Sync.pollOnce) { - await Scratchpad.Sync.pollOnce(); - } + await Scratchpad.Sync.pollOnce(); setTimeout(() => { refreshBtn.disabled = false; refreshBtn.textContent = '↻ Refresh'; diff --git a/tabs.js b/tabs.js index 684e4f7..563decd 100644 --- a/tabs.js +++ b/tabs.js @@ -240,7 +240,10 @@ const Tabs = { const current = Scratchpad.Editor.getContent(s.editor); if (current === newHtml) return false; Scratchpad.Editor.setContent(s.editor, newHtml); - s.lastSavedContent = newHtml; + // Use the post-sanitize serialized form so the next save's equality + // short-circuit doesn't trip on serializer-induced differences (e.g. + // XMLSerializer adding xmlns to elements). + s.lastSavedContent = Scratchpad.Editor.getContent(s.editor); console.log(`[Tabs] ${id}: applied remote update (${newHtml.length} chars)`); return true; }, From 2b705e33531ea9df4d52bf1c3a0f94c3ddd74922 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:33:47 -0400 Subject: [PATCH 16/25] feat: cross-device sync for tab list with deletion banner --- sync.js | 68 ++++++++++++++++++++++++++++++++--- tabs.js | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 4 deletions(-) diff --git a/sync.js b/sync.js index 3a97199..65b23bb 100644 --- a/sync.js +++ b/sync.js @@ -28,6 +28,8 @@ const Sync = { _handleChanges(changes) { let applied = false; + + // Process per-tab content first. for (const [key, change] of Object.entries(changes)) { if (key.startsWith('tab_')) { const id = key.slice(4); @@ -36,28 +38,86 @@ const Sync = { if (Scratchpad.Tabs.applyRemoteContent(id, newValue)) applied = true; } } - // tabIndex changes handled in Task 10. } + + // Then process tabIndex. + if (changes.tabIndex) { + Sync._applyIndex(changes.tabIndex.newValue, changes); + applied = true; + } + if (applied) Sync.onUpdate(); }, + _applyIndex(newIndex, contentChanges) { + const Tabs = Scratchpad.Tabs; + // Build a contentMap from the change payload for any added tabs whose content + // is in this same change event. + const contentMap = {}; + if (contentChanges) { + for (const [key, c] of Object.entries(contentChanges)) { + if (key.startsWith('tab_')) { + const id = key.slice(4); + if (typeof c.newValue === 'string') contentMap[id] = c.newValue; + } + } + } + + // Capture state about the active tab before applying changes (we may need it for the banner). + const activeId = Tabs.activeTabId; + const activeState = activeId ? Tabs.tabs.get(activeId) : null; + const activeName = activeState?.name; + const activeContent = activeState ? Scratchpad.Editor.getContent(activeState.editor) : ''; + + // Apply additions/removals/renames (the function leaves a removed-active tab in DOM for now). + const result = Tabs.applyRemoteIndex(newIndex, contentMap); + + // For added tabs whose content wasn't in the change payload, fetch from storage. + const missingContent = result.added.filter(meta => !(meta.id in contentMap)); + if (missingContent.length) { + const keys = missingContent.map(m => 'tab_' + m.id); + Scratchpad.Storage.get(keys).then(res => { + for (const meta of missingContent) { + const v = res['tab_' + meta.id]; + if (typeof v === 'string') Scratchpad.Tabs.applyRemoteContent(meta.id, v); + } + }).catch(e => console.error('[Sync] backfill content failed:', e)); + } + + // If the active tab was removed remotely, show the banner. + if (result.activeRemoved && activeId) { + // Switch to a neighboring tab first. + const neighbor = newIndex[0]?.id; + if (neighbor) Tabs.switchToTab(neighbor); + Tabs.showTabDeletedBanner(activeId, activeName, activeContent); + } + }, + // Read all relevant keys and apply any updates we missed. async pollOnce() { try { const result = await Scratchpad.Storage.get('tabIndex'); const tabIndex = Array.isArray(result.tabIndex) ? result.tabIndex : []; const ids = tabIndex.map(t => t.id); + + // First, apply tabIndex if it differs from local order. + const localIndex = Scratchpad.Tabs.tabOrder.map(id => { + const s = Scratchpad.Tabs.tabs.get(id); + return { id: s.id, name: s.name }; + }); + const indexChanged = JSON.stringify(localIndex) !== JSON.stringify(tabIndex); + if (indexChanged) Sync._applyIndex(tabIndex, null); + + // Then, refresh content for all known tabs. const contentKeys = ids.map(id => 'tab_' + id); const contents = contentKeys.length ? await Scratchpad.Storage.get(contentKeys) : {}; - let applied = false; for (const id of ids) { if (!Scratchpad.Tabs.hasTab(id)) continue; const v = contents['tab_' + id]; if (typeof v === 'string' && Scratchpad.Tabs.applyRemoteContent(id, v)) applied = true; } - if (applied) Sync.onUpdate(); - // tabIndex diff applied in Task 10. + if (applied || indexChanged) Sync.onUpdate(); } catch (e) { console.error('[Sync] poll failed:', e); } diff --git a/tabs.js b/tabs.js index 563decd..948ca04 100644 --- a/tabs.js +++ b/tabs.js @@ -250,6 +250,116 @@ const Tabs = { hasTab(id) { return Tabs.tabs.has(id); }, + // Apply a remote tabIndex change. `newIndex` is the array from storage. + // Returns { added: [...metas], removed: [...ids], renamed: [{id, oldName, newName}], activeRemoved: bool }. + applyRemoteIndex(newIndex, contentMap) { + if (!Array.isArray(newIndex)) return { added: [], removed: [], renamed: [], activeRemoved: false }; + + const remoteIds = new Set(newIndex.map(t => t.id)); + const localIds = new Set(Tabs.tabOrder); + + const added = newIndex.filter(t => !localIds.has(t.id)); + const removed = Tabs.tabOrder.filter(id => !remoteIds.has(id)); + const renamed = []; + for (const meta of newIndex) { + const local = Tabs.tabs.get(meta.id); + if (local && local.name !== meta.name) { + renamed.push({ id: meta.id, oldName: local.name, newName: meta.name }); + } + } + + // Apply renames first. + for (const r of renamed) { + const s = Tabs.tabs.get(r.id); + if (!s) continue; + if (s.labelEl.dataset.renaming === '1') continue; // user editing — skip + s.name = r.newName; + s.labelEl.textContent = r.newName; + } + + // Apply additions. + for (const meta of added) { + Tabs._addTabInternal(meta, contentMap[meta.id] || ''); + } + + // Apply removals — handler in caller decides what to do with active-tab removal. + // Here we only remove non-active tabs; the caller will handle the active one. + const activeRemoved = removed.includes(Tabs.activeTabId); + for (const id of removed) { + if (id === Tabs.activeTabId) continue; + Tabs._removeTabInternal(id); + } + + // Reorder DOM to match the new index (skipping any locally still-present + // active tab that was remotely removed). + Tabs.tabOrder = Tabs.tabOrder.filter(id => Tabs.tabs.has(id)); + // Place tabs in newIndex order (active-removed tab will be appended at end if still present). + const ordered = []; + for (const meta of newIndex) { + if (Tabs.tabs.has(meta.id)) ordered.push(meta.id); + } + if (activeRemoved && Tabs.tabs.has(Tabs.activeTabId) && !ordered.includes(Tabs.activeTabId)) { + ordered.push(Tabs.activeTabId); + } + Tabs.tabOrder = ordered; + Tabs._reorderDom(); + Tabs._refreshOnlyTabClass(); + + return { added, removed, renamed, activeRemoved }; + }, + + // Show a banner for an active tab that was deleted on another device. + showTabDeletedBanner(tabId, tabName, content) { + const existing = document.getElementById('tabDeletedBanner'); + if (existing) existing.remove(); + + const banner = document.createElement('div'); + banner.id = 'tabDeletedBanner'; + banner.className = 'tab-deleted-banner'; + + const msg = document.createElement('span'); + msg.textContent = `Tab "${tabName}" was deleted on another device.`; + + const saveBtn = document.createElement('button'); + saveBtn.textContent = 'Save as new tab'; + + const dismissBtn = document.createElement('button'); + dismissBtn.className = 'dismiss'; + dismissBtn.textContent = '×'; + dismissBtn.title = 'Dismiss'; + + banner.appendChild(msg); + banner.appendChild(saveBtn); + banner.appendChild(dismissBtn); + + // Insert before tab-bar. + const tabBar = document.getElementById('tabBar'); + tabBar.parentNode.insertBefore(banner, tabBar); + + saveBtn.addEventListener('click', async () => { + banner.remove(); + const newId = Scratchpad.Migration.newTabId(); + Tabs._addTabInternal({ id: newId, name: tabName }, content); + try { + await Tabs._persistIndexAndContent(newId, content); + Tabs.switchToTab(newId); + Tabs._refreshOnlyTabClass(); + } catch (e) { + console.error('[Tabs] save-as-new-tab failed:', e); + Tabs._removeTabInternal(newId); + Tabs.onStatus('error', 'Save as new tab failed'); + } + Tabs._removeTabInternal(tabId); + Tabs._refreshOnlyTabClass(); + }); + + dismissBtn.addEventListener('click', () => { + banner.remove(); + Tabs._removeTabInternal(tabId); + Tabs._refreshOnlyTabClass(); + }); + }, + // Remove tab DOM + state but do NOT touch storage. Used for rollback and remote-removal. _removeTabInternal(id) { const s = Tabs.tabs.get(id); From a3fc98b7f4aed8cd41d05b6a42ff5f93cecf536c Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:38:25 -0400 Subject: [PATCH 17/25] fix: drop deleted-tab placeholder before persisting save-as-new-tab --- tabs.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tabs.js b/tabs.js index 948ca04..9ee49ff 100644 --- a/tabs.js +++ b/tabs.js @@ -338,18 +338,21 @@ const Tabs = { saveBtn.addEventListener('click', async () => { banner.remove(); + // Drop the deleted-tab placeholder from local state BEFORE persisting, + // so the new tabIndex written to sync storage does not include the + // deleted id (which would otherwise resurrect on other devices). + Tabs._removeTabInternal(tabId); const newId = Scratchpad.Migration.newTabId(); Tabs._addTabInternal({ id: newId, name: tabName }, content); try { await Tabs._persistIndexAndContent(newId, content); Tabs.switchToTab(newId); - Tabs._refreshOnlyTabClass(); + Tabs.onUpdate(); } catch (e) { console.error('[Tabs] save-as-new-tab failed:', e); Tabs._removeTabInternal(newId); Tabs.onStatus('error', 'Save as new tab failed'); } - Tabs._removeTabInternal(tabId); Tabs._refreshOnlyTabClass(); }); From e0bbc5b7355c12beb1053ce4b3a6ff8136bcd968 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:40:12 -0400 Subject: [PATCH 18/25] feat: orphan recovery for concurrent tab creation --- scratchpad.js | 18 ++++++++++++++++++ sync.js | 41 +++++++++++++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/scratchpad.js b/scratchpad.js index 67bd55d..79deccf 100644 --- a/scratchpad.js +++ b/scratchpad.js @@ -116,6 +116,24 @@ window.addEventListener('beforeunload', () => { const local = await Scratchpad.Storage.Local.get('activeTabId'); Scratchpad.Tabs.hydrate(tabIndex, contentMap, local.activeTabId); + // One-shot orphan recovery on startup (cheap; helps if a previous session crashed mid-create). + const tabIndexNow = (await Scratchpad.Storage.get('tabIndex')).tabIndex || []; + const merged = await Scratchpad.Sync._recoverOrphans(tabIndexNow); + if (merged.length !== tabIndexNow.length) { + const newIds = merged.map(m => m.id).filter(id => !Scratchpad.Tabs.hasTab(id)); + if (newIds.length) { + const keys = newIds.map(id => 'tab_' + id); + const contents = await Scratchpad.Storage.get(keys); + for (const meta of merged) { + if (!Scratchpad.Tabs.hasTab(meta.id)) { + Scratchpad.Tabs._addTabInternal(meta, contents['tab_' + meta.id] || ''); + } + } + Scratchpad.Tabs._reorderDom(); + Scratchpad.Tabs._refreshOnlyTabClass(); + } + } + updateStatus('loaded'); updateLastUpdateTime(); } catch (e) { diff --git a/sync.js b/sync.js index 65b23bb..c56a16a 100644 --- a/sync.js +++ b/sync.js @@ -26,7 +26,33 @@ const Sync = { } }, - _handleChanges(changes) { + // Scan all sync storage for tab_ keys not present in `tabIndex`. If any + // are found, treat them as orphans: append metadata and write back. + async _recoverOrphans(tabIndex) { + const all = await Scratchpad.Storage.getAll(); + const knownIds = new Set(tabIndex.map(t => t.id)); + const orphanKeys = Object.keys(all).filter(k => k.startsWith('tab_') && !knownIds.has(k.slice(4))); + if (orphanKeys.length === 0) return tabIndex; + + console.warn(`[Sync] recovering ${orphanKeys.length} orphaned tab(s)`); + // Compute next "Tab N" name(s) starting from the largest used number. + const used = new Set(); + for (const t of tabIndex) { + const m = t.name.match(/^Tab (\d+)$/); + if (m) used.add(parseInt(m[1], 10)); + } + let n = 1; + const rescued = orphanKeys.map(k => { + while (used.has(n)) n++; + used.add(n); + return { id: k.slice(4), name: 'Tab ' + (n++) }; + }); + const merged = [...tabIndex, ...rescued]; + await Scratchpad.Storage.set({ tabIndex: merged }); + return merged; + }, + + async _handleChanges(changes) { let applied = false; // Process per-tab content first. @@ -42,14 +68,14 @@ const Sync = { // Then process tabIndex. if (changes.tabIndex) { - Sync._applyIndex(changes.tabIndex.newValue, changes); + await Sync._applyIndex(changes.tabIndex.newValue, changes); applied = true; } if (applied) Sync.onUpdate(); }, - _applyIndex(newIndex, contentChanges) { + async _applyIndex(newIndex, contentChanges) { const Tabs = Scratchpad.Tabs; // Build a contentMap from the change payload for any added tabs whose content // is in this same change event. @@ -69,8 +95,11 @@ const Sync = { const activeName = activeState?.name; const activeContent = activeState ? Scratchpad.Editor.getContent(activeState.editor) : ''; + // Orphan recovery: if storage has tab_ keys not in the new index, append them. + const merged = await Sync._recoverOrphans(newIndex); + // Apply additions/removals/renames (the function leaves a removed-active tab in DOM for now). - const result = Tabs.applyRemoteIndex(newIndex, contentMap); + const result = Tabs.applyRemoteIndex(merged, contentMap); // For added tabs whose content wasn't in the change payload, fetch from storage. const missingContent = result.added.filter(meta => !(meta.id in contentMap)); @@ -87,7 +116,7 @@ const Sync = { // If the active tab was removed remotely, show the banner. if (result.activeRemoved && activeId) { // Switch to a neighboring tab first. - const neighbor = newIndex[0]?.id; + const neighbor = merged[0]?.id; if (neighbor) Tabs.switchToTab(neighbor); Tabs.showTabDeletedBanner(activeId, activeName, activeContent); } @@ -106,7 +135,7 @@ const Sync = { return { id: s.id, name: s.name }; }); const indexChanged = JSON.stringify(localIndex) !== JSON.stringify(tabIndex); - if (indexChanged) Sync._applyIndex(tabIndex, null); + if (indexChanged) await Sync._applyIndex(tabIndex, null); // Then, refresh content for all known tabs. const contentKeys = ids.map(id => 'tab_' + id); From 62c9e30d6958460d4c6adcb09bd050efff4f8649 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:43:04 -0400 Subject: [PATCH 19/25] fix: catch async errors from Sync._handleChanges to avoid unhandled rejections --- sync.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sync.js b/sync.js index c56a16a..bf1a087 100644 --- a/sync.js +++ b/sync.js @@ -11,7 +11,9 @@ const Sync = { install({ onUpdate }) { Sync.onUpdate = onUpdate || (() => {}); - Scratchpad.Storage.onChanged((changes) => Sync._handleChanges(changes)); + Scratchpad.Storage.onChanged((changes) => { + Sync._handleChanges(changes).catch(e => console.error('[Sync] handleChanges failed:', e)); + }); }, startPolling() { From 2f8cee9399959c38e66f868c02443526f80834c8 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:45:14 -0400 Subject: [PATCH 20/25] =?UTF-8?q?feat:=20tab=20overflow=20with=20=E2=8B=AF?= =?UTF-8?q?=20dropdown=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tabs.js | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tabs.js b/tabs.js index 9ee49ff..96a083a 100644 --- a/tabs.js +++ b/tabs.js @@ -38,6 +38,8 @@ const Tabs = { Tabs.tabAddBtn.addEventListener('click', () => Tabs.createTab()); Tabs._installShortcuts(); + Tabs.tabBarEl = Tabs.tabListEl.parentElement; + Tabs._initOverflow(); }, // Build initial tabs from a tabIndex array and a contentMap of {id → htmlString}. @@ -103,6 +105,7 @@ const Tabs = { }; Tabs.tabs.set(meta.id, state); Tabs.tabOrder.push(meta.id); + Tabs._recomputeOverflow?.(); return state; }, @@ -177,6 +180,7 @@ const Tabs = { Scratchpad.Storage.Local.set({ activeTabId: id }).catch(e => console.error('[Tabs] persist activeTabId failed:', e)); next.editor.focus(); + Tabs._recomputeOverflow?.(); }, // Mark a tab as dirty and schedule a debounced save. @@ -371,6 +375,7 @@ const Tabs = { s.editor.remove(); Tabs.tabs.delete(id); Tabs.tabOrder = Tabs.tabOrder.filter(x => x !== id); + Tabs._recomputeOverflow?.(); }, _refreshOnlyTabClass() { @@ -380,6 +385,76 @@ const Tabs = { } }, + _initOverflow() { + if (Tabs._overflowObserver) return; + Tabs._overflowObserver = new ResizeObserver(() => Tabs._recomputeOverflow()); + Tabs._overflowObserver.observe(Tabs.tabListEl); + Tabs._overflowObserver.observe(Tabs.tabBarEl || Tabs.tabListEl.parentElement); + + Tabs.tabOverflowBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const open = !Tabs.tabOverflowMenuEl.hidden; + Tabs.tabOverflowMenuEl.hidden = open; + if (!open) Tabs._populateOverflowMenu(); + }); + + document.addEventListener('click', (e) => { + if (!Tabs.tabOverflowEl.contains(e.target)) { + Tabs.tabOverflowMenuEl.hidden = true; + } + }); + }, + + _recomputeOverflow() { + // Reveal all tabs first to measure natural sizes. + for (const s of Tabs.tabs.values()) { + s.tabEl.style.display = ''; + } + + const listRect = Tabs.tabListEl.getBoundingClientRect(); + const containerRight = listRect.right; + const overflowed = []; + for (const id of Tabs.tabOrder) { + const s = Tabs.tabs.get(id); + if (!s) continue; + const rect = s.tabEl.getBoundingClientRect(); + if (rect.right > containerRight + 0.5) { + // This tab is clipped — hide it (unless it's the active one; we always show that). + if (id === Tabs.activeTabId) continue; + s.tabEl.style.display = 'none'; + overflowed.push(id); + } + } + Tabs.tabOverflowEl.hidden = overflowed.length === 0; + Tabs._overflowedIds = overflowed; + if (!Tabs.tabOverflowMenuEl.hidden) Tabs._populateOverflowMenu(); + }, + + _populateOverflowMenu() { + Tabs.tabOverflowMenuEl.replaceChildren(); + const ids = Tabs._overflowedIds || []; + for (const id of ids) { + const s = Tabs.tabs.get(id); + if (!s) continue; + const item = document.createElement('div'); + item.className = 'menu-item' + (id === Tabs.activeTabId ? ' active' : ''); + item.textContent = s.name; + item.addEventListener('click', () => { + Tabs.tabOverflowMenuEl.hidden = true; + Tabs.switchToTab(id); + Tabs._recomputeOverflow(); + }); + Tabs.tabOverflowMenuEl.appendChild(item); + } + if (ids.length === 0) { + const empty = document.createElement('div'); + empty.className = 'menu-item'; + empty.style.color = '#999'; + empty.textContent = '(no overflowed tabs)'; + Tabs.tabOverflowMenuEl.appendChild(empty); + } + }, + _installShortcuts() { document.addEventListener('keydown', (e) => { // Ctrl+Alt+T → new tab @@ -508,6 +583,7 @@ const Tabs = { Tabs.tabListEl.appendChild(s.tabEl); Tabs.editorStackEl.appendChild(s.editor); } + Tabs._recomputeOverflow?.(); }, _beginRename(id) { @@ -548,6 +624,7 @@ const Tabs = { } else { s.labelEl.textContent = oldName; } + Tabs._recomputeOverflow?.(); }; input.addEventListener('keydown', (e) => { From 2658f9bc43cdcbc8a7809a6e4d35159d3ba1565a Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:47:48 -0400 Subject: [PATCH 21/25] perf: suppress per-tab overflow recompute during hydration --- tabs.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tabs.js b/tabs.js index 96a083a..70ce8bc 100644 --- a/tabs.js +++ b/tabs.js @@ -45,14 +45,19 @@ const Tabs = { // Build initial tabs from a tabIndex array and a contentMap of {id → htmlString}. // Activates the tab whose id matches `preferredActiveId` (or the first tab). hydrate(tabIndex, contentMap, preferredActiveId) { + // Suppress per-tab overflow recompute during the bulk add; ResizeObserver + // (and the trailing call below) handle the final layout in one pass. + Tabs._suspendOverflow = true; for (const meta of tabIndex) { Tabs._addTabInternal(meta, contentMap[meta.id] || ''); } + Tabs._suspendOverflow = false; const activeId = (preferredActiveId && Tabs.tabs.has(preferredActiveId)) ? preferredActiveId : tabIndex[0]?.id; if (activeId) Tabs.switchToTab(activeId); Tabs._refreshOnlyTabClass(); + Tabs._recomputeOverflow?.(); }, // Create the in-memory + DOM state for a tab and append it to the bar/stack. @@ -406,6 +411,7 @@ const Tabs = { }, _recomputeOverflow() { + if (Tabs._suspendOverflow) return; // Reveal all tabs first to measure natural sizes. for (const s of Tabs.tabs.values()) { s.tabEl.style.display = ''; From 0a256bcb35d1f99043080ac2a09cfd983c66cc5d Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:49:10 -0400 Subject: [PATCH 22/25] feat: per-tab fallback to local storage on sync quota error --- tabs.js | 51 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/tabs.js b/tabs.js index 70ce8bc..a9f101f 100644 --- a/tabs.js +++ b/tabs.js @@ -13,6 +13,7 @@ const Tabs = { tabOrder: [], // ordered list of tab ids activeTabId: null, recentlyClosed: null, // { meta: {id,name}, content, undoTimeoutId, removedIndex } | null + _localFallbackTabs: new Set(), // DOM refs (populated in init()) tabListEl: null, @@ -217,16 +218,38 @@ const Tabs = { return; } Tabs.onStatus('saving'); + + const writeKey = 'tab_' + id; + const useLocal = Tabs._localFallbackTabs.has(id); + const target = useLocal ? Scratchpad.Storage.Local : Scratchpad.Storage; + try { - await Scratchpad.Storage.set({ ['tab_' + id]: current }); + await target.set({ [writeKey]: current }); s.lastSavedContent = current; s.contentChanged = false; Tabs.onStatus('saved'); Tabs.onUpdate(); - console.log(`[Tabs] ${id}: saved ${current.length} chars`); + console.log(`[Tabs] ${id}: saved ${current.length} chars (${useLocal ? 'local fallback' : 'sync'})`); } catch (e) { - console.error(`[Tabs] save failed for ${id}:`, e); - Tabs.onStatus('error', `Save failed for "${s.name}"`); + if (Tabs._isQuotaError(e) && !useLocal) { + console.warn(`[Tabs] ${id}: quota exceeded; falling back to local storage`); + Tabs._localFallbackTabs.add(id); + Tabs._showQuotaBanner(); + // Retry once with local. + try { + await Scratchpad.Storage.Local.set({ [writeKey]: current }); + s.lastSavedContent = current; + s.contentChanged = false; + Tabs.onStatus('saved'); + Tabs.onUpdate(); + } catch (e2) { + console.error(`[Tabs] ${id}: local fallback also failed:`, e2); + Tabs.onStatus('error', `Save failed for "${s.name}"`); + } + } else { + console.error(`[Tabs] save failed for ${id}:`, e); + Tabs.onStatus('error', `Save failed for "${s.name}"`); + } } }, @@ -383,6 +406,26 @@ const Tabs = { Tabs._recomputeOverflow?.(); }, + _isQuotaError(e) { + const msg = (e && (e.message || String(e))).toLowerCase(); + return msg.includes('quota') || msg.includes('exceed'); + }, + + _showQuotaBanner() { + if (document.getElementById('quotaBanner')) return; + const banner = document.createElement('div'); + banner.id = 'quotaBanner'; + banner.className = 'tab-deleted-banner'; + banner.textContent = 'Sync storage full. New changes will save locally only.'; + const dismiss = document.createElement('button'); + dismiss.className = 'dismiss'; + dismiss.textContent = '×'; + dismiss.addEventListener('click', () => banner.remove()); + banner.appendChild(dismiss); + const tabBar = document.getElementById('tabBar'); + tabBar.parentNode.insertBefore(banner, tabBar); + }, + _refreshOnlyTabClass() { const onlyOne = Tabs.tabs.size === 1; for (const s of Tabs.tabs.values()) { From 3439b52ddc952855bf7459f7eacaf0a4c645c9df Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:52:21 -0400 Subject: [PATCH 23/25] fix: don't overwrite local-fallback content during sync poll/onChanged --- sync.js | 4 +++- tabs.js | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/sync.js b/sync.js index bf1a087..3c827c1 100644 --- a/sync.js +++ b/sync.js @@ -62,7 +62,7 @@ const Sync = { if (key.startsWith('tab_')) { const id = key.slice(4); const newValue = change.newValue; - if (typeof newValue === 'string' && Scratchpad.Tabs.hasTab(id)) { + if (typeof newValue === 'string' && Scratchpad.Tabs.hasTab(id) && !Scratchpad.Tabs.isLocalFallback(id)) { if (Scratchpad.Tabs.applyRemoteContent(id, newValue)) applied = true; } } @@ -109,6 +109,7 @@ const Sync = { const keys = missingContent.map(m => 'tab_' + m.id); Scratchpad.Storage.get(keys).then(res => { for (const meta of missingContent) { + if (Scratchpad.Tabs.isLocalFallback(meta.id)) continue; const v = res['tab_' + meta.id]; if (typeof v === 'string') Scratchpad.Tabs.applyRemoteContent(meta.id, v); } @@ -145,6 +146,7 @@ const Sync = { let applied = false; for (const id of ids) { if (!Scratchpad.Tabs.hasTab(id)) continue; + if (Scratchpad.Tabs.isLocalFallback(id)) continue; // Don't clobber local-fallback content with stale sync data. const v = contents['tab_' + id]; if (typeof v === 'string' && Scratchpad.Tabs.applyRemoteContent(id, v)) applied = true; } diff --git a/tabs.js b/tabs.js index a9f101f..a0a6549 100644 --- a/tabs.js +++ b/tabs.js @@ -281,6 +281,7 @@ const Tabs = { }, hasTab(id) { return Tabs.tabs.has(id); }, + isLocalFallback(id) { return Tabs._localFallbackTabs.has(id); }, // Apply a remote tabIndex change. `newIndex` is the array from storage. // Returns { added: [...metas], removed: [...ids], renamed: [{id, oldName, newName}], activeRemoved: bool }. From 6a7f424b95844176c739760384e8988fb30e25f7 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:52:50 -0400 Subject: [PATCH 24/25] chore: bump version to 2.0 for multi-tab release --- manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index 853b08e..221031b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 2, "name": "Scratchpad", - "version": "1.9", - "description": "A synced scratchpad with rich text formatting and hyperlinks that syncs across your Firefox devices", + "version": "2.0", + "description": "A synced multi-tab scratchpad with rich text formatting and hyperlinks that syncs across your Firefox devices", "browser_specific_settings": { "gecko": { From 59bda47139e16c8833fe9a8c9f6626d347bec058 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 3 May 2026 13:59:00 -0400 Subject: [PATCH 25/25] fix: prevent closed-tab resurrection, ignore shortcuts during rename, refresh README --- README.md | 35 ++++++++++++++++++++++------------- tabs.js | 15 +++++++++++++-- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a754201..d9310ca 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # Scratchpad - Firefox Extension -A simple, synced scratchpad extension for Firefox that lets you quickly jot down notes that automatically sync across all your Firefox devices. +A synced multi-tab scratchpad extension for Firefox. Each tab maintains its own content, with rich text formatting and hyperlinks, and syncs across all your Firefox devices. ## Features -- **Clean Interface**: Full-screen textarea for distraction-free note-taking -- **Auto-Save**: Your notes are automatically saved as you type -- **Cross-Device Sync**: Notes sync across all your Firefox devices using Firefox Sync -- **Simple & Fast**: No login required, no configuration needed - just install and start typing +- **Multiple Tabs**: Organize separate notes in tabs; double-click to rename, click × to close (with undo) +- **Rich Text**: Bold, italic, underline, bullet/numbered lists, and hyperlinks +- **Auto-Save**: Each tab saves automatically as you type +- **Cross-Device Sync**: Tabs and content sync across all your Firefox devices using Firefox Sync +- **Keyboard Shortcuts**: `Ctrl+Alt+T` new tab, `Ctrl+Alt+W` close tab, `Ctrl+Tab` / `Ctrl+Shift+Tab` cycle tabs, `Ctrl+B/I/U/K` formatting ## Installation @@ -50,18 +51,26 @@ The extension uses Firefox's `storage.sync` API, which: ## Technical Details - **Manifest Version**: 2 (Firefox standard) +- **Extension Version**: 2.0 (multi-tab) - **Permissions**: `storage` (for sync functionality) -- **Storage API**: `browser.storage.sync` -- **Auto-save Delay**: 500ms after typing stops +- **Storage**: `browser.storage.sync` for tab content; falls back to `browser.storage.local` per-tab when sync quota is exceeded +- **Storage Layout**: `tabIndex` lists all tabs; each tab's content lives in its own `tab_` key +- **Auto-save Delay**: 500ms debounce after typing stops; 5s periodic flush; 10s sync poll +- **Active Tab**: Persisted per-device in `browser.storage.local` (each device remembers its own focus) ## Files -- `manifest.json` - Extension configuration -- `background.js` - Handles opening scratchpad tabs -- `scratchpad.html` - Main scratchpad interface -- `scratchpad.css` - Styling -- `scratchpad.js` - Save/load functionality with sync -- `icons/` - Extension icons +- `manifest.json` — Extension configuration +- `background.js` — Handles opening scratchpad tabs +- `scratchpad.html` — Main interface (tab bar, toolbar, editor stack) +- `scratchpad.css` — Styling +- `scratchpad.js` — Lifecycle and wiring +- `storage.js` — Storage area initialization (sync with local fallback) +- `editor.js` — HTML/URL sanitization, formatting commands, link creation +- `migration.js` — One-time migration from the legacy single-key schema +- `tabs.js` — Tab CRUD, switching, undo, rename, overflow, document-level shortcuts +- `sync.js` — Cross-device sync via storage.onChanged + 10s poll, with orphan recovery +- `icons/` — Extension icons ## Privacy diff --git a/tabs.js b/tabs.js index a0a6549..7c7738e 100644 --- a/tabs.js +++ b/tabs.js @@ -200,7 +200,7 @@ const Tabs = { _saveTab(id) { const s = Tabs.tabs.get(id); - if (!s) return Promise.resolve(); + if (!s || s.disposed) return Promise.resolve(); s.saveTimeout = null; // Serialize per-tab saves: chain onto any in-flight save so writes for the // same tab apply in call order. Without this, two concurrent Storage.set @@ -211,7 +211,7 @@ const Tabs = { async _saveTabNow(id) { const s = Tabs.tabs.get(id); - if (!s) return; + if (!s || s.disposed) return; const current = Scratchpad.Editor.getContent(s.editor); if (current === s.lastSavedContent) { console.log(`[Tabs] ${id}: unchanged, skipping save`); @@ -400,10 +400,15 @@ const Tabs = { _removeTabInternal(id) { const s = Tabs.tabs.get(id); if (!s) return; + // Mark disposed and cancel any pending save so a debounced timer or + // chained save can't write to tab_ after we remove it from storage. + s.disposed = true; + if (s.saveTimeout) { clearTimeout(s.saveTimeout); s.saveTimeout = null; } s.tabEl.remove(); s.editor.remove(); Tabs.tabs.delete(id); Tabs.tabOrder = Tabs.tabOrder.filter(x => x !== id); + Tabs._localFallbackTabs.delete(id); Tabs._recomputeOverflow?.(); }, @@ -507,6 +512,12 @@ const Tabs = { _installShortcuts() { document.addEventListener('keydown', (e) => { + // Skip our shortcuts while the user is typing in the rename input — + // otherwise Ctrl+Alt+W would close the tab being renamed and Ctrl+Tab + // would silently abandon the rename. + if (e.target && e.target.classList && e.target.classList.contains('tab-rename-input')) { + return; + } // Ctrl+Alt+T → new tab if (e.ctrlKey && e.altKey && (e.key === 't' || e.key === 'T')) { e.preventDefault();