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