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/docs/superpowers/plans/2026-05-03-tabs-implementation.md b/docs/superpowers/plans/2026-05-03-tabs-implementation.md new file mode 100644 index 0000000..b29bc60 --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-tabs-implementation.md @@ -0,0 +1,2419 @@ +# Multi-Tab Scratchpad Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add multi-tab support to the Scratchpad Firefox extension so that each tab maintains its own content, sync, and undo state — preserving the existing single-scratchpad behavior as the first tab on upgrade. + +**Architecture:** Refactor the current monolithic `scratchpad.js` into focused modules (`storage.js`, `editor.js`, `migration.js`, `tabs.js`, `sync.js`) 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. 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 ` + + + + diff --git a/scratchpad.js b/scratchpad.js index a086b5f..79deccf 100644 --- a/scratchpad.js +++ b/scratchpad.js @@ -1,241 +1,46 @@ -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 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 +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'); -// 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'); - } -} +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}`; } -// 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 { - if (!storageArea) { - await initStorage(); - } - - const result = await storageArea.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); - } -} - -// 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 - }); - lastSavedContent = currentContent; - contentChanged = false; - updateStatus('saved'); - updateLastUpdateTime(); - console.log(`Saved ${currentContent.length} characters to ${usingSyncStorage ? '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...'; 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 setTimeout(() => { - if (statusDiv.textContent === storageType) { - updateStatus('ready'); - } + if (statusDiv.textContent === storageType) updateStatus('ready'); }, 2000); break; case 'loaded': - const readyText = usingSyncStorage ? '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'; @@ -245,226 +50,99 @@ function updateStatus(status, message = '') { } } -// Debounced save function - saves after user stops typing -function debouncedSave() { - contentChanged = true; - clearTimeout(saveTimeout); - saveTimeout = setTimeout(() => { - saveContent(); - }, 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.'); - } - } +// Toolbar buttons act on the active editor. +function activeEditor() { + const id = Scratchpad.Tabs.activeTabId; + return id ? Scratchpad.Tabs.tabs.get(id)?.editor : null; } -// 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 ed = activeEditor(); + if (!ed) return; const command = btn.dataset.command; if (command) { - executeCommand(command); + Scratchpad.Editor.executeCommand(ed, command); + Scratchpad.Tabs._markDirty(Scratchpad.Tabs.activeTabId); } }); }); -// Handle link button click linkBtn.addEventListener('click', (e) => { e.preventDefault(); - createLink(); + 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(); - + await Scratchpad.Sync.pollOnce(); setTimeout(() => { refreshBtn.disabled = false; refreshBtn.textContent = '↻ Refresh'; }, 500); }); -// 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) { - 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); - 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 && getContent() !== lastSavedContent) { - saveContent(); - } - }, AUTO_SAVE_INTERVAL); -} - -// Periodic sync check - checks for updates from other devices every 10 seconds -async function checkForSyncUpdates() { +(async () => { try { - if (!storageArea) { - console.log('Storage not initialized yet'); - return; - } - - const result = await storageArea.get('scratchpadContent'); - const storedContent = result.scratchpadContent || ''; - const currentContent = getContent(); - - 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}`); + await Scratchpad.Storage.init(); + await Scratchpad.Migration.runIfNeeded(); + + Scratchpad.Tabs.init({ + tabListEl, editorStackEl, tabAddBtn, + tabOverflowEl, tabOverflowBtn, tabOverflowMenuEl, + onStatus: updateStatus, + onUpdate: updateLastUpdateTime, + }); - if (!userIsTyping) { - // Safe to update - user isn't actively typing - setContent(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'); + 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); + + // 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(); } - } 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); - } -} - -function startSyncCheck() { - syncCheckInterval = setInterval(() => { - checkForSyncUpdates(); - }, SYNC_CHECK_INTERVAL); -} -// 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 && getContent() !== lastSavedContent) { - saveContent(); + updateStatus('loaded'); + updateLastUpdateTime(); + } catch (e) { + console.error('[Lifecycle] startup failed:', e); + updateStatus('error', e.message); } -}); - -// Load content when page opens -loadContent(); -// Start the periodic auto-save -startAutoSave(); + autoSaveInterval = setInterval(() => Scratchpad.Tabs.flushDirty(), AUTO_SAVE_INTERVAL); -// Start periodic sync checking -startSyncCheck(); + Scratchpad.Sync.install({ onUpdate: updateLastUpdateTime }); + Scratchpad.Sync.startPolling(); +})(); 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; diff --git a/sync.js b/sync.js new file mode 100644 index 0000000..3c827c1 --- /dev/null +++ b/sync.js @@ -0,0 +1,160 @@ +// 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).catch(e => console.error('[Sync] handleChanges failed:', e)); + }); + }, + + startPolling() { + if (Sync.pollIntervalId) return; + Sync.pollIntervalId = setInterval(() => Sync.pollOnce(), SYNC_POLL_MS); + }, + + stopPolling() { + if (Sync.pollIntervalId) { + clearInterval(Sync.pollIntervalId); + Sync.pollIntervalId = null; + } + }, + + // 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. + 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) && !Scratchpad.Tabs.isLocalFallback(id)) { + if (Scratchpad.Tabs.applyRemoteContent(id, newValue)) applied = true; + } + } + } + + // Then process tabIndex. + if (changes.tabIndex) { + await Sync._applyIndex(changes.tabIndex.newValue, changes); + applied = true; + } + + if (applied) Sync.onUpdate(); + }, + + 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. + 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) : ''; + + // 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(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)); + if (missingContent.length) { + 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); + } + }).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 = merged[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) await 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; + 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; + } + if (applied || indexChanged) Sync.onUpdate(); + } catch (e) { + console.error('[Sync] poll failed:', e); + } + }, +}; + +window.Scratchpad.Sync = Sync; diff --git a/tabs.js b/tabs.js new file mode 100644 index 0000000..7c7738e --- /dev/null +++ b/tabs.js @@ -0,0 +1,699 @@ +// 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, tabEl, labelEl, lastSavedContent, contentChanged, saveTimeout, saveChain } + tabs: new Map(), + 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, + 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()); + Tabs._installShortcuts(); + Tabs.tabBarEl = Tabs.tabListEl.parentElement; + Tabs._initOverflow(); + }, + + // 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. + // 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); + Tabs._recomputeOverflow?.(); + 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; + + // 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) { + 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(); + Tabs._recomputeOverflow?.(); + }, + + // 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); + }, + + _saveTab(id) { + const s = Tabs.tabs.get(id); + 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 + // 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 || s.disposed) return; + 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}"`); + } + } + }, + + // Auto-save tick: persist any dirty tabs. + flushDirty() { + for (const s of Tabs.tabs.values()) { + if (s.contentChanged) Tabs._saveTab(s.id); + } + }, + + // 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); + // 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; + }, + + 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 }. + 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(); + // 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.onUpdate(); + } catch (e) { + console.error('[Tabs] save-as-new-tab failed:', e); + Tabs._removeTabInternal(newId); + Tabs.onStatus('error', 'Save as new tab failed'); + } + 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); + 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?.(); + }, + + _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()) { + s.tabEl.classList.toggle('only-tab', onlyOne); + } + }, + + _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() { + if (Tabs._suspendOverflow) return; + // 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) => { + // 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(); + 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) { + 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); + } + Tabs._recomputeOverflow?.(); + }, + + _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; + } + Tabs._recomputeOverflow?.(); + }; + + 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;