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
This commit is contained in:
Fabio Montefuscolo 2026-08-03 19:50:30 +02:00
parent 1037722196
commit 4365b7687b
13 changed files with 1242 additions and 0 deletions

38
background.js Normal file
View file

@ -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);
}
});

BIN
icons/icon-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

BIN
icons/icon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

BIN
icons/icon-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

35
manifest.json Normal file
View file

@ -0,0 +1,35 @@
{
"manifest_version": 3,
"name": "Side Notes",
"version": "0.1.0",
"description": "One markdown note per page, available instantly from a side panel.",
"permissions": [
"sidePanel",
"tabs",
"webNavigation",
"unlimitedStorage"
],
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"action": {
"default_title": "Open Side Notes"
},
"side_panel": {
"default_path": "sidepanel/sidepanel.html"
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"commands": {
"_execute_action": {
"suggested_key": {
"default": "Ctrl+Shift+N",
"mac": "Command+Shift+N"
}
}
}
}

132
notes-list/notes-list.css Normal file
View file

@ -0,0 +1,132 @@
/* Side Notes — Notes List (Dark Theme, chrome://history-inspired) */
:root {
--bg: #1e1e1e;
--bg-header: #252525;
--text: #d4d4d4;
--text-muted: #888;
--text-dim: #666;
--border: #3c3c3c;
--accent: #569cd6;
--hover-bg: #2a2a2a;
--section-label: #999;
}
* {
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;
}
/* ---- Header ---- */
header {
background: var(--bg-header);
border-bottom: 1px solid var(--border);
padding: 24px 32px 16px;
}
header h1 {
font-size: 22px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
}
#subtitle {
font-size: 13px;
color: var(--text-muted);
}
/* ---- Main list ---- */
main {
max-width: 800px;
margin: 0 auto;
padding: 16px 32px 48px;
}
/* ---- Day sections ---- */
.day-section {
margin-bottom: 24px;
}
.day-label {
font-size: 12px;
font-weight: 600;
color: var(--section-label);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
position: sticky;
top: 0;
background: var(--bg);
z-index: 1;
}
/* ---- Note entries ---- */
.note-entry {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.1s;
}
.note-entry:hover {
background: var(--hover-bg);
}
.note-entry img {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.note-entry .entry-text {
flex: 1;
min-width: 0;
}
.note-entry .entry-title {
font-size: 14px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.note-entry .entry-url {
font-size: 12px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.note-entry .entry-time {
font-size: 12px;
color: var(--text-muted);
flex-shrink: 0;
text-align: right;
min-width: 70px;
}
/* ---- Empty state ---- */
#empty-message {
text-align: center;
color: var(--text-muted);
padding: 48px 0;
font-size: 14px;
}

View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Side Notes — All Notes</title>
<link rel="stylesheet" href="notes-list.css">
</head>
<body>
<header>
<h1>Side Notes</h1>
<p id="subtitle">All notes</p>
</header>
<main id="note-list">
<p id="empty-message">No notes yet. Open a page and use the side panel to write one.</p>
</main>
<script type="module" src="notes-list.js"></script>
</body>
</html>

216
notes-list/notes-list.js Normal file
View file

@ -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();
})();

43
shared/key-builder.js Normal file
View file

@ -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 };
}

177
shared/storage.js Normal file
View file

@ -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<IDBDatabase>} */
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<object|null>}
*/
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<object>} 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<object[]>}
*/
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<void>}
*/
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);
});
}

169
sidepanel/sidepanel.css Normal file
View file

@ -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;
}

32
sidepanel/sidepanel.html Normal file
View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Side Notes</title>
<link rel="stylesheet" href="sidepanel.css">
</head>
<body>
<div id="header">
<div id="page-info">
<img id="favicon" src="" alt="" width="16" height="16" hidden>
<span id="page-title">Loading…</span>
</div>
<button id="open-list" title="View all notes" aria-label="View all notes">☰</button>
</div>
<textarea id="editor" placeholder="Write your note…" spellcheck="true"></textarea>
<div id="find-bar" hidden>
<input type="search" id="find-input" placeholder="Find in note…">
<span id="find-count"></span>
<button id="find-prev" title="Previous match" aria-label="Previous match">▲</button>
<button id="find-next" title="Next match" aria-label="Next match">▼</button>
<button id="find-close" title="Close find bar" aria-label="Close find bar">✕</button>
</div>
<div id="status">Saved</div>
<script type="module" src="sidepanel.js"></script>
</body>
</html>

379
sidepanel/sidepanel.js Normal file
View file

@ -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 <textarea>, no custom editing widgets — native text interaction.
// - Debounced autosave on keystroke (no save button).
// - In-panel find: Ctrl+F / Cmd+F intercepted, matches selected via
// setSelectionRange + scroll into view.
// - Cross-panel consistency: BroadcastChannel messages trigger reload
// unless this editor currently has focus (deferred until blur).
// - SPA-aware: navigation events from the background worker re-resolve
// the note key and reload.
import { buildNoteKey } from '../shared/key-builder.js';
import { getNoteByKey, saveNote } from '../shared/storage.js';
// ---------------------------------------------------------------------------
// DOM references
// ---------------------------------------------------------------------------
const editor = document.getElementById('editor');
const faviconEl = document.getElementById('favicon');
const pageTitleEl = document.getElementById('page-title');
const statusEl = document.getElementById('status');
// Find bar
const findBar = document.getElementById('find-bar');
const findInput = document.getElementById('find-input');
const findCount = document.getElementById('find-count');
const findPrev = document.getElementById('find-prev');
const findNext = document.getElementById('find-next');
const findClose = document.getElementById('find-close');
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let currentTabId = null;
let currentKey = null;
let currentNote = null;
let saveTimer = null;
const SAVE_DEBOUNCE_MS = 500;
// Find state
let findMatches = [];
let findIndex = -1;
// Cross-panel consistency: when a remote update arrives while we're focused,
// we defer the reload. This flag is set to true to avoid re-entrant saves
// during a programmatic content load.
let isLoadingContent = false;
// ---------------------------------------------------------------------------
// Channel — shared with other side panels and the background worker
// ---------------------------------------------------------------------------
const bc = new BroadcastChannel('side-notes');
// ---------------------------------------------------------------------------
// Detect host tab
// ---------------------------------------------------------------------------
async function resolveHostTab() {
// Chrome passes tabId as a query parameter to side panel pages.
const params = new URLSearchParams(window.location.search);
const tabIdFromUrl = params.get('tabId');
if (tabIdFromUrl) {
return Number(tabIdFromUrl);
}
// Fallback: ask the active tab in the last focused window.
try {
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
if (tab) return tab.id;
} catch {
// Permissions may restrict this — fall through.
}
return null;
}
// ---------------------------------------------------------------------------
// Load a note for the host tab
// ---------------------------------------------------------------------------
async function loadNoteForTab(tabId) {
try {
const tab = await chrome.tabs.get(tabId);
const key = buildNoteKey(tab.url || '');
// Update header
if (tab.favIconUrl) {
faviconEl.src = tab.favIconUrl;
faviconEl.hidden = false;
} else {
faviconEl.hidden = true;
}
pageTitleEl.textContent = tab.title || tab.url || 'Untitled';
currentTabId = tabId;
currentKey = key;
// Load content
const note = await getNoteByKey(key);
currentNote = note;
isLoadingContent = true;
editor.value = note ? note.content : '';
isLoadingContent = false;
updateStatus('Saved');
} catch (err) {
console.error('Side Notes: failed to load note for tab', tabId, err);
pageTitleEl.textContent = 'Error';
currentKey = null;
}
}
// ---------------------------------------------------------------------------
// Autosave
// ---------------------------------------------------------------------------
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(doSave, SAVE_DEBOUNCE_MS);
updateStatus('Unsaved…');
}
async function doSave() {
if (!currentKey || currentTabId == null) return;
try {
const tab = await chrome.tabs.get(currentTabId);
const pageMeta = {
pageTitle: tab.title || '',
favIconUrl: tab.favIconUrl || '',
};
const saved = await saveNote(currentKey, editor.value, pageMeta);
currentNote = saved;
updateStatus('Saved');
// Update header in case tab title changed.
pageTitleEl.textContent = tab.title || tab.url || 'Untitled';
if (tab.favIconUrl) {
faviconEl.src = tab.favIconUrl;
faviconEl.hidden = false;
}
// Notify other panels.
bc.postMessage({ type: 'save', key: currentKey, updatedAt: saved.updatedAt });
} catch (err) {
console.error('Side Notes: autosave failed', err);
updateStatus('Save failed');
}
}
function updateStatus(text) {
statusEl.textContent = text;
}
// ---------------------------------------------------------------------------
// BroadcastChannel — cross-panel consistency
// ---------------------------------------------------------------------------
bc.onmessage = (event) => {
const msg = event.data;
// Navigation event from background worker: URL changed in our tab.
if (msg.type === 'navigation' && msg.tabId === currentTabId) {
loadNoteForTab(currentTabId);
return;
}
// Save event from another panel for the same key.
if (msg.type === 'save' && msg.key === currentKey) {
if (document.activeElement === editor) {
// Defer reload until blur.
deferReload(msg.key);
} else {
reloadFromDB();
}
}
};
let deferredReload = false;
function deferReload(key) {
if (deferredReload) return;
deferredReload = true;
updateStatus('Updated in another panel — will refresh on blur');
}
async function reloadFromDB() {
if (!currentKey) return;
try {
const note = await getNoteByKey(currentKey);
isLoadingContent = true;
editor.value = note ? note.content : '';
isLoadingContent = false;
currentNote = note;
updateStatus('Saved');
deferredReload = false;
} catch (err) {
console.error('Side Notes: reload failed', err);
}
}
// ---------------------------------------------------------------------------
// Event: editor input → autosave
// ---------------------------------------------------------------------------
editor.addEventListener('input', () => {
if (isLoadingContent) return;
scheduleSave();
});
// ---------------------------------------------------------------------------
// Event: blur → apply deferred reload
// ---------------------------------------------------------------------------
editor.addEventListener('blur', () => {
// Flush any pending save first.
if (saveTimer) {
clearTimeout(saveTimer);
doSave();
}
if (deferredReload) {
reloadFromDB();
}
});
// ---------------------------------------------------------------------------
// In-panel Find
// ---------------------------------------------------------------------------
function openFind() {
findBar.hidden = false;
findInput.focus();
findInput.select();
// Reset
findMatches = [];
findIndex = -1;
findCount.textContent = '';
}
function closeFind() {
findBar.hidden = true;
findMatches = [];
findIndex = -1;
findCount.textContent = '';
// Clear selection
editor.setSelectionRange(0, 0);
editor.focus();
}
function doFind(query) {
findMatches = [];
findIndex = -1;
if (!query) {
findCount.textContent = '';
editor.setSelectionRange(0, 0);
return;
}
const text = editor.value;
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
let idx = 0;
while (true) {
const pos = lowerText.indexOf(lowerQuery, idx);
if (pos === -1) break;
findMatches.push(pos);
idx = pos + 1;
}
if (findMatches.length > 0) {
findIndex = 0;
selectMatch(0);
findCount.textContent = `1 / ${findMatches.length}`;
} else {
findCount.textContent = '0 / 0';
}
}
function selectMatch(index) {
if (findMatches.length === 0) return;
findIndex = index;
const start = findMatches[index];
const end = start + findInput.value.length;
editor.setSelectionRange(start, end);
// Scroll the match into view using the textarea's native scroll.
// We compute the approximate line and scroll to it.
const before = editor.value.substring(0, start);
const line = before.split('\n').length;
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20;
const scrollTop = (line - 3) * lineHeight; // show a few lines above
editor.scrollTop = Math.max(0, scrollTop);
// Ensure the textarea has focus so the selection is visible.
editor.focus();
findCount.textContent = `${index + 1} / ${findMatches.length}`;
}
function findNextMatch() {
if (findMatches.length === 0) return;
selectMatch((findIndex + 1) % findMatches.length);
}
function findPrevMatch() {
if (findMatches.length === 0) return;
selectMatch((findIndex - 1 + findMatches.length) % findMatches.length);
}
// Keyboard: Ctrl+F / Cmd+F → open find bar
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
e.stopPropagation();
openFind();
}
// Escape → close find bar
if (e.key === 'Escape' && !findBar.hidden) {
e.preventDefault();
closeFind();
}
// Enter in find input → next match
if (e.key === 'Enter' && document.activeElement === findInput) {
e.preventDefault();
if (e.shiftKey) {
findPrevMatch();
} else {
findNextMatch();
}
}
});
findInput.addEventListener('input', () => {
doFind(findInput.value);
});
findPrev.addEventListener('click', findPrevMatch);
findNext.addEventListener('click', findNextMatch);
findClose.addEventListener('click', closeFind);
// ---------------------------------------------------------------------------
// Tab activation — handle user switching tabs while panel is open
// ---------------------------------------------------------------------------
chrome.tabs.onActivated.addListener((activeInfo) => {
// Reload only if the activated tab is different from our current one.
if (activeInfo.tabId !== currentTabId) {
loadNoteForTab(activeInfo.tabId);
}
});
// ---------------------------------------------------------------------------
// Tab updates — if our tab's URL changes (full page load), reload the note.
// ---------------------------------------------------------------------------
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (tabId === currentTabId && changeInfo.url) {
loadNoteForTab(tabId);
}
});
// ---------------------------------------------------------------------------
// Open notes list page
// ---------------------------------------------------------------------------
document.getElementById('open-list').addEventListener('click', () => {
const url = chrome.runtime.getURL('notes-list/notes-list.html');
// Check if the notes-list page is already open.
chrome.tabs.query({ url }, (tabs) => {
if (tabs.length > 0) {
chrome.tabs.update(tabs[0].id, { active: true });
chrome.windows.update(tabs[0].windowId, { focused: true });
} else {
chrome.tabs.create({ url });
}
});
});
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
(async function init() {
const tabId = await resolveHostTab();
if (tabId != null) {
await loadNoteForTab(tabId);
} else {
pageTitleEl.textContent = 'Side Notes';
updateStatus('Could not detect active tab');
}
})();