- 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
216 lines
6.8 KiB
JavaScript
216 lines
6.8 KiB
JavaScript
// 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();
|
|
})();
|