/* * Content script (classic script, single file). * * Sections: * 1. constants (mirrored from src/shared/constants.js - keep in sync, * enforced by unit tests) * 2. selection controller * 3. action button (Shadow DOM) * 4. translation popover (Shadow DOM, state machine) * 5. port client (runtime.Port to background) * 6. entry point * * Security rules (Security.md): * - never call Ollama directly; * - all dynamic text is rendered with textContent; * - no eval / new Function / remote code. */ (() => { 'use strict'; // --------------------------------------------------------------------- // 1. Constants (mirrored from src/shared/constants.js) // --------------------------------------------------------------------- const PORT_NAME = 'translation-stream'; const MAX_SELECTION_LENGTH = 20000; const ERROR_CODES = { INVALID_REQUEST: 'INVALID_REQUEST', TEXT_EMPTY: 'TEXT_EMPTY', TEXT_TOO_LONG: 'TEXT_TOO_LONG', OLLAMA_UNAVAILABLE: 'OLLAMA_UNAVAILABLE', MODEL_NOT_FOUND: 'MODEL_NOT_FOUND', INVALID_SETTINGS: 'INVALID_SETTINGS', REQUEST_ABORTED: 'REQUEST_ABORTED', STREAM_ERROR: 'STREAM_ERROR', UNKNOWN_ERROR: 'UNKNOWN_ERROR' }; const CONTENT_TO_BG = { TRANSLATE: 'translate', CANCEL: 'cancel' }; const BG_TO_CONTENT = { STARTED: 'started', CHUNK: 'chunk', COMPLETED: 'completed', ERROR: 'error' }; const POPOVER_STATES = { HIDDEN: 'HIDDEN', LOADING: 'LOADING', STREAMING: 'STREAMING', COMPLETED: 'COMPLETED', ERROR: 'ERROR' }; // --------------------------------------------------------------------- // State // --------------------------------------------------------------------- /** @type {boolean} */ let enabled = true; /** @type {DOMRect|null} rect of the latest stable selection */ let currentSelectionRect = null; /** @type {string|null} id of the request the popover currently shows */ let activeRequestId = null; /** @type {string|null} source text of the last request (for regenerate) */ let lastSourceText = null; // --------------------------------------------------------------------- // 2. Selection controller // --------------------------------------------------------------------- /** * Read the current selection. Returns null when there is nothing to show. * Top-level document only (SEL-05). * @returns {{text: string, rect: DOMRect}|null} */ function readSelection() { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { return null; } const text = selection.toString().trim(); if (!text) return null; if (text.length > MAX_SELECTION_LENGTH) return null; const range = selection.getRangeAt(0); const rect = range.getBoundingClientRect(); if (!rect || (rect.width === 0 && rect.height === 0)) return null; return { text, rect }; } /** * Debounce helper. * @param {Function} fn * @param {number} wait ms */ function debounce(fn, wait) { let timer = 0; return (...args) => { window.clearTimeout(timer); timer = window.setTimeout(() => fn(...args), wait); }; } const scheduleUpdate = debounce(() => { const selection = readSelection(); if (selection) { currentSelectionRect = selection.rect; actionButton.show(selection.rect); if (popover.isVisible()) { // Follow the newest selection rect while visible (ACT-05). popover.repositionTo(selection.rect); } } else { currentSelectionRect = null; actionButton.hide(); // Selection cleared: hide the popover too (SEL-03), cancelling any // in-flight request. if (popover.isVisible()) { popover.close(); } } }, 150); function onSelectionEvent() { if (!enabled) return; scheduleUpdate(); } // --------------------------------------------------------------------- // 3. Action button (Shadow DOM) // --------------------------------------------------------------------- const actionButton = (() => { let host = null; let shadow = null; let wrap = null; let button = null; function ensureDom() { if (host) return; host = document.createElement('div'); host.id = 'ollama-translations-action-host'; host.className = 'ollama-translations-host'; document.documentElement.appendChild(host); shadow = host.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = ` :host { all: initial; } .wrap { position: fixed; z-index: 2147483647; display: none; } .btn { width: 28px; height: 28px; border-radius: 50%; border: 1px solid rgba(0,0,0,.25); background: #ffffff; box-shadow: 0 1px 4px rgba(0,0,0,.35); padding: 0; cursor: pointer; display: flex; align-items: center; justify-content: center; } .btn:hover { background: #eef3ff; } .btn svg { width: 18px; height: 18px; display: block; } `; shadow.append(style); button = document.createElement('button'); button.type = 'button'; button.title = '選択範囲を翻訳'; button.setAttribute('aria-label', '選択範囲を翻訳'); // Inline SVG (project-owned, no external resources). const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute( 'd', 'M12.87 15.07 10.33 12.56 10.36 12.53C12.1 10.59 13.27 8.36 13.93 6.26H16V4.26H9V2.26H7V4.26H0V6.24H12.17 ' + 'C11.5 8 10.44 9.65 9 11.16 8.07 10.18 7.3 9.11 6.69 8H4.69C5.42 9.63 6.42 11.16 7.67 12.53L2.63 17.54 4.05 ' + '18.96 9.5 13.51 12.87 15.07M17.5 10H15.5L11 22H13L14.12 19H18.88L20 22H22L17.5 10M14.88 17 16.5 12.66 18.12 ' + '17H14.88Z' ); path.setAttribute('fill', '#1a73e8'); svg.append(path); button.append(svg); button.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); onTranslateClicked(); }); wrap = document.createElement('div'); wrap.className = 'wrap'; wrap.append(button); shadow.append(wrap); } /** * Show the button near the selection rect. * @param {DOMRect} rect */ function show(rect) { if (!enabled) return; ensureDom(); const margin = 6; const size = 28; let left = rect.right + margin; let top = rect.top; // Clamp into the viewport (ACT-03 / Design 5.4). if (left + size > window.innerWidth - 4) left = rect.left - size - margin; if (left + size > window.innerWidth - 4) left = window.innerWidth - size - 4; if (left < 4) left = 4; if (top + size > window.innerHeight - 4) top = window.innerHeight - size - 4; if (top < 4) top = 4; wrap.style.left = `${Math.round(left)}px`; wrap.style.top = `${Math.round(top)}px`; wrap.style.display = 'block'; } function hide() { if (wrap) wrap.style.display = 'none'; } return { show, hide }; })(); // --------------------------------------------------------------------- // 4. Translation popover (Shadow DOM) // --------------------------------------------------------------------- const popover = (() => { let host = null; let shadow = null; let panel = null; let body = null; let meta = null; let status = null; let copyButton = null; let regenerateButton = null; let closeButton = null; let currentRect = null; let state = POPOVER_STATES.HIDDEN; /** @type {string} accumulated translation text of the current request */ let text = ''; /** @type {string|null} text of the last requested translation */ let localLastSourceText = null; function ensureDom() { if (host) return; host = document.createElement('div'); host.id = 'ollama-translations-popover-host'; host.className = 'ollama-translations-host'; document.documentElement.append(host); shadow = host.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = ` :host { all: initial; } .panel { position: fixed; z-index: 2147483647; width: 340px; max-width: calc(100vw - 16px); max-height: 320px; display: none; flex-direction: column; background: #ffffff; color: #1f1f1f; border: 1px solid rgba(0,0,0,.28); border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.3); font-family: system-ui, sans-serif; font-size: 13px; line-height: 1.5; } .header { display: flex; align-items: center; gap: 6px; padding: 6px 10px; border-bottom: 1px solid rgba(0,0,0,.12); background: #f6f8fa; border-radius: 8px 8px 0 0; } .title { font-weight: 600; flex: 1; } .meta { color: #5f6368; font-size: 11px; } .close { border: none; background: transparent; cursor: pointer; font-size: 14px; line-height: 1; padding: 2px 4px; color: #444; } .body { padding: 8px 10px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; min-height: 24px; user-select: text; } .footer { display: flex; gap: 6px; padding: 6px 10px; border-top: 1px solid rgba(0,0,0,.12); } .btn { border: 1px solid rgba(0,0,0,.25); background: #fff; border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; font-family: system-ui, sans-serif; } .btn:hover { background: #f0f4ff; } .btn[disabled] { opacity: .5; cursor: default; } .status { color: #5f6368; font-size: 11px; padding: 0 10px 6px; } .state-ERROR .status { color: #c5221f; } .state-COMPLETED .status { color: #188038; } `; shadow.append(style); panel = document.createElement('div'); panel.className = 'panel'; const header = document.createElement('div'); header.className = 'header'; const title = document.createElement('span'); title.className = 'title'; title.textContent = '翻訳'; meta = document.createElement('span'); meta.className = 'meta'; closeButton = document.createElement('button'); closeButton.type = 'button'; closeButton.className = 'close'; closeButton.textContent = '✕'; closeButton.title = '閉じる'; closeButton.setAttribute('aria-label', '閉じる'); header.append(title, meta, closeButton); body = document.createElement('div'); body.className = 'body'; status = document.createElement('div'); status.className = 'status'; const footer = document.createElement('div'); footer.className = 'footer'; copyButton = document.createElement('button'); copyButton.type = 'button'; copyButton.className = 'btn'; copyButton.textContent = 'コピー'; regenerateButton = document.createElement('button'); regenerateButton.type = 'button'; regenerateButton.className = 'btn'; regenerateButton.textContent = '再生成'; footer.append(copyButton, regenerateButton); panel.append(header, body, status, footer); shadow.append(panel); closeButton.addEventListener('click', () => { close(); }); copyButton.addEventListener('click', () => { void copyToClipboard(); }); regenerateButton.addEventListener('click', () => { if (localLastSourceText) { requestTranslation(localLastSourceText); } }); } function setState(next) { state = next; if (panel) { panel.className = `panel state-${next}`; } updateControls(); } function updateControls() { const busy = state === POPOVER_STATES.LOADING || state === POPOVER_STATES.STREAMING; copyButton.disabled = busy || text.length === 0; regenerateButton.disabled = busy || !localLastSourceText; if (state === POPOVER_STATES.LOADING) status.textContent = '翻訳中…'; else if (state === POPOVER_STATES.STREAMING) status.textContent = '受信中…'; else if (state === POPOVER_STATES.COMPLETED) status.textContent = '完了'; else if (state === POPOVER_STATES.ERROR) status.textContent = 'エラー'; else status.textContent = ''; } /** * Show the popover near the given rect. * @param {DOMRect|{left:number,top:number,bottom:number,right:number}} rect */ function show(rect) { ensureDom(); currentRect = rect; panel.style.display = 'flex'; reposition(); } function reposition() { if (!currentRect || !panel) return; const width = 340; const max = window.innerWidth - 8; let left = currentRect.left; if (left + width > max) left = Math.max(4, max - width); if (left < 4) left = 4; let top = currentRect.bottom + 8; // If it would overflow the bottom, place it above the selection. const approxHeight = Math.min(320, Math.max(80, panel.getBoundingClientRect().height || 120)); if (top + approxHeight > window.innerHeight - 4) { top = Math.max(4, currentRect.top - approxHeight - 8); } panel.style.left = `${Math.round(left)}px`; panel.style.top = `${Math.round(top)}px`; } function isVisible() { return Boolean(panel && panel.style.display !== 'none' && state !== POPOVER_STATES.HIDDEN); } function repositionTo(rect) { currentRect = rect; reposition(); } function hideInternal() { if (panel) panel.style.display = 'none'; setState(POPOVER_STATES.HIDDEN); text = ''; activeRequestId = null; } function close() { if (activeRequestId) { portClient.cancel(activeRequestId); } hideInternal(); } /** * Begin a new translation request. * @param {string} sourceText * @param {DOMRect|{left:number,top:number,bottom:number,right:number}} rect */ function beginRequest(sourceText, rect) { ensureDom(); localLastSourceText = sourceText; text = ''; body.textContent = ''; show(rect); setState(POPOVER_STATES.LOADING); } /** @param {string} modelName */ function setModel(modelName) { if (meta) meta.textContent = modelName ? `Ollama / ${modelName}` : 'Ollama'; } /** @param {string} partial */ function appendChunk(partial) { if (!partial) return; text += partial; // Plain text rendering only (UI-04 / SEC-03). body.textContent = text; if (state !== POPOVER_STATES.STREAMING) setState(POPOVER_STATES.STREAMING); } function complete() { setState(POPOVER_STATES.COMPLETED); } /** @param {string} message */ function showError(message) { setState(POPOVER_STATES.ERROR); status.textContent = message || 'エラーが発生しました。'; } async function copyToClipboard() { if (!text) return; try { await navigator.clipboard.writeText(text); status.textContent = 'コピーしました'; } catch { // No execCommand fallback (deprecated): show failure only (Design §20). status.textContent = 'コピーできませんでした'; } } return { get state() { return state; }, isVisible, repositionTo, close, beginRequest, setModel, appendChunk, complete, showError }; })(); // --------------------------------------------------------------------- // 5. Port client // --------------------------------------------------------------------- const portClient = (() => { /** @type {object|null} */ let port = null; function connect() { if (port) return port; port = browser.runtime.connect({ name: PORT_NAME }); port.onMessage.addListener((message) => { if (!message || typeof message !== 'object' || typeof message.requestId !== 'string') return; if (message.requestId !== activeRequestId) return; // stale chunk switch (message.type) { case BG_TO_CONTENT.STARTED: popover.setModel(typeof message.model === 'string' ? message.model : ''); break; case BG_TO_CONTENT.CHUNK: if (typeof message.text === 'string') popover.appendChunk(message.text); break; case BG_TO_CONTENT.COMPLETED: popover.complete(); break; case BG_TO_CONTENT.ERROR: if (message.code !== ERROR_CODES.REQUEST_ABORTED) { popover.showError(typeof message.message === 'string' ? message.message : ''); } break; default: break; } }); port.onDisconnect.addListener(() => { port = null; }); return port; } return { /** * Start a translation request for the given text, showing the popover. * @param {string} text */ translate(text) { const p = connect(); const requestId = generateRequestId(); activeRequestId = requestId; lastSourceText = text; popover.beginRequest(text, currentSelectionRect || { left: 16, top: 16, bottom: 40, right: 356 }); p.postMessage({ type: CONTENT_TO_BG.TRANSLATE, requestId, text }); }, /** * @param {string} requestId */ cancel(requestId) { const p = port || connect(); p.postMessage({ type: CONTENT_TO_BG.CANCEL, requestId }); } }; })(); /** @returns {string} random request id */ function generateRequestId() { if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') { return globalThis.crypto.randomUUID(); } const bytes = new Uint8Array(16); globalThis.crypto.getRandomValues(bytes); return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } // --------------------------------------------------------------------- // Flow: icon click -> translate, regenerate // --------------------------------------------------------------------- function onTranslateClicked() { if (!enabled) return; const selection = readSelection(); if (!selection) return; currentSelectionRect = selection.rect; portClient.translate(selection.text); } /** * Request a translation for an arbitrary source text (regenerate). * @param {string} sourceText */ function requestTranslation(sourceText) { portClient.translate(sourceText); } // --------------------------------------------------------------------- // Enabled state (storage.local, EN-01/EN-02) // --------------------------------------------------------------------- function applyEnabled(next) { enabled = typeof next === 'boolean' ? next : true; if (!enabled) { actionButton.hide(); if (popover.state !== POPOVER_STATES.HIDDEN) { popover.close(); } } } function readEnabled() { try { browser.storage.local .get({ settings: null }) .then((result) => { const settings = result && result.settings; applyEnabled(settings && typeof settings.enabled === 'boolean' ? settings.enabled : true); }) .catch(() => {}); } catch { // storage may be unavailable early; default stays enabled } } try { browser.storage.onChanged.addListener((changes, areaName) => { if (areaName !== 'local' || !changes.settings) return; const next = changes.settings.newValue; applyEnabled(next && typeof next.enabled === 'boolean' ? next.enabled : true); }); } catch { // ignore } // --------------------------------------------------------------------- // 6. Entry point // --------------------------------------------------------------------- window.addEventListener('selectionchange', onSelectionEvent, { passive: true }); document.addEventListener('mouseup', onSelectionEvent, { passive: true }); document.addEventListener('keyup', onSelectionEvent, { passive: true }); // Reposition or hide UI when the viewport changes (Design 5.4). window.addEventListener( 'scroll', () => { if (popover.state === POPOVER_STATES.HIDDEN) { actionButton.hide(); return; } const selection = readSelection(); if (selection) { currentSelectionRect = selection.rect; actionButton.show(selection.rect); popover.repositionTo(selection.rect); } else { actionButton.hide(); } }, { passive: true, capture: true } ); window.addEventListener( 'resize', () => { if (popover.state !== POPOVER_STATES.HIDDEN) { const selection = readSelection(); if (selection) popover.repositionTo(selection.rect); } }, { passive: true } ); readEnabled(); })();