From 82bb55f66071b452e3bc7adeb09eafb5003de11f Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Wed, 2 Sep 2026 03:14:05 +0900 Subject: feat: PDF Viewer translation via context menu and two-mode Action Popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Firefox built-in PDF Viewer support using only public WebExtensions APIs: a selection context menu ("選択したテキストを翻訳") starts a translation and the toolbar Action Popup shows the streaming result. No content-script injection into the privileged PDF Viewer, no PDF.js internals, no separate windows, no custom viewer (Specifications.md §4.6). Specification alignment (Phase 1) - Specifications.md: new requirements PDF-01..04 (PDF selection translation), CTX-01..03 (context menu translation), POP-01..06 (popup two modes); SEL-06 updated to route PDF through the menus API; section 13.4 adds a 14-item manual PDF verification checklist; section 15 removes "Firefox内蔵PDF Viewerへの対応" from out-of-scope - Design.md: architecture diagram with the menus path, menu-translation component, menus permission, popup translation-result state machine (idle/starting/streaming/completed/error/cancelled), shared translation state (§9.2), context-menu integration phase, test plan and API audit checklist items (menus permission scope, no PDF Viewer internals) - Security.md: menus permission restricted to selection-context translation; PDF Viewer treated as privileged browser UI (no injection, no wrappedJSObject/cloneInto/exportFunction, no internal DOM access); menus.OnClickData.selectionText treated as untrusted input passing the same validation pipeline; popup result rendering stays textContent-only Background (Phase 2) - translation-service.js (new): the single translation pipeline shared by every entry point - selection validation (type/trim/empty/control chars/max length), prompt building, Ollama request, session/abort, and the shared translation state (requestId, sourceText, translatedText, status, model, error, startedAt, source). "source" is metadata only; a PDF-specific translation engine does not exist - menu-translation.js (new): validates menus.OnClickData.selectionText and starts a sink-less translation observed via GET_POPUP_STATE - message-router.js: routes port translations through the shared service; new runtime messages GET_POPUP_STATE and REGENERATE_LAST; popup-state port handler broadcasts live state (initial push + every chunk/status change) so an open popup shows streaming progress in real time - index.js: registers the selection context menu (contexts: ["selection"] only, no menus.onShown) and calls browser.action.openPopup() synchronously inside the click user gesture, catching failures so a popup-open problem never breaks the translation itself - manifest.json: permissions now ["storage", "menus"] - settings-service.js: __resetCacheForTests hook for test isolation Popup two-mode UI (Phase 3) - popup.html/js/css: mode nav (設定 / 翻訳). Translation mode shows status, model, streaming text (textContent only), user-safe error, and copy (Clipboard API only, no execCommand fallback), regenerate (new requestId reusing the stored source text) and a settings shortcut. Closing the popup never cancels the request; reopening shows the latest text and continues receiving chunks live. Initial mode: active (starting/streaming) translation wins, otherwise the last tab the user picked is restored (storage.local.popupMode, default settings) Fresh-install model auto-selection - translation-service.js ensureModelConfigured(): when storage.model is empty, pick the first model from the running Ollama and persist it before translating, mirroring the popup/options auto-select. A user who never opened any settings UI can translate immediately; when Ollama is unreachable a clear OLLAMA_UNAVAILABLE message is shown User-safe error details - detailForUser(): strips URLs (no internal host names echoed to the UI), caps length at 120 chars, drops full JSON response bodies, and hides meaningless low-level network errors so the popover/popup shows the base message plus at most a short, safe detail Tests: 85 passed, 0 failed - selectionText validation (untrusted input pipeline) - menu selection -> translation -> popup state streaming/completed - empty selection rejected without starting a request - streaming status transitions observed through GET_POPUP_STATE - Ollama unreachable surfaces a user-safe error into popup state - newer request replaces older state (abort semantics) - regenerateLast reuses source text with a fresh requestId - popup-state port receives live broadcasts; wrong port names ignored - fresh-install auto-select persists the first Ollama model - error detail sanitization (URL stripping, length cap, JSON drop) - popup/options smoke tests updated for the two-mode markup - setupRouter resets the shared settings cache for test isolation Docs: README documents the two-mode popup and PDF flow; Testing-Results records automated results and the manual PDF checklist (to be executed). No git-side surprises: this commit was created explicitly on request (requirement 18 of the PDF task had deferred commits until now). --- src/popup/popup.css | 73 +++++++++++++++++++- src/popup/popup.html | 56 +++++++++++----- src/popup/popup.js | 184 ++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 293 insertions(+), 20 deletions(-) (limited to 'src/popup') diff --git a/src/popup/popup.css b/src/popup/popup.css index 964454d..de287a9 100644 --- a/src/popup/popup.css +++ b/src/popup/popup.css @@ -8,9 +8,80 @@ body { background: #ffffff; } +.popup-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + h1 { font-size: 14px; - margin: 0 0 10px; + margin: 0; +} + +.mode-nav { + display: flex; + gap: 4px; +} + +.nav-btn { + border: 1px solid rgba(0, 0, 0, 0.25); + background: #fff; + border-radius: 4px; + padding: 2px 10px; + cursor: pointer; + font-size: 12px; +} + +.nav-btn.active { + background: #1a73e8; + border-color: #1a73e8; + color: #fff; +} + +.translation-text { + min-height: 60px; + max-height: 220px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 4px; + padding: 6px 8px; + margin: 6px 0; + user-select: text; +} + +.meta { + color: #5f6368; + font-size: 11px; + margin: 2px 0 0; +} + +.actions { + display: flex; + gap: 6px; + margin-top: 8px; +} + +.actions button { + flex: 1; + padding: 5px 8px; + border: 1px solid rgba(0, 0, 0, 0.25); + border-radius: 4px; + background: #fff; + cursor: pointer; +} + +.actions button:hover:not([disabled]) { + background: #f0f4ff; +} + +.actions button[disabled] { + opacity: 0.5; + cursor: default; } .row { diff --git a/src/popup/popup.html b/src/popup/popup.html index e51475f..06a4cf4 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -6,28 +6,50 @@
-

Ollama Translations

+ -
- - -
- -
- -
- - + + -
- - -
+ +
+
+ + +
- +
+ +
+ + +
+ +
+ +
+ + +
+ + +
diff --git a/src/popup/popup.js b/src/popup/popup.js index eb213f5..3bd0cd9 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -1,6 +1,11 @@ 'use strict'; -import { LANGUAGES, ERROR_CODES, RUNTIME_MESSAGES } from '../shared/constants.js'; +import { + LANGUAGES, + ERROR_CODES, + RUNTIME_MESSAGES, + POPUP_STATUSES +} from '../shared/constants.js'; import { defaultSettings, sanitizeSettings } from '../shared/settings-schema.js'; const $enabled = /** @type {HTMLInputElement} */ (document.getElementById('enabled-toggle')); @@ -10,9 +15,171 @@ const $modelStatus = /** @type {HTMLParagraphElement} */ (document.getElementByI const $language = /** @type {HTMLSelectElement} */ (document.getElementById('language-select')); const $openOptions = /** @type {HTMLButtonElement} */ (document.getElementById('open-options')); +// Mode navigation + translation result mode elements +const $navSettings = /** @type {HTMLButtonElement} */ (document.getElementById('nav-settings')); +const $navTranslation = /** @type {HTMLButtonElement} */ (document.getElementById('nav-translation')); +const $settingsMode = /** @type {HTMLElement} */ (document.getElementById('settings-mode')); +const $translationMode = /** @type {HTMLElement} */ (document.getElementById('translation-mode')); +const $translationStatus = /** @type {HTMLParagraphElement} */ (document.getElementById('translation-status')); +const $translationMeta = /** @type {HTMLParagraphElement} */ (document.getElementById('translation-meta')); +const $translationText = /** @type {HTMLDivElement} */ (document.getElementById('translation-text')); +const $translationError = /** @type {HTMLParagraphElement} */ (document.getElementById('translation-error')); +const $copyTranslation = /** @type {HTMLButtonElement} */ (document.getElementById('copy-translation')); +const $regenerateTranslation = /** @type {HTMLButtonElement} */ (document.getElementById('regenerate-translation')); +const $openOptionsFromTranslation = /** @type {HTMLButtonElement} */ (document.getElementById('open-options-from-translation')); + /** @type {string[]} current model list for change detection */ let knownModels = []; +// --------------------------------------------------------------------- +// Mode switching +// --------------------------------------------------------------------- + +/** @param {'settings'|'translation'} mode */ +function showMode(mode) { + const translation = mode === 'translation'; + $settingsMode.hidden = translation; + $translationMode.hidden = !translation; + $navSettings.classList.toggle('active', !translation); + $navTranslation.classList.toggle('active', translation); + $navSettings.setAttribute('aria-pressed', String(!translation)); + $navTranslation.setAttribute('aria-pressed', String(translation)); +} + +$navSettings.addEventListener('click', () => { + showMode('settings'); + void persistMode('settings'); +}); +$navTranslation.addEventListener('click', () => { + showMode('translation'); + void persistMode('translation'); + void refreshTranslationState(); +}); + +$openOptionsFromTranslation.addEventListener('click', () => { + browser.runtime.openOptionsPage(); +}); + +/** + * Remember the tab the user picked so the next popup open restores it + * instead of always jumping to the translation tab. + * @param {'settings'|'translation'} mode + */ +async function persistMode(mode) { + try { + await browser.storage.local.set({ popupMode: mode }); + } catch { + // non-fatal + } +} + +// --------------------------------------------------------------------- +// Translation result mode +// --------------------------------------------------------------------- + +const STATUS_LABELS = { + [POPUP_STATUSES.IDLE]: '待機中', + [POPUP_STATUSES.STARTING]: '翻訳を準備中…', + [POPUP_STATUSES.STREAMING]: '受信中…', + [POPUP_STATUSES.COMPLETED]: '完了', + [POPUP_STATUSES.ERROR]: 'エラー', + [POPUP_STATUSES.CANCELLED]: '中断' +}; + +function renderTranslationState(state) { + if (!state || typeof state !== 'object') return; + const status = typeof state.status === 'string' ? state.status : POPUP_STATUSES.IDLE; + const busy = status === POPUP_STATUSES.STARTING || status === POPUP_STATUSES.STREAMING; + + $translationStatus.textContent = STATUS_LABELS[status] || status; + + if (state.model) { + $translationMeta.hidden = false; + $translationMeta.textContent = `Ollama / ${state.model}`; + } else { + $translationMeta.hidden = true; + } + + // Plain-text rendering only (Security.md §7.4). + $translationText.textContent = typeof state.translatedText === 'string' ? state.translatedText : ''; + + if (state.error) { + $translationError.hidden = false; + $translationError.textContent = state.error; + } else { + $translationError.hidden = true; + } + + $copyTranslation.disabled = busy || !(state.translatedText && state.translatedText.length > 0); + $regenerateTranslation.disabled = busy || !state.sourceText; +} + +async function refreshTranslationState() { + try { + const response = await browser.runtime.sendMessage({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }); + if (response && response.ok && response.state) { + renderTranslationState(response.state); + return response.state; + } + } catch { + // background unavailable; leave the current view + } + return null; +} + +// --------------------------------------------------------------------- +// Live state updates: subscribe to the shared background state so an +// open popup receives streaming chunks in real time (Design.md §7.4). +// --------------------------------------------------------------------- + +try { + const statePort = browser.runtime.connect({ name: 'popup-state' }); + statePort.onMessage.addListener((message) => { + if (message && message.type === 'popup-state' && message.state) { + renderTranslationState(message.state); + } + }); + // onDisconnect: popup is closing or the background restarted; nothing to do. +} catch { + // Port may be unavailable in non-extension test contexts; polling in + // refreshTranslationState() still covers the initial state. +} + +$copyTranslation.addEventListener('click', () => { + const text = $translationText.textContent || ''; + if (!text) return; + void (async () => { + try { + await navigator.clipboard.writeText(text); + $translationStatus.textContent = 'コピーしました'; + } catch { + // No execCommand fallback (deprecated): show failure only. + $translationStatus.textContent = 'コピーできませんでした'; + } + })(); +}); + +$regenerateTranslation.addEventListener('click', () => { + void (async () => { + try { + const response = await browser.runtime.sendMessage({ type: RUNTIME_MESSAGES.REGENERATE_LAST }); + if (response && response.ok) { + await refreshTranslationState(); + } else { + $translationError.hidden = false; + $translationError.textContent = '再生成を開始できませんでした。'; + } + } catch { + $translationError.hidden = false; + $translationError.textContent = '再生成を開始できませんでした。'; + } + })(); +}); + +// --------------------------------------------------------------------- +// Settings mode (existing behavior) +// --------------------------------------------------------------------- + function fillLanguages(selected) { $language.textContent = ''; for (const lang of LANGUAGES) { @@ -138,10 +305,23 @@ void (async () => { // If the model list loaded and no model is saved yet, persist the // auto-selected first model so the stored settings match the UI. if (knownModels.length > 0) { - const result = await browser.storage.local.get({ settings: null }); + const result = await browser.storage.local.get({ settings: null, popupMode: null }); const stored = sanitizeSettings(result.settings || defaultSettings()); if (!stored.model) { await persist({ model: $model.value }); } + // Decide the initial mode. Active/recent translation wins; otherwise + // restore the tab the user last picked (default: settings). + const state = await refreshTranslationState(); + const active = state && (state.status === 'starting' || state.status === 'streaming'); + if (active) { + showMode('translation'); + } else if (result.popupMode === 'translation' && state && state.translatedText && state.translatedText.length > 0) { + showMode('translation'); + } else { + showMode('settings'); + } + } else { + showMode('settings'); } })(); \ No newline at end of file -- cgit v1.2.3