summaryrefslogtreecommitdiff
path: root/src/content
diff options
context:
space:
mode:
Diffstat (limited to 'src/content')
-rw-r--r--src/content/content.css10
-rw-r--r--src/content/content.js676
2 files changed, 686 insertions, 0 deletions
diff --git a/src/content/content.css b/src/content/content.css
new file mode 100644
index 0000000..169534d
--- /dev/null
+++ b/src/content/content.css
@@ -0,0 +1,10 @@
+/*
+ * Content script styles. Injected by manifest.json (content_scripts.css).
+ * Styles for the Shadow DOM hosts live inside each shadow root; these rules
+ * only neutralize layout impact of the host elements.
+ */
+
+.ollama-translations-host {
+ all: initial;
+ display: contents;
+} \ No newline at end of file
diff --git a/src/content/content.js b/src/content/content.js
new file mode 100644
index 0000000..e1e6823
--- /dev/null
+++ b/src/content/content.js
@@ -0,0 +1,676 @@
+/*
+ * 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();
+})(); \ No newline at end of file