From 4365b7687becabf42158a9221a1af7961bf7f489 Mon Sep 17 00:00:00 2001 From: Fabio Montefuscolo Date: Mon, 3 Aug 2026 19:50:30 +0200 Subject: [PATCH] feat: implement Side Notes Chrome extension - Manifest V3 with sidePanel, tabs, webNavigation, unlimitedStorage permissions - Background service worker: sidePanel setup + SPA navigation detection - Shared modules: URL key derivation (buildNoteKey) and IndexedDB storage - Side panel: per-tab markdown editor with debounced autosave, in-panel find - Cross-panel consistency via BroadcastChannel with focus-aware refresh - Notes-list page: day-grouped entries, click-to-focus/navigate behavior - Dark theme by default --- background.js | 38 ++++ icons/icon-128.png | Bin 0 -> 381 bytes icons/icon-16.png | Bin 0 -> 92 bytes icons/icon-48.png | Bin 0 -> 159 bytes manifest.json | 35 ++++ notes-list/notes-list.css | 132 +++++++++++++ notes-list/notes-list.html | 21 ++ notes-list/notes-list.js | 216 +++++++++++++++++++++ shared/key-builder.js | 43 +++++ shared/storage.js | 177 +++++++++++++++++ sidepanel/sidepanel.css | 169 +++++++++++++++++ sidepanel/sidepanel.html | 32 ++++ sidepanel/sidepanel.js | 379 +++++++++++++++++++++++++++++++++++++ 13 files changed, 1242 insertions(+) create mode 100644 background.js create mode 100644 icons/icon-128.png create mode 100644 icons/icon-16.png create mode 100644 icons/icon-48.png create mode 100644 manifest.json create mode 100644 notes-list/notes-list.css create mode 100644 notes-list/notes-list.html create mode 100644 notes-list/notes-list.js create mode 100644 shared/key-builder.js create mode 100644 shared/storage.js create mode 100644 sidepanel/sidepanel.css create mode 100644 sidepanel/sidepanel.html create mode 100644 sidepanel/sidepanel.js diff --git a/background.js b/background.js new file mode 100644 index 0000000..b5038bc --- /dev/null +++ b/background.js @@ -0,0 +1,38 @@ +// Side Notes — Background Service Worker +// +// Responsibilities: +// - Set up side panel behavior on install. +// - Detect in-page navigation (SPA pushState/replaceState, hash changes) +// and notify any open side panel for the affected tab to reload its note. + +import { buildNoteKey } from './shared/key-builder.js'; + +// --------------------------------------------------------------------------- +// Install +// --------------------------------------------------------------------------- +chrome.runtime.onInstalled.addListener(() => { + chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); +}); + +// --------------------------------------------------------------------------- +// In-page navigation detection (SPA support) +// --------------------------------------------------------------------------- +const bc = new BroadcastChannel('side-notes'); + +function notifyTab(tabId, url) { + const key = buildNoteKey(url); + bc.postMessage({ type: 'navigation', tabId, key }); +} + +chrome.webNavigation.onHistoryStateUpdated.addListener((details) => { + // details.frameId === 0 => main frame only (ignore iframes) + if (details.frameId === 0) { + notifyTab(details.tabId, details.url); + } +}); + +chrome.webNavigation.onReferenceFragmentUpdated.addListener((details) => { + if (details.frameId === 0) { + notifyTab(details.tabId, details.url); + } +}); diff --git a/icons/icon-128.png b/icons/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..d4a79739b55072957aca3bc635f795d375cad4c4 GIT binary patch literal 381 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV07_xaSW-L^Y*$UFH@iZ>%q__ zt0xLPf*l7B3QhX?@^Hv=&KXPmifyNqU1iVVcaUkYU?^mG#BhYMgIR!Gfe%^WxAcRb zzi#|xV7Ot+z);4|&~TTTA>liA?t7gB(fgTDO_mIO4NCPTo@O1TaS?83{1OT+c7$N`w literal 0 HcmV?d00001 diff --git a/icons/icon-48.png b/icons/icon-48.png new file mode 100644 index 0000000000000000000000000000000000000000..6138e1d0a5d13dd11137f1be2888e3472a53e130 GIT binary patch literal 159 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtG*1`Dkcv5PFFEoy7>KZ3)LLov zL}87Rz+qXBXAYPC+Rr@6RnacHIYODYLajr{rM98(^DRrqCKk@Bt>4sM*lWyISarCa uMG%!f)wqds5ldi`hT{|kFM&|L66Vx5jM8^>WHf*_GI+ZBxvX + + + + + Side Notes — All Notes + + + +
+

Side Notes

+

All notes

+
+ +
+

No notes yet. Open a page and use the side panel to write one.

+
+ + + + diff --git a/notes-list/notes-list.js b/notes-list/notes-list.js new file mode 100644 index 0000000..7867d53 --- /dev/null +++ b/notes-list/notes-list.js @@ -0,0 +1,216 @@ +// Side Notes — Notes List Page +// +// A dedicated extension page that lists all notes grouped by day and sorted +// by recency. Clicking an entry either focuses an already-open tab (if one +// exists for that note's URL key) or navigates the current tab to it. +// The side panel is then opened on the target tab. + +import { buildNoteKey } from '../shared/key-builder.js'; +import { listNotes } from '../shared/storage.js'; + +// --------------------------------------------------------------------------- +// DOM +// --------------------------------------------------------------------------- +const listEl = document.getElementById('note-list'); +const emptyMsg = document.getElementById('empty-message'); + +// --------------------------------------------------------------------------- +// Group notes by day +// --------------------------------------------------------------------------- + +/** + * Return a human-readable day label for a date. + * Today → "Today", Yesterday → "Yesterday", otherwise the locale date. + */ +function dayLabel(date) { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const target = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const diffDays = Math.round((today - target) / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return 'Yesterday'; + return target.toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); +} + +/** + * Format a time string from an ISO timestamp. + */ +function formatTime(isoString) { + const d = new Date(isoString); + return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); +} + +function groupByDay(notes) { + const groups = new Map(); + for (const note of notes) { + const d = new Date(note.updatedAt); + const key = dayLabel(d); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(note); + } + return groups; +} + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- +async function render() { + const notes = await listNotes(); + + if (notes.length === 0) { + listEl.innerHTML = ''; + listEl.appendChild(emptyMsg); + return; + } + + emptyMsg.remove(); + listEl.innerHTML = ''; + + const groups = groupByDay(notes); + for (const [label, groupNotes] of groups) { + const section = document.createElement('div'); + section.className = 'day-section'; + + const labelEl = document.createElement('div'); + labelEl.className = 'day-label'; + labelEl.textContent = label; + section.appendChild(labelEl); + + for (const note of groupNotes) { + section.appendChild(buildEntry(note)); + } + + listEl.appendChild(section); + } +} + +function buildEntry(note) { + const entry = document.createElement('div'); + entry.className = 'note-entry'; + entry.dataset.noteId = note.id; + entry.dataset.key = note.key; + entry.dataset.url = note.url || note.key; // fallback: reconstruct from key + + // Favicon + const img = document.createElement('img'); + img.src = note.favIconUrl || ''; + img.alt = ''; + img.width = 16; + img.height = 16; + img.onerror = () => { img.style.visibility = 'hidden'; }; + entry.appendChild(img); + + // Text block + const textBlock = document.createElement('div'); + textBlock.className = 'entry-text'; + + const title = document.createElement('div'); + title.className = 'entry-title'; + title.textContent = note.pageTitle || note.key || 'Untitled'; + + const url = document.createElement('div'); + url.className = 'entry-url'; + url.textContent = note.key || ''; + + textBlock.appendChild(title); + textBlock.appendChild(url); + entry.appendChild(textBlock); + + // Time + const time = document.createElement('div'); + time.className = 'entry-time'; + time.textContent = formatTime(note.updatedAt); + entry.appendChild(time); + + // Click handler — see handleEntryClick below + entry.addEventListener('click', () => handleEntryClick(note)); + + return entry; +} + +// Our own tab ID — resolved at init time so we can call sidePanel.open() +// synchronously in the click handler (required for user-gesture propagation). +let ownTabId = null; + +// --------------------------------------------------------------------------- +// Click handling — focus existing tab or navigate current +// --------------------------------------------------------------------------- + +/** + * Handle a click on a notes-list entry. + * + * IMPORTANT: `chrome.sidePanel.open()` requires a live user gesture and MUST + * be called synchronously (no preceding await) in the click handler. We call + * it first on our own tab as a fallback, then do the async matching work. If + * a matching tab is found we call sidePanel.open() again on the matched tab. + */ +async function handleEntryClick(note) { + const noteKey = note.key; + + // Step 1 (SYNCHRONOUS): open the side panel on our own tab to satisfy the + // user-gesture requirement. This will be superseded if a matching tab is + // found below, or if we navigate away. + if (ownTabId != null) { + chrome.sidePanel.open({ tabId: ownTabId }); + } + + // Step 2: query all tabs and check for a match. + let allTabs; + try { + allTabs = await chrome.tabs.query({}); + } catch { + return; + } + + let matchTab = null; + for (const tab of allTabs) { + if (!tab.url) continue; + try { + const tabKey = buildNoteKey(tab.url); + if (tabKey === noteKey) { + matchTab = tab; + break; + } + } catch { + // URL parsing may fail for special URLs (chrome://, etc.) — skip. + } + } + + if (matchTab) { + // A tab is already open at this note's URL → focus it and open its panel. + chrome.windows.update(matchTab.windowId, { focused: true }); + chrome.tabs.update(matchTab.id, { active: true }); + chrome.sidePanel.open({ tabId: matchTab.id }); + } else if (ownTabId != null) { + // No match → navigate our own tab to the note's URL. + // The key is the normalized URL (scheme://host/path?search), sufficient + // for navigation even though the hash was stripped. + chrome.tabs.update(ownTabId, { url: noteKey }); + chrome.sidePanel.open({ tabId: ownTabId }); + } else { + // Fallback (shouldn't normally happen): create a new tab. + try { + const tab = await chrome.tabs.create({ url: noteKey }); + chrome.sidePanel.open({ tabId: tab.id }); + } catch { /* last resort — do nothing */ } + } +} + +// --------------------------------------------------------------------------- +// Init +// --------------------------------------------------------------------------- +(async function init() { + // Resolve our own tab ID so click handlers can use it synchronously. + try { + const tab = await chrome.tabs.getCurrent(); + if (tab) ownTabId = tab.id; + } catch { /* not opened as a tab — ownTabId stays null */ } + + render(); +})(); diff --git a/shared/key-builder.js b/shared/key-builder.js new file mode 100644 index 0000000..a09e232 --- /dev/null +++ b/shared/key-builder.js @@ -0,0 +1,43 @@ +// Shared URL-key derivation for note lookups. +// Used by the side panel (load/save), the notes-list click handler +// (tab matching), and background navigation detection. + +const DEFAULT_CONFIG = { + // Default: scheme://domain/path?search, hash excluded. + buildKey(url) { + try { + const u = new URL(url); + return `${u.protocol}//${u.host}${u.pathname}${u.search}`; + } catch { + // If URL parsing fails, use the raw string as-is. + return url; + } + }, +}; + +let _config = { ...DEFAULT_CONFIG }; + +/** + * Derive a storage key from a page URL. + * @param {string} url + * @param {object} [config] — optional override (for testing / future UI changes) + * @returns {string} + */ +export function buildNoteKey(url, config = _config) { + return config.buildKey(url); +} + +/** + * Swap the key-derivation rule at runtime. + * @param {object} newConfig — must expose a `buildKey(url)` function + */ +export function setKeyConfig(newConfig) { + _config = { ...newConfig }; +} + +/** + * Return the current config (read-only copy). + */ +export function getKeyConfig() { + return { ..._config }; +} diff --git a/shared/storage.js b/shared/storage.js new file mode 100644 index 0000000..55e921c --- /dev/null +++ b/shared/storage.js @@ -0,0 +1,177 @@ +// IndexedDB storage layer for Side Notes. +// +// Schema: +// Object store: "notes" +// Key path: id (auto-generated) +// Indexes: "byKey" (non-unique, on derived URL key) +// "byUpdated" (on updatedAt) +// +// Retrieval always filters `deletedAt == null`, so at most one live note +// per URL key is visible even if soft-deleted records coexist. + +const DB_NAME = 'side-notes'; +const DB_VERSION = 1; +const STORE_NAME = 'notes'; + +/** @returns {Promise} */ +function openDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { + keyPath: 'id', + autoIncrement: true, + }); + store.createIndex('byKey', 'key', { unique: false }); + store.createIndex('byUpdated', 'updatedAt'); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +/** + * Generate a unique id. crypto.randomUUID is available in service workers + * and extension pages in Chromium-based browsers. + */ +function generateId() { + return crypto.randomUUID(); +} + +/** + * Retrieve the live (non-deleted) note for a given URL key. + * @param {string} key + * @returns {Promise} + */ +export async function getNoteByKey(key) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const index = tx.objectStore(STORE_NAME).index('byKey'); + const req = index.openCursor(IDBKeyRange.only(key), 'prev'); + req.onsuccess = () => { + const cursor = req.result; + if (!cursor) return resolve(null); + // Skip soft-deleted records; return the first live one (most recent + // because we iterate in reverse insertion order via 'prev'). + if (cursor.value.deletedAt == null) { + resolve(cursor.value); + } else { + cursor.continue(); + } + }; + req.onerror = () => reject(req.error); + }); +} + +/** + * Persist (insert or update) a note for a given key. + * + * The save strategy: look up the existing live note for this key; if one + * exists, overwrite it in-place. Otherwise insert a new record. + * + * @param {string} key + * @param {string} content — markdown body + * @param {{ pageTitle?: string, favIconUrl?: string }} pageMeta + * @returns {Promise} the saved record + */ +export async function saveNote(key, content, pageMeta = {}) { + const db = await openDB(); + const now = new Date().toISOString(); + + // Find the existing live record for this key. + const existing = await getNoteByKey(key); + + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + + let req; + if (existing) { + // Update in-place. + const updated = { + ...existing, + key, + content, + pageTitle: pageMeta.pageTitle ?? existing.pageTitle ?? '', + favIconUrl: pageMeta.favIconUrl ?? existing.favIconUrl ?? '', + updatedAt: now, + }; + req = store.put(updated); + } else { + // Insert new. + const record = { + id: generateId(), + key, + content, + pageTitle: pageMeta.pageTitle ?? '', + favIconUrl: pageMeta.favIconUrl ?? '', + createdAt: now, + updatedAt: now, + deletedAt: null, + }; + req = store.add(record); + } + + req.onsuccess = () => { + // Return the saved record. + const getReq = store.get(req.result); + getReq.onsuccess = () => resolve(getReq.result); + getReq.onerror = () => reject(getReq.error); + }; + req.onerror = () => reject(req.error); + }); +} + +/** + * List all live notes ordered by updatedAt descending. + * @returns {Promise} + */ +export async function listNotes() { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const index = tx.objectStore(STORE_NAME).index('byUpdated'); + const req = index.openCursor(null, 'prev'); + const results = []; + req.onsuccess = () => { + const cursor = req.result; + if (cursor) { + if (cursor.value.deletedAt == null) { + results.push(cursor.value); + } + cursor.continue(); + } else { + resolve(results); + } + }; + req.onerror = () => reject(req.error); + }); +} + +/** + * Soft-delete a note by id (sets deletedAt). No-op if already deleted. + * @param {string|number} id + * @returns {Promise} + */ +export async function markDeleted(id) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const getReq = store.get(id); + getReq.onsuccess = () => { + const record = getReq.result; + if (!record || record.deletedAt != null) { + resolve(); + return; + } + record.deletedAt = new Date().toISOString(); + store.put(record).onsuccess = () => resolve(); + }; + getReq.onerror = () => reject(getReq.error); + }); +} diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css new file mode 100644 index 0000000..3a0da46 --- /dev/null +++ b/sidepanel/sidepanel.css @@ -0,0 +1,169 @@ +/* Side Notes — Side Panel Styles (Dark Theme) */ + +:root { + --bg: #1e1e1e; + --bg-header: #252525; + --bg-find: #2a2a2a; + --text: #d4d4d4; + --text-muted: #888; + --border: #3c3c3c; + --accent: #569cd6; + --accent-dim: #3a6a9e; + --status-bg: #333; + --focus-ring: #569cd6; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 14px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ---- Header ---- */ +#header { + padding: 8px 12px; + background: var(--bg-header); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + min-height: 36px; + flex-shrink: 0; +} + +#page-info { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1; +} + +#favicon { + flex-shrink: 0; + width: 16px; + height: 16px; +} + +#page-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 13px; + color: var(--text); +} + +/* ---- Editor ---- */ +#editor { + flex: 1; + background: var(--bg); + color: var(--text); + border: none; + padding: 12px; + font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; + font-size: 13px; + line-height: 1.6; + resize: none; + outline: none; + tab-size: 2; +} + +#editor::placeholder { + color: var(--text-muted); +} + +#editor:focus { + box-shadow: inset 0 0 0 1px var(--focus-ring); +} + +/* ---- Find Bar ---- */ +#find-bar { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + background: var(--bg-find); + border-top: 1px solid var(--border); + flex-shrink: 0; +} + +#find-bar[hidden] { + display: none; +} + +#find-input { + flex: 1; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 8px; + font-size: 13px; + outline: none; +} + +#find-input:focus { + border-color: var(--accent); +} + +#find-count { + font-size: 12px; + color: var(--text-muted); + min-width: 40px; + text-align: center; +} + +#find-bar button { + background: none; + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + padding: 4px 8px; + cursor: pointer; + font-size: 12px; + line-height: 1; +} + +#find-bar button:hover { + background: var(--border); +} + +#find-close { + font-size: 14px; +} + +#open-list { + background: none; + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + padding: 2px 8px; + cursor: pointer; + font-size: 14px; + line-height: 1; + flex-shrink: 0; +} + +#open-list:hover { + background: var(--border); +} + +/* ---- Status bar ---- */ +#status { + padding: 4px 12px; + font-size: 11px; + color: var(--text-muted); + background: var(--status-bg); + flex-shrink: 0; + min-height: 22px; +} diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html new file mode 100644 index 0000000..44fe335 --- /dev/null +++ b/sidepanel/sidepanel.html @@ -0,0 +1,32 @@ + + + + + + Side Notes + + + + + + + + + +
Saved
+ + + + diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js new file mode 100644 index 0000000..fa3d2c7 --- /dev/null +++ b/sidepanel/sidepanel.js @@ -0,0 +1,379 @@ +// Side Notes — Side Panel Editor +// +// A per-tab markdown note editor that lives in the browser side panel. +// Key behaviors: +// - One