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:
parent
1037722196
commit
4365b7687b
13 changed files with 1242 additions and 0 deletions
38
background.js
Normal file
38
background.js
Normal 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
BIN
icons/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 381 B |
BIN
icons/icon-16.png
Normal file
BIN
icons/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 B |
BIN
icons/icon-48.png
Normal file
BIN
icons/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 B |
35
manifest.json
Normal file
35
manifest.json
Normal 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
132
notes-list/notes-list.css
Normal 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;
|
||||
}
|
||||
21
notes-list/notes-list.html
Normal file
21
notes-list/notes-list.html
Normal 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
216
notes-list/notes-list.js
Normal 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
43
shared/key-builder.js
Normal 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
177
shared/storage.js
Normal 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
169
sidepanel/sidepanel.css
Normal 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
32
sidepanel/sidepanel.html
Normal 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
379
sidepanel/sidepanel.js
Normal 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');
|
||||
}
|
||||
})();
|
||||
Loading…
Add table
Reference in a new issue