summaryrefslogtreecommitdiff
path: root/src/popup
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-09-02 03:14:05 +0900
committertanaka takashi <isazaka@gmail.com>2026-09-02 03:14:05 +0900
commit82bb55f66071b452e3bc7adeb09eafb5003de11f (patch)
tree01d48a68273594fc2ed480d837724562dd6fb05d /src/popup
parentf5439335e1d18ee577e2b7c1e67f7844f9f235ac (diff)
feat: PDF Viewer translation via context menu and two-mode Action Popupv0.1.0
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).
Diffstat (limited to 'src/popup')
-rw-r--r--src/popup/popup.css73
-rw-r--r--src/popup/popup.html56
-rw-r--r--src/popup/popup.js184
3 files changed, 293 insertions, 20 deletions
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 @@
</head>
<body>
<main>
- <h1>Ollama Translations</h1>
+ <header class="popup-header">
+ <h1>Ollama Translations</h1>
+ <nav class="mode-nav" aria-label="表示切替">
+ <button id="nav-settings" type="button" class="nav-btn active" aria-pressed="true">設定</button>
+ <button id="nav-translation" type="button" class="nav-btn" aria-pressed="false">翻訳</button>
+ </nav>
+ </header>
- <section class="row toggle-row">
- <label class="toggle-label" for="enabled-toggle">翻訳機能</label>
- <input type="checkbox" id="enabled-toggle" role="switch">
- </section>
-
- <section class="row">
- <label for="model-select">モデル</label>
- <div class="model-row">
- <select id="model-select"></select>
- <button id="reload-models" type="button" title="モデル一覧を再取得">↻</button>
+ <!-- Translation result mode -->
+ <section id="translation-mode" class="mode" hidden>
+ <p id="translation-status" class="status"></p>
+ <p id="translation-meta" class="meta" hidden></p>
+ <div id="translation-text" class="translation-text" aria-live="polite"></div>
+ <p id="translation-error" class="status error" hidden></p>
+ <div class="actions">
+ <button id="copy-translation" type="button" disabled>コピー</button>
+ <button id="regenerate-translation" type="button" disabled>再生成</button>
+ <button id="open-options-from-translation" type="button">設定を開く</button>
</div>
- <p id="model-status" class="status" hidden></p>
</section>
- <section class="row">
- <label for="language-select">翻訳先言語</label>
- <select id="language-select"></select>
- </section>
+ <!-- Settings mode -->
+ <section id="settings-mode" class="mode">
+ <section class="row toggle-row">
+ <label class="toggle-label" for="enabled-toggle">翻訳機能</label>
+ <input type="checkbox" id="enabled-toggle" role="switch">
+ </section>
- <button id="open-options" type="button">詳細設定を開く</button>
+ <section class="row">
+ <label for="model-select">モデル</label>
+ <div class="model-row">
+ <select id="model-select"></select>
+ <button id="reload-models" type="button" title="モデル一覧を再取得">↻</button>
+ </div>
+ <p id="model-status" class="status" hidden></p>
+ </section>
+
+ <section class="row">
+ <label for="language-select">翻訳先言語</label>
+ <select id="language-select"></select>
+ </section>
+
+ <button id="open-options" type="button">詳細設定を開く</button>
+ </section>
</main>
<script type="module" src="popup.js"></script>
</body>
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