/* * 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; // --------------------------------------------------------------------- // 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; display: block; } .wrap { position: fixed; z-index: 2147483647; display: none; width: 28px; height: 28px; } /* The clickable element is the SVG itself with explicit width/height attributes. A