side-notes/sidepanel/sidepanel.js

464 lines
14 KiB
JavaScript
Raw Normal View History

// 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;
// Restore caret and scroll position, clamped to the current content
// length (another panel may have shortened the note since the position
// was saved). Records saved before caret persistence default to 0.
const len = editor.value.length;
const start = Math.min(note?.caretStart ?? 0, len);
const end = Math.min(note?.caretEnd ?? start, len);
editor.setSelectionRange(start, end);
const savedScrollTop = note?.scrollTop ?? 0;
requestAnimationFrame(() => {
editor.scrollTop = savedScrollTop;
});
editor.focus();
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() {
saveTimer = null;
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, {
caretStart: editor.selectionStart,
caretEnd: editor.selectionEnd,
scrollTop: editor.scrollTop,
});
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);
const content = note ? note.content : '';
// Skip the assignment when content is unchanged: setting editor.value
// would needlessly reset this panel's caret to the start.
if (content !== editor.value) {
isLoadingContent = true;
editor.value = 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();
}
flushCaretSave();
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flushCaretSave();
}
});
function flushCaretSave() {
if (caretTimer) {
clearTimeout(caretTimer);
doCaretSave();
}
}
// ---------------------------------------------------------------------------
// Caret-only persistence
//
// Caret moves (selectionchange / scroll) without content edits are saved on
// a debounce. These saves never broadcast: a remote caret update must not
// make other panels reload and lose their own caret.
// ---------------------------------------------------------------------------
let caretTimer = null;
const CARET_DEBOUNCE_MS = 800;
function scheduleCaretSave() {
if (!currentKey || isLoadingContent) return;
// A pending content autosave will capture the caret anyway.
if (saveTimer) return;
if (caretTimer) clearTimeout(caretTimer);
caretTimer = setTimeout(doCaretSave, CARET_DEBOUNCE_MS);
}
async function doCaretSave() {
caretTimer = null;
if (!currentKey || currentTabId == null) return;
// A content save got scheduled meanwhile — it captures the caret.
if (saveTimer) return;
// A remote content change is waiting for blur; our content is stale and
// must not be written back over it.
if (deferredReload) return;
try {
const saved = await saveNote(currentKey, editor.value, {}, {
caretStart: editor.selectionStart,
caretEnd: editor.selectionEnd,
scrollTop: editor.scrollTop,
});
currentNote = saved;
} catch (err) {
console.error('Side Notes: caret save failed', err);
}
}
document.addEventListener('selectionchange', () => {
if (document.activeElement === editor) {
scheduleCaretSave();
}
});
editor.addEventListener('scroll', scheduleCaretSave);
// ---------------------------------------------------------------------------
// 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');
}
})();