Shift Translator Hover Toggle + Selection Tooltip (Chrome Translator API)

Hover element + modifier key to toggle translation. Select text + modifier key for tooltip translation.

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         Shift Translator Hover Toggle + Selection Tooltip (Chrome Translator API)
// @namespace    https://example.com/
// @version      1.2.0
// @description  Hover element + modifier key to toggle translation. Select text + modifier key for tooltip translation.
// @author       Link Chen
// @license      MIT
// @match        *://*/*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(() => {
  'use strict';

  /*
   * =========================================================
   * Debug
   * =========================================================
   */

  const DEBUG = false;

  /*
   * =========================================================
   * Config
   * =========================================================
   */

  const SOURCE_LANGUAGE = 'en';
  const TARGET_LANGUAGE_CANDIDATES = ['zh-CN', 'zh', 'zh-Hans'];

  const PARAGRAPH_SELECTOR = `
    article p,
    article li,
    article blockquote,
    article div,
    article span,

    main p,
    main li,
    main blockquote,
    main div,
    main span,

    .markdown-body p,
    .markdown-body li,
    .markdown-body blockquote,
    .markdown-body div,
    .markdown-body span,

    p,
    blockquote,
    div,
    span
  `;

  const EXCLUDED_SELECTOR = [
    'script',
    'style',
    'noscript',
    'textarea',
    'code',
    'pre',
    'kbd',
    'samp',
    'svg',
    'canvas',
    'nav',
    'header',
    'footer',
    'button',
    'input',
    'select',
    'option',
    'img',
    '[role="navigation"]',
    '[translate="no"]',
    '[data-tm-no-translate="1"]',
  ].join(',');

  const TRANSLATED_COPY_ATTR = 'data-tm-translated-copy';
  const TRANSLATED_FROM_ATTR = 'data-tm-translated-from';
  const SOURCE_ID_ATTR = 'data-tm-source-id';

  const MODIFIER_STORAGE_KEY = 'tm_modifier_keys';
  const DEFAULT_MODIFIER_KEYS = ['shift'];

  const DEBUG_OUTLINE_CLASS = 'tm-debug-outline';

  /*
   * =========================================================
   * State
   * =========================================================
   */

  const translatorCache = new Map();

  let modifierKeys = loadModifierKeys();

  let activeToast = null;
  let toastTimer = null;

  let hoveredParagraph = null;
  let tooltipState = null;
  let modifierModalOverlay = null;

  /*
   * =========================================================
   * Style
   * =========================================================
   */

  const style = document.createElement('style');

  style.textContent = `
    @keyframes tm-spin {
      from { transform: rotate(0deg); }
      to { transform: rotate(360deg); }
    }

    .tm-spinner {
      width: 16px;
      height: 16px;
      border-radius: 999px;
      border: 2px solid rgba(0,0,0,.16);
      border-top-color: rgba(0,0,0,.72);
      animation: tm-spin .8s linear infinite;
      flex: 0 0 auto;
    }

    .tm-loading-row {
      display: inline-flex;
      align-items: center;
      gap: 8px;
      padding: 4px 0;
      color: rgba(0,0,0,.62);
      font: 13px/1.4 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
    }

    .${DEBUG_OUTLINE_CLASS} {
      outline: 2px solid rgba(0,128,255,.65) !important;
      outline-offset: 2px !important;
      background: rgba(0,128,255,.04) !important;
    }
  `;

  document.documentElement.appendChild(style);

  /*
   * =========================================================
   * Utils
   * =========================================================
   */

  function uid() {
    return `tm_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
  }

  function isElement(value) {
    return value && value.nodeType === Node.ELEMENT_NODE;
  }

  function isEditableTarget(target) {
    if (!target) return false;

    const tag = target.tagName;

    if (
      tag === 'INPUT' ||
      tag === 'TEXTAREA'
    ) {
      return true;
    }

    if (target.isContentEditable) {
      return true;
    }

    if (
      target.closest?.(
        '[contenteditable="true"]'
      )
    ) {
      return true;
    }

    return false;
  }

  function isParagraphLike(el) {

    if (
      !isElement(el) ||
      !el.matches(PARAGRAPH_SELECTOR)
    ) {
      return false;
    }

    if (el.closest(EXCLUDED_SELECTOR)) {
      return false;
    }

    const text =
      el.innerText?.trim() || '';

    // ignore tiny texts
    if (text.length < 12) {
      return false;
    }

    // ignore huge container blocks
    const childCount =
      el.children?.length || 0;

    if (childCount > 8) {
      return false;
    }

    // ignore giant layout containers
    const rect =
      el.getBoundingClientRect();

    if (
      rect.width > window.innerWidth * 0.9 &&
      rect.height > 300
    ) {
      return false;
    }

    // ignore deep wrappers
    const hasNestedParagraph =
      el.querySelector?.(
        'p, article, main, section'
      );

    if (
      hasNestedParagraph &&
      childCount > 2
    ) {
      return false;
    }

    return true;
  }

  function normalizeModifierKeys(input) {
    if (!input) {
      return [...DEFAULT_MODIFIER_KEYS];
    }

    const allowed = ['shift', 'control', 'command'];

    const parts = input
      .toLowerCase()
      .split('+')
      .map(v => v.trim())
      .filter(Boolean);

    const unique = [...new Set(parts)];
    const valid = unique.filter(v => allowed.includes(v));

    return valid.length
      ? valid
      : [...DEFAULT_MODIFIER_KEYS];
  }

  function loadModifierKeys() {
    try {
      const raw = GM_getValue(
        MODIFIER_STORAGE_KEY,
        ''
      );

      if (!raw) {
        return [...DEFAULT_MODIFIER_KEYS];
      }

      return normalizeModifierKeys(raw);
    } catch {
      return [...DEFAULT_MODIFIER_KEYS];
    }
  }

  function saveModifierKeys(keys) {
    modifierKeys = normalizeModifierKeys(
      keys.join('+')
    );

    GM_setValue(
      MODIFIER_STORAGE_KEY,
      modifierKeys.join('+')
    );
  }

  function isModifierMatch(event) {
    const pressed = [];

    if (event.shiftKey) pressed.push('shift');
    if (event.ctrlKey) pressed.push('control');
    if (event.metaKey) pressed.push('command');

    if (pressed.length !== modifierKeys.length) {
      return false;
    }

    return modifierKeys.every(k => pressed.includes(k));
  }

  function showToast(
    message,
    ms = 1600,
    isError = false
  ) {
    if (!activeToast) {
      activeToast = document.createElement('div');

      activeToast.style.cssText = `
        position:fixed;
        left:16px;
        bottom:16px;
        z-index:2147483647;
        max-width:min(520px,calc(100vw - 32px));
        padding:10px 12px;
        border-radius:10px;
        color:#fff;
        font:13px/1.4 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
        box-shadow:0 8px 30px rgba(0,0,0,.28);
        white-space:pre-wrap;
        pointer-events:none;
      `;

      document.documentElement.appendChild(
        activeToast
      );
    }

    activeToast.textContent = message;

    activeToast.style.background = isError
      ? 'rgba(176,0,32,.94)'
      : 'rgba(20,20,20,.92)';

    activeToast.style.display = 'block';

    clearTimeout(toastTimer);

    toastTimer = setTimeout(() => {
      if (activeToast) {
        activeToast.style.display = 'none';
      }
    }, isError ? 5000 : ms);
  }

  function showErrorToast(err) {
    const message =
      err?.message ||
      String(err) ||
      'Translation failed.';

    console.error('[TM Translator]', err);

    showToast(message, 5000, true);
  }

  function createLoadingRow(text = 'Translating...') {
    const row = document.createElement('div');

    row.className = 'tm-loading-row';

    const spinner = document.createElement('span');
    spinner.className = 'tm-spinner';

    const label = document.createElement('span');
    label.textContent = text;

    row.appendChild(spinner);
    row.appendChild(label);

    return row;
  }

  /*
   * =========================================================
   * Translator
   * =========================================================
   */

  async function getTranslator() {

    if (!('Translator' in self)) {
      throw new Error(
        'Translator API is not available.'
      );
    }

    for (const targetLanguage of TARGET_LANGUAGE_CANDIDATES) {

      const cacheKey =
        `${SOURCE_LANGUAGE}->${targetLanguage}`;

      if (translatorCache.has(cacheKey)) {
        return translatorCache.get(cacheKey);
      }

      let availability;

      try {

        availability =
          await Translator.availability({
            sourceLanguage: SOURCE_LANGUAGE,
            targetLanguage,
          });

      } catch {
        continue;
      }

      if (
        availability !== 'available' &&
        availability !== 'downloadable'
      ) {
        continue;
      }

      const promise = Translator.create({
        sourceLanguage: SOURCE_LANGUAGE,
        targetLanguage,
      });

      translatorCache.set(cacheKey, promise);

      try {
        return await promise;
      } catch {
        translatorCache.delete(cacheKey);
      }
    }

    throw new Error(
      'No supported translator available.'
    );
  }

  async function translatePlainText(text) {
    const translator = await getTranslator();
    return translator.translate(text);
  }

  /*
   * =========================================================
   * Hover Translate
   * =========================================================
   */

  function getParagraphFromPoint(event) {

    if (typeof document.elementsFromPoint === 'function') {

      const stack =
        document.elementsFromPoint(
          event.clientX,
          event.clientY
        );

      for (const el of stack) {

        if (!isElement(el)) continue;

        if (
          el.closest(
            `[${TRANSLATED_COPY_ATTR}="1"]`
          )
        ) {
          continue;
        }

        const paragraph =
          el.closest(PARAGRAPH_SELECTOR);

        if (
          paragraph &&
          isParagraphLike(paragraph)
        ) {
          return paragraph;
        }
      }
    }

    return null;
  }

  function updateDebugOutline(nextParagraph) {

    if (!DEBUG) return;

    if (
      hoveredParagraph &&
      hoveredParagraph !== nextParagraph
    ) {
      hoveredParagraph.classList.remove(
        DEBUG_OUTLINE_CLASS
      );
    }

    if (nextParagraph) {
      nextParagraph.classList.add(
        DEBUG_OUTLINE_CLASS
      );
    }
  }

  function setHoveredParagraph(nextParagraph) {

    updateDebugOutline(nextParagraph);

    hoveredParagraph =
      nextParagraph || null;
  }

  function stripDuplicateIds(root) {

    if (!root) return;

    if (root.hasAttribute?.('id')) {
      root.removeAttribute('id');
    }

    root.querySelectorAll?.('[id]')
      .forEach(el => el.removeAttribute('id'));
  }

  function collectTranslatableTextNodes(root) {

    const nodes = [];

    const walker =
      document.createTreeWalker(
        root,
        NodeFilter.SHOW_TEXT,
        {
          acceptNode(node) {

            if (!node?.nodeValue?.trim()) {
              return NodeFilter.FILTER_REJECT;
            }

            const parent =
              node.parentElement;

            if (!parent) {
              return NodeFilter.FILTER_REJECT;
            }

            if (
              parent.closest(EXCLUDED_SELECTOR)
            ) {
              return NodeFilter.FILTER_REJECT;
            }

            return NodeFilter.FILTER_ACCEPT;
          },
        }
      );

    let current;

    while ((current = walker.nextNode())) {
      nodes.push(current);
    }

    return nodes;
  }

  async function translateCloneTree(clone) {

    const translator =
      await getTranslator();

    const textNodes =
      collectTranslatableTextNodes(clone);

    for (const node of textNodes) {

      const text =
        node.nodeValue;

      if (!text?.trim()) continue;

      try {

        const translated =
          await translator.translate(text);

        if (translated) {
          node.nodeValue = translated;
        }

      } catch (err) {
        console.warn('[TM Translator]', err);
      }
    }
  }

  function findExistingTranslation(original) {

    const sourceId =
      original.getAttribute(
        SOURCE_ID_ATTR
      );

    if (!sourceId) return null;

    const sibling =
      original.nextElementSibling;

    if (
      sibling &&
      sibling.getAttribute(
        TRANSLATED_COPY_ATTR
      ) === '1' &&
      sibling.getAttribute(
        TRANSLATED_FROM_ATTR
      ) === sourceId
    ) {
      return sibling;
    }

    return null;
  }

  async function toggleTranslation(original) {

    let sourceId =
      original.getAttribute(
        SOURCE_ID_ATTR
      );

    if (!sourceId) {

      sourceId = uid();

      original.setAttribute(
        SOURCE_ID_ATTR,
        sourceId
      );
    }

    const existing =
      findExistingTranslation(original);

    if (existing) {

      existing.remove();

      showToast(
        'Translation hidden.'
      );

      return;
    }

    const loading =
      createLoadingRow(
        'Translating...'
      );

    original.insertAdjacentElement(
      'afterend',
      loading
    );

    const clone =
      original.cloneNode(true);

    stripDuplicateIds(clone);

    clone.style.opacity = '0.5';
    clone.style.marginTop = '4px';
    clone.style.marginBottom = '0';

    clone.setAttribute(
      TRANSLATED_COPY_ATTR,
      '1'
    );

    clone.setAttribute(
      TRANSLATED_FROM_ATTR,
      sourceId
    );

    try {

      await translateCloneTree(clone);

      if (loading.isConnected) {
        loading.replaceWith(clone);
      }

      showToast(
        'Translation shown.'
      );

    } catch (err) {

      loading.remove();

      showErrorToast(err);

      throw err;
    }
  }

  /*
   * =========================================================
   * Tooltip Translate
   * =========================================================
   */

  function getSelectedText() {

    const selection =
      window.getSelection?.();

    if (
      selection &&
      !selection.isCollapsed
    ) {

      const text =
        selection.toString();

      if (text?.trim()) {

        const range =
          selection.getRangeAt(0);

        return {
          text,
          rect:
            range.getBoundingClientRect(),
        };
      }
    }

    return null;
  }

  function closeTooltip() {

    if (!tooltipState) return;

    const {
      tooltip,
      onPointerDown,
      onBlur,
      onVisibilityChange,
    } = tooltipState;

    document.removeEventListener(
      'pointerdown',
      onPointerDown,
      true
    );

    window.removeEventListener(
      'blur',
      onBlur,
      true
    );

    document.removeEventListener(
      'visibilitychange',
      onVisibilityChange,
      true
    );

    tooltip.remove();

    tooltipState = null;
  }

  async function openSelectionTooltip(
    selectionData
  ) {

    try {

      if (!selectionData?.text?.trim()) {
        return;
      }

      closeTooltip();

      const tooltip =
        document.createElement('div');

      tooltip.style.cssText = `
        position:fixed;
        z-index:2147483647;
        max-width:min(520px,calc(100vw - 24px));
        min-width:280px;
        max-height:calc(100vh - 24px);
        overflow:auto;
        background:#fff;
        color:#111;
        border-radius:14px;
        box-shadow:0 16px 48px rgba(0,0,0,.22);
        border:1px solid rgba(0,0,0,.08);
        padding:14px;
        font:14px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
        word-break:break-word;
        box-sizing:border-box;
        visibility:hidden;
      `;

      const title =
        document.createElement('div');

      title.style.cssText = `
        font-size:12px;
        font-weight:700;
        text-transform:uppercase;
        letter-spacing:.04em;
        margin-bottom:10px;
        color:rgba(0,0,0,.48);
      `;

      title.textContent =
        'Translation';

      const content =
        document.createElement('div');

      content.style.whiteSpace =
        'pre-wrap';

      content.appendChild(
        createLoadingRow(
          'Translating...'
        )
      );

      tooltip.appendChild(title);
      tooltip.appendChild(content);

      document.documentElement.appendChild(
        tooltip
      );

      const margin = 12;

      const viewportW =
        window.visualViewport?.width ||
        window.innerWidth;

      const viewportH =
        window.visualViewport?.height ||
        window.innerHeight;

      const viewportLeft =
        window.visualViewport?.offsetLeft || 0;

      const viewportTop =
        window.visualViewport?.offsetTop || 0;

      function positionTooltip() {

        const rect =
          selectionData.rect;

        const tipRect =
          tooltip.getBoundingClientRect();

        const tipW = Math.min(
          tipRect.width,
          viewportW - margin * 2
        );

        const tipH = Math.min(
          tipRect.height,
          viewportH - margin * 2
        );

        const spaceBelow =
          viewportH -
          (rect.bottom - viewportTop) -
          margin;

        const spaceAbove =
          (rect.top - viewportTop) -
          margin;

        let top;

        if (
          spaceBelow >= tipH ||
          spaceBelow >= spaceAbove
        ) {

          top = Math.min(
            rect.bottom + 12,
            viewportTop +
              viewportH -
              tipH -
              margin
          );

        } else {

          top = Math.max(
            viewportTop + margin,
            rect.top - tipH - 12
          );
        }

        let left =
          rect.left - viewportLeft;

        left = Math.min(
          left,
          viewportW -
            tipW -
            margin
        );

        left = Math.max(
          margin,
          left
        );

        tooltip.style.left =
          `${left + viewportLeft}px`;

        tooltip.style.top =
          `${top}px`;

        tooltip.style.visibility =
          'visible';
      }

      requestAnimationFrame(
        positionTooltip
      );

      const onPointerDown =
        event => {
          if (
            !tooltip.contains(
              event.target
            )
          ) {
            closeTooltip();
          }
        };

      const onBlur = () => {
        closeTooltip();
      };

      const onVisibilityChange =
        () => {
          if (document.hidden) {
            closeTooltip();
          }
        };

      document.addEventListener(
        'pointerdown',
        onPointerDown,
        true
      );

      window.addEventListener(
        'blur',
        onBlur,
        true
      );

      document.addEventListener(
        'visibilitychange',
        onVisibilityChange,
        true
      );

      tooltipState = {
        tooltip,
        onPointerDown,
        onBlur,
        onVisibilityChange,
      };

      const translated =
        await translatePlainText(
          selectionData.text
        );

      if (!tooltipState) return;

      content.textContent =
        translated || '';

      requestAnimationFrame(() => {

        if (!tooltipState) return;

        positionTooltip();
      });

    } catch (err) {
      showErrorToast(err);
    }
  }

  /*
   * =========================================================
   * Settings Modal
   * =========================================================
   */

  function openModifierSettingsModal() {

    try {

      if (
        modifierModalOverlay?.isConnected
      ) {
        return;
      }

      const overlay =
        document.createElement('div');

      modifierModalOverlay =
        overlay;

      overlay.style.cssText = `
        position:fixed;
        inset:0;
        z-index:2147483647;
        background:rgba(0,0,0,.18);
        display:flex;
        align-items:center;
        justify-content:center;
      `;

      const modal =
        document.createElement('div');

      modal.style.cssText = `
        width:420px;
        background:#fff;
        border-radius:16px;
        padding:20px;
        box-shadow:0 20px 60px rgba(0,0,0,.25);
        font:14px/1.5 system-ui;
        box-sizing:border-box;
      `;

      const title =
        document.createElement('div');

      title.style.cssText = `
        font-size:18px;
        font-weight:700;
        margin-bottom:12px;
      `;

      title.textContent =
        'Modifier Key Settings';

      const desc =
        document.createElement('div');

      desc.style.cssText = `
        margin-bottom:12px;
        color:#666;
      `;

      desc.textContent =
        'Allowed: shift / control / command';

      const input =
        document.createElement('input');

      input.id =
        'tm-modifier-input';

      input.value =
        modifierKeys.join('+');

      input.style.cssText = `
        width:100%;
        padding:10px 12px;
        border-radius:10px;
        border:1px solid rgba(0,0,0,.12);
        box-sizing:border-box;
        font-size:14px;
      `;

      const actions =
        document.createElement('div');

      actions.style.cssText = `
        display:flex;
        justify-content:flex-end;
        margin-top:16px;
        gap:8px;
      `;

      const cancelBtn =
        document.createElement('button');

      cancelBtn.textContent =
        'Cancel';

      const saveBtn =
        document.createElement('button');

      saveBtn.textContent =
        'Save';

      actions.appendChild(cancelBtn);
      actions.appendChild(saveBtn);

      modal.appendChild(title);
      modal.appendChild(desc);
      modal.appendChild(input);
      modal.appendChild(actions);

      overlay.appendChild(modal);

      document.documentElement.appendChild(
        overlay
      );

      const close = () => {

        modifierModalOverlay =
          null;

        overlay.remove();
      };

      overlay.addEventListener(
        'click',
        e => {
          if (e.target === overlay) {
            close();
          }
        }
      );

      cancelBtn.addEventListener(
        'click',
        close
      );

      saveBtn.addEventListener(
        'click',
        () => {

          try {

            const normalized =
              normalizeModifierKeys(
                input.value
              );

            saveModifierKeys(
              normalized
            );

            showToast(
              `Modifier updated: ${normalized.join('+')}`
            );

            close();

          } catch (err) {
            showErrorToast(err);
          }
        }
      );

    } catch (err) {
      showErrorToast(err);
    }
  }

  /*
   * =========================================================
   * Events
   * =========================================================
   */

  function handlePointerMove(event) {

    try {

      const paragraph =
        getParagraphFromPoint(event);

      setHoveredParagraph(
        paragraph &&
        isParagraphLike(paragraph)
          ? paragraph
          : null
      );

    } catch (err) {
      showErrorToast(err);
    }
  }

  async function handleKeyDown(event) {

    try {

      if (
        isEditableTarget(
          event.target
        )
      ) {
        return;
      }

      const isSettingsShortcut =
        event.code === 'Comma' &&
        (
          (
            event.ctrlKey &&
            event.shiftKey
          ) ||
          (
            event.metaKey &&
            event.shiftKey
          )
        );

      if (isSettingsShortcut) {

        event.preventDefault();
        event.stopPropagation();

        // toggle modal
        if (
          modifierModalOverlay?.isConnected
        ) {

          modifierModalOverlay.remove();
          modifierModalOverlay = null;

        } else {

          openModifierSettingsModal();
        }

        return;
      }

      if (!isModifierMatch(event)) {
        return;
      }

      if (event.repeat) {
        return;
      }

      const selectionData =
        getSelectedText();

      if (selectionData) {

        event.preventDefault();
        event.stopImmediatePropagation();

        await openSelectionTooltip(
          selectionData
        );

        return;
      }

      if (
        !hoveredParagraph ||
        !isParagraphLike(
          hoveredParagraph
        )
      ) {
        return;
      }

      event.preventDefault();
      event.stopImmediatePropagation();

      await toggleTranslation(
        hoveredParagraph
      );

    } catch (err) {
      showErrorToast(err);
    }
  }

  document.addEventListener(
    'pointermove',
    handlePointerMove,
    true
  );

  document.addEventListener(
    'keydown',
    handleKeyDown,
    true
  );

  console.log(
    `[TM Translator] Loaded. DEBUG=${DEBUG}`
  );
})();