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

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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}`
  );
})();