529 lines
17 KiB
JavaScript
529 lines
17 KiB
JavaScript
// 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');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Font size — persisted in localStorage, applied as an inline style
|
|
// ---------------------------------------------------------------------------
|
|
const FONT_SIZE_KEY = 'side-notes:fontSize';
|
|
const FONT_SIZE_DEFAULT = 16;
|
|
const FONT_SIZE_MIN = 10;
|
|
const FONT_SIZE_MAX = 24;
|
|
const FONT_SIZE_STEP = 1;
|
|
|
|
function loadFontSize() {
|
|
const raw = Number(localStorage.getItem(FONT_SIZE_KEY));
|
|
if (!Number.isFinite(raw) || raw <= 0) return FONT_SIZE_DEFAULT;
|
|
return Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, Math.round(raw)));
|
|
}
|
|
|
|
function applyFontSize(size) {
|
|
const clamped = Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, size));
|
|
editor.style.fontSize = `${clamped}px`;
|
|
localStorage.setItem(FONT_SIZE_KEY, String(clamped));
|
|
return clamped;
|
|
}
|
|
|
|
let fontSize = applyFontSize(loadFontSize());
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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();
|
|
}
|
|
// Font size: Ctrl/Cmd+= (or +) increase, Ctrl/Cmd+- decrease, Ctrl/Cmd+0 reset
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) {
|
|
e.preventDefault();
|
|
fontSize = applyFontSize(fontSize + FONT_SIZE_STEP);
|
|
}
|
|
if ((e.ctrlKey || e.metaKey) && e.key === '-') {
|
|
e.preventDefault();
|
|
fontSize = applyFontSize(fontSize - FONT_SIZE_STEP);
|
|
}
|
|
if ((e.ctrlKey || e.metaKey) && e.key === '0') {
|
|
e.preventDefault();
|
|
fontSize = applyFontSize(FONT_SIZE_DEFAULT);
|
|
}
|
|
// 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);
|
|
}
|
|
});
|
|
|
|
document.getElementById('font-decrease').addEventListener('click', () => {
|
|
fontSize = applyFontSize(fontSize - FONT_SIZE_STEP);
|
|
});
|
|
|
|
document.getElementById('font-increase').addEventListener('click', () => {
|
|
fontSize = applyFontSize(fontSize + FONT_SIZE_STEP);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Chrome does not always grant focus to the panel document the moment it
|
|
// opens (notably via the keyboard shortcut), so the focus() call in
|
|
// loadNoteForTab can silently fail. Retry briefly after init, stopping as
|
|
// soon as the editor has focus or the user interacts with anything else.
|
|
function retryEditorFocus() {
|
|
let attempts = 0;
|
|
const timer = setInterval(() => {
|
|
attempts++;
|
|
if (document.activeElement === editor || attempts > 10) {
|
|
clearInterval(timer);
|
|
return;
|
|
}
|
|
editor.focus();
|
|
}, 100);
|
|
const stop = () => clearInterval(timer);
|
|
window.addEventListener('pointerdown', stop, { capture: true, once: true });
|
|
window.addEventListener('keydown', stop, { capture: true, once: true });
|
|
}
|
|
|
|
(async function init() {
|
|
const tabId = await resolveHostTab();
|
|
if (tabId != null) {
|
|
await loadNoteForTab(tabId);
|
|
retryEditorFocus();
|
|
} else {
|
|
pageTitleEl.textContent = 'Side Notes';
|
|
updateStatus('Could not detect active tab');
|
|
}
|
|
})();
|