import { LANGUAGES, PSM_MODES, getSettings, saveSettings, getRegion, getPages, setPages, deletePage, clearAll, joinPages, thumbKey, advanceIsConfigured, getRun, } from '../shared.js'; // Firefox exposes the promise-based APIs as `browser`; its `chrome` alias is // callback-style, which every `await` below would silently break on. const chrome = globalThis.browser ?? globalThis.chrome; const $ = (id) => document.getElementById(id); const expanded = new Set(); let state = { settings: null, region: null, pages: [], thumbs: {}, viewport: null, run: null }; /* ------------------------------- bootstrap ------------------------------- */ async function load() { const [settings, region, pages, run] = await Promise.all([getSettings(), getRegion(), getPages(), getRun()]); const thumbKeys = pages.map((p) => thumbKey(p.id)); const thumbs = thumbKeys.length ? await chrome.storage.local.get(thumbKeys) : {}; state = { ...state, settings, region, pages, thumbs, run }; render(); } /* The manifest asks for no host access up front. Single captures ride on activeTab, which Chrome grants when you press the hotkey or open this popup; a durable grant is only needed for runs that outlive a page load. */ async function currentSite() { const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); try { const url = new URL(tab?.url || ''); return /^https?:$/.test(url.protocol) ? { origin: `${url.origin}/*`, host: url.host } : null; } catch { return null; } } async function renderAccess() { const site = await currentSite(); const card = $('access-card'); if (!site) { card.hidden = true; return; } const granted = await chrome.permissions.contains({ origins: [''] }) || await chrome.permissions.contains({ origins: [site.origin] }); card.hidden = granted; $('access-info').textContent = `${site.host} — not granted`; } async function requestAccess(origins) { try { if (await chrome.permissions.request({ origins })) await renderAccess(); } catch (err) { // Chrome refuses the request outside a user gesture; nothing to recover. console.warn('permission request failed', err); } } async function readViewport() { try { const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); if (!tab || /^(chrome|edge|about|devtools|chrome-extension):/i.test(tab.url || '')) return; const [{ result }] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => ({ iw: window.innerWidth, ih: window.innerHeight }), }); state.viewport = result; renderRegion(); } catch { /* not injectable here — skip the warning */ } } async function showHotkey() { const commands = await chrome.commands.getAll(); const find = (name) => commands.find((c) => c.name === name)?.shortcut || 'Set hotkey'; $('hotkey').textContent = find('capture-region'); $('run-hotkey').textContent = find('toggle-run'); } /* -------------------------------- render -------------------------------- */ function render() { renderRun(); renderRegion(); renderSettings(); renderPages(); renderError(); renderAdvanceStatus(); renderAccess(); } function renderRun() { const run = state.run || {}; const btn = $('run-toggle'); btn.textContent = run.active ? 'Stop auto-run' : 'Start auto-run'; btn.className = `btn ${run.active ? 'stop' : 'go'}`; const note = $('run-note'); if (run.active) { note.hidden = false; note.className = 'note info'; note.textContent = `Running — ${run.captured} captured. Esc on the page stops it too.`; } else if (run.stopReason) { note.hidden = false; note.className = 'note warn'; note.textContent = `Last run stopped: ${run.stopReason}.`; } else { note.hidden = true; } } function renderRegion() { const r = state.region; const info = $('region-info'); const warn = $('region-warn'); $('set-region').textContent = r ? 'Redraw' : 'Set region'; if (!r) { info.textContent = 'Not set yet'; warn.hidden = true; return; } info.textContent = `${r.w} × ${r.h} at x ${r.x}, y ${r.y}` + (r.origin ? ` · ${r.origin.replace(/^https?:\/\//, '')}` : ''); const vp = state.viewport; if (vp && (vp.iw !== r.vw || vp.ih !== r.vh)) { warn.hidden = false; warn.textContent = `Window was ${r.vw} × ${r.vh} when you drew this, now ${vp.iw} × ${vp.ih}. ` + 'The region still points at the same spot on screen — redraw it if the page reflowed.'; } else { warn.hidden = true; } } function renderSettings() { const s = state.settings; fillSelect($('lang'), LANGUAGES.map((l) => [l.code, l.label]), s.lang); fillSelect($('psm'), PSM_MODES.map((m) => [m.value, m.label]), s.psm); $('enhance').checked = s.enhance; $('hud').checked = s.hud; $('dedupeWarn').checked = s.dedupeWarn; $('autoAdvance').checked = s.autoAdvance; $('advanceMode').value = s.advanceMode; $('advanceDelay').value = String(s.advanceDelay); $('advanceKey').value = s.advanceKey; $('advance-target').textContent = s.advancePoint ? `point ${s.advancePoint.x}, ${s.advancePoint.y}` : 'no control picked'; $('runInterval').value = String(s.runInterval); $('runStopOnDupes').value = String(s.runStopOnDupes); $('runMaxPages').value = String(s.runMaxPages); $('advance-opts').hidden = !s.autoAdvance; $('advance-click').hidden = s.advanceMode !== 'click'; $('advance-key').hidden = s.advanceMode !== 'key'; const unset = s.autoAdvance && !advanceIsConfigured(s); $('advance-warn').hidden = !unset; if (unset) { $('advance-warn').textContent = 'Auto-advance is on but no control is picked, so nothing will happen. Use Pick control.'; } } async function renderAdvanceStatus() { const { lastAdvance } = await chrome.storage.local.get('lastAdvance'); const el = $('advance-status'); el.className = 'status'; if (!lastAdvance) { el.textContent = 'not tried yet'; return; } el.classList.add(lastAdvance.ok ? 'ok' : 'bad'); el.textContent = `${lastAdvance.ok ? '\u2713' : '\u2717'} ${lastAdvance.detail}`; el.title = el.textContent; } function fillSelect(el, entries, value) { if (el.options.length !== entries.length) { el.replaceChildren(...entries.map(([v, label]) => new Option(label, v))); } el.value = value; } function renderPages() { const pages = state.pages; const host = $('pages'); const done = pages.filter((p) => p.status === 'done').length; $('count').hidden = pages.length === 0; $('count').textContent = String(pages.length); $('list-title').textContent = pages.length ? `${pages.length} page${pages.length > 1 ? 's' : ''} · ${done} read` : 'No pages yet'; $('expand-all').hidden = pages.length === 0; $('expand-all').textContent = expanded.size ? 'Collapse all' : 'Expand all'; for (const id of ['copy-all', 'download', 'clear']) $(id).disabled = pages.length === 0; if (!pages.length) { const hint = el('div', { class: 'empty' }); if (state.region) { hint.append('Press ', el('kbd', {}, [$('hotkey').textContent]), ' on the page to capture.'); } else { hint.append('Set a capture region, then press the hotkey once per page.'); } host.replaceChildren(hint); return; } host.replaceChildren(...pages.map(renderPage)); } function renderPage(p) { const open = expanded.has(p.id); const thumb = state.thumbs[thumbKey(p.id)]; const chips = []; if (p.status !== 'done') chips.push(el('span', { class: `chip ${p.status}` }, [p.status])); if (p.dup) chips.push(el('span', { class: 'chip dup' }, ['duplicate'])); const meta = p.status === 'done' ? `${(p.text || '').length} chars${p.conf != null ? ` · ${Math.round(p.conf)}%` : ''}` : ''; const head = el('div', { class: 'page-head' }, [ el('span', { class: 'page-n' }, [`Page ${p.n}`]), ...chips, el('span', { class: 'page-meta' }, [meta]), ]); const body = el('div', { class: 'page-body' }, [head]); if (p.status === 'error') { body.append(el('div', { class: 'preview' }, [p.error || 'Unknown error'])); } else if (open) { const ta = el('textarea', { class: 'editor' }); ta.value = p.text || ''; ta.addEventListener('input', debounce(() => updateText(p.id, ta.value), 350)); body.append(ta); } else { const preview = el('div', { class: 'preview', title: 'Click to edit' }, [(p.text || '').trim() || (p.status === 'done' ? '(no text found)' : 'waiting…')]); preview.addEventListener('click', () => { expanded.add(p.id); renderPages(); }); body.append(preview); } const actions = el('div', { class: 'page-actions' }, [ button('Copy', () => navigator.clipboard.writeText(p.text || '')), open ? button('Collapse', () => { expanded.delete(p.id); renderPages(); }) : button('Edit', () => { expanded.add(p.id); renderPages(); }), p.status === 'error' || p.status === 'done' ? button('Re-run', () => send({ type: 'ocrit:retry', id: p.id })) : null, button('Delete', async () => { expanded.delete(p.id); await deletePage(p.id); await load(); }, 'danger'), ].filter(Boolean)); body.append(actions); const row = el('div', { class: `page ${p.status}${p.dup ? ' dup' : ''}` }, []); if (thumb) { const img = el('img', { class: 'thumb', src: thumb, alt: `Page ${p.n} crop`, title: 'Open full size' }); // Not the data: URL directly — Firefox refuses to navigate a tab to one. img.addEventListener('click', () => chrome.tabs.create({ url: chrome.runtime.getURL(`src/viewer/viewer.html?id=${encodeURIComponent(p.id)}&n=${p.n}`), })); row.append(img); } row.append(body); return row; } async function renderError() { const { lastError } = await chrome.storage.local.get('lastError'); $('error').hidden = !lastError; if (lastError) $('error-text').textContent = lastError.message; } /* -------------------------------- helpers -------------------------------- */ /* Chrome navigates straight to its shortcut editor. Firefox treats about:addons as privileged and refuses tabs.create on it, so there the best we can do is say where it lives. */ const IS_GECKO = chrome.runtime.getURL('').startsWith('moz-extension://'); async function openShortcuts() { const note = $('shortcuts-note'); if (!IS_GECKO) { try { await chrome.tabs.create({ url: 'chrome://extensions/shortcuts' }); window.close(); return; } catch { /* fall through to the written directions */ } } note.textContent = 'Open about:addons → gear icon → Manage Extension Shortcuts.'; note.hidden = false; } function el(tag, attrs = {}, children = []) { const node = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) if (v != null) node.setAttribute(k, v); for (const c of children) node.append(c); return node; } function button(label, onClick, cls = '') { const b = el('button', { class: `btn ghost small ${cls}` }, [label]); b.addEventListener('click', onClick); return b; } function debounce(fn, ms) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; } function send(msg) { return chrome.runtime.sendMessage(msg).catch((e) => ({ ok: false, error: String(e) })); } async function updateText(id, text) { const pages = await getPages(); const i = pages.findIndex((p) => p.id === id); if (i === -1) return; pages[i] = { ...pages[i], text, dup: false }; await setPages(pages); } function download(name, text) { const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' })); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 4000); } async function patch(key, value) { state.settings = await saveSettings({ [key]: value }); renderSettings(); } /* --------------------------------- wiring --------------------------------- */ $('set-region').addEventListener('click', async () => { await send({ type: 'ocrit:start-region-picker' }); window.close(); }); $('pick-advance').addEventListener('click', async () => { await send({ type: 'ocrit:start-advance-picker' }); window.close(); }); $('grant').addEventListener('click', async () => { const site = await currentSite(); if (site) await requestAccess([site.origin]); }); $('grant-all').addEventListener('click', () => requestAccess([''])); chrome.permissions.onAdded.addListener(renderAccess); chrome.permissions.onRemoved.addListener(renderAccess); $('run-toggle').addEventListener('click', async () => { const btn = $('run-toggle'); btn.disabled = true; const res = await send({ type: 'ocrit:toggle-run' }); btn.disabled = false; if (!res?.ok) { await renderError(); return; } state.run = res.run; renderRun(); if (res.run.active) window.close(); // get out of the way of the page }); $('run-shortcuts').addEventListener('click', openShortcuts); $('test-advance').addEventListener('click', async () => { const btn = $('test-advance'); btn.disabled = true; btn.textContent = 'Testing…'; await send({ type: 'ocrit:test-advance' }); await renderAdvanceStatus(); btn.textContent = 'Test now'; btn.disabled = false; }); $('capture').addEventListener('click', async () => { const res = await send({ type: 'ocrit:capture' }); if (res?.ok) await load(); else await renderError(); }); $('shortcuts').addEventListener('click', openShortcuts); $('toggle-settings').addEventListener('click', () => { $('settings').hidden = !$('settings').hidden; }); $('error-dismiss').addEventListener('click', async () => { await send({ type: 'ocrit:clear-error' }); await renderError(); }); $('expand-all').addEventListener('click', () => { if (expanded.size) expanded.clear(); else for (const p of state.pages) expanded.add(p.id); renderPages(); }); $('copy-all').addEventListener('click', async () => { await navigator.clipboard.writeText(joinPages(state.pages)); $('copy-all').textContent = 'Copied'; setTimeout(() => { $('copy-all').textContent = 'Copy all'; }, 1200); }); $('download').addEventListener('click', () => { const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-'); download(`ocr-it-${stamp}.txt`, joinPages(state.pages)); }); $('clear').addEventListener('click', async () => { if (!confirm(`Delete all ${state.pages.length} captured pages?`)) return; expanded.clear(); await clearAll(); await load(); }); for (const key of ['lang', 'psm', 'advanceMode', 'advanceKey']) { $(key).addEventListener('change', (e) => patch(key, e.target.value)); } for (const key of ['advanceDelay', 'runInterval', 'runStopOnDupes', 'runMaxPages']) { $(key).addEventListener('change', (e) => patch(key, Number(e.target.value))); } for (const key of ['enhance', 'hud', 'dedupeWarn', 'autoAdvance']) { $(key).addEventListener('change', (e) => patch(key, e.target.checked)); } const refresh = debounce(load, 120); chrome.storage.onChanged.addListener((changes, area) => { if (area !== 'local') return; if ('lastError' in changes) renderError(); if ('lastAdvance' in changes) renderAdvanceStatus(); if ('run' in changes) { state.run = changes.run.newValue; renderRun(); } if ('pages' in changes || 'region' in changes || 'settings' in changes || Object.keys(changes).some((k) => k.startsWith('thumb:'))) refresh(); }); await load(); await showHotkey(); renderPages(); readViewport(); send({ type: 'ocrit:warmup' });